feat(editor): pin up to three Settings shortcuts to the toolbar

Slot number is the address: holes close up, surviving buttons keep their
numbers, so a shortcut never moves. Pinning with a node open pins the node.
This commit is contained in:
digital-garbage
2026-08-22 22:24:55 +03:00
parent 857215bca7
commit 9fb64a41ca
8 changed files with 371 additions and 3 deletions
+10
View File
@@ -3,6 +3,16 @@
## [Unreleased]
### Added
- **Pin up to three shortcuts to the timeline toolbar.** Settings is a window with a sidebar,
and its useful places — a specific node's page, one Engine category — are several clicks
deep: fine to walk once, tedious while dialling something in. `📌 Pin to a button` in the
Settings header puts whatever is open into one of three slots, chosen from a dialog that
shows what each slot currently holds and asks before replacing one. Pinning while a node
is open pins the NODE, not the section it lives in. Slot 1 is always the leftmost button
and slot 3 the one nearest Assets; an empty slot closes up and the remaining buttons keep
their own numbers, so a shortcut's position never moves. Hovering says where it leads.
Pins ride with the project, like the other editor preferences.
- **Reverse a clip**, audio included, in `+ Add → Effects`. Unlike the other clip effects
there is no preview-side equivalent, so the monitor switches that clip to a server-rendered
segment — what you watch is the reversed encode, not a forward one standing in for it.
+2
View File
@@ -98,6 +98,7 @@
<div class="transport-actions" id="transport-actions" aria-label="Generate and render"></div>
<div class="zone-head-right">
<span id="timeline-meta"></span>
<nav class="dock-tabs pinned-tabs" id="pinned-tabs" aria-label="Pinned shortcuts"></nav>
<nav class="dock-tabs" id="dock-tabs" aria-label="Workspace panels"></nav>
<button class="composer-btn" id="composer-btn" type="button" title="Open the Composer — global prompt, shortcuts, split markers">
<span class="composer-dot">◆</span> Composer
@@ -130,6 +131,7 @@
<script src="menubar.js"></script>
<script src="actionbar.js"></script>
<script src="layout.js"></script>
<script src="pinned_buttons.js"></script>
<script src="mediabrowser.js"></script>
<script src="log.js"></script>
<script src="player.js"></script>
+37 -1
View File
@@ -2619,6 +2619,10 @@
if (dirty) { try { await persist(); } catch (_) {} }
}
// A pinned button can ask for a node before the section has ever mounted, and the slots
// only exist after loadAll(). Held here and applied once they do.
let _openNodeOnMount = null;
function mount(body, ctx) {
container = el("div", "models-mount");
container.append(el("div", "pj-meta models-loading", "Loading models & pipeline…"));
@@ -2633,7 +2637,19 @@
refresh.onclick = refreshList;
ctx.setActions([imp, refresh]);
loadAll()
.then(() => { if (container && container.isConnected) render(); })
.then(() => {
if (!container || !container.isConnected) return;
const pending = _openNodeOnMount; _openNodeOnMount = null;
if (pending && slotById(pending)) { _setView("node:" + pending); return; }
render();
if (pending) {
// Landing on the pipeline instead, without saying why, would look like the
// button simply did the wrong thing.
alert("That node is not in this project's pipeline.\n\n"
+ "The pinned button still points at it — open the node you want and use "
+ "Pin to a button to repoint the slot.");
}
})
.catch(() => {
if (container && container.isConnected) {
clear(container);
@@ -2654,10 +2670,30 @@
iconBg: "linear-gradient(180deg,#b18cff,#7a4fd0)",
icon: '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="#fff" stroke-width="1.4" stroke-linejoin="round"><path d="M8 1.8 14 5v6l-6 3.2L2 11V5l6-3.2z"/><path d="M2 5l6 3 6-3M8 8v6.2"/></svg>',
mount,
// Pinning while a node is open should pin THAT node, not the section it lives in —
// the node page is the thing that takes several clicks to reach.
pinTarget: () => {
if (!view.startsWith("node:")) return null;
const slot = slotById(view.slice(5));
if (!slot) return null;
return { kind: "node", id: slot.id, label: slotDisplayLabel(slot) };
},
});
window.ModelsModal = {
open: () => window.SettingsWindow.open("models"),
// Open Models & Pipeline showing ONE node. Used by pinned buttons; safe to call
// whether or not the section has been mounted before.
openNode: (slotId) => {
if (!slotId) return;
if (container && container.isConnected && slotById(slotId)) {
window.SettingsWindow.open("models");
setView("node:" + slotId);
return;
}
_openNodeOnMount = slotId;
window.SettingsWindow.open("models");
},
refresh: async () => {
await ensureRoles().catch(() => {});
await doFullRefresh().catch(() => {});
+211
View File
@@ -0,0 +1,211 @@
// Up to three user-chosen shortcuts, sitting left of the workspace toggles.
//
// Settings is a window with a sidebar, and the useful places inside it — a specific node's
// page, one Engine category — are several clicks deep. That is fine to walk once and
// tedious to walk twenty times while dialling something in. A pin puts any of them one
// click away.
//
// Slot number is the address: slot 1 is always the leftmost button, slot 3 always the one
// nearest Assets, whether or not the slots between them are filled. So the position of a
// shortcut never moves as others come and go, and muscle memory survives.
(function () {
const SLOTS = 3;
const KEY = "pinnedButtons"; // stored in editor settings: travels with the project
// ── slot rules (pure — the part with actual rules in it) ──────────────────────
/** Exactly SLOTS entries, so slot N is index N-1 and a hole stays a hole.
* Anything malformed becomes an empty slot rather than a button that cannot work. */
function normalizePins(raw) {
const out = Array.isArray(raw) ? raw.slice(0, SLOTS) : [];
while (out.length < SLOTS) out.push(null);
return out.map((p) => (p && typeof p === "object" && p.kind && p.id ? p : null));
}
/** Buttons to draw, left to right: holes close up, but each surviving button keeps the
* NUMBER of the slot it occupies. Slots 1+3 filled draws "Custom 1, Custom 3" — so a
* shortcut's position never shifts because a neighbour came or went. */
function visibleSlots(list) {
return normalizePins(list)
.map((target, i) => ({ slotNo: i + 1, target }))
.filter((e) => e.target);
}
/** The same destination twice is a wasted slot. Placing it in `index` clears any other
* slot pointing at the same thing. */
function placePin(list, index, target) {
const next = normalizePins(list);
next.forEach((p, j) => {
if (p && j !== index && p.kind === target.kind && p.id === target.id) next[j] = null;
});
next[index] = target;
return next;
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { SLOTS, normalizePins, visibleSlots, placePin };
}
if (typeof window === "undefined" || !window.dom || !window.Store) return;
const { el, clear } = window.dom;
const S = window.Store;
const host = document.getElementById("pinned-tabs");
if (!host) return;
function pins() {
return normalizePins(S.getEditorSetting ? S.getEditorSetting(KEY) : null);
}
function setPins(list) {
const next = normalizePins(list);
// Node ids are project-scoped, so this belongs with the project's own settings rather
// than in the browser alone — the same mirror that carries the rest of the editor
// preferences onto a freshly rented machine.
S.setEditorSetting(KEY, next);
render();
}
// ── describing a target ───────────────────────────────────────────────────────
function describe(t) {
if (!t) return "";
if (t.kind === "node") return t.label || "a node";
return t.label || t.id || "a settings page";
}
function tooltip(t, slotNo) {
if (!t) return `Custom ${slotNo} — nothing pinned yet`;
return `Opens up ${describe(t)}`;
}
// ── opening ───────────────────────────────────────────────────────────────────
function openTarget(t) {
if (!t) return;
if (t.kind === "node") {
// ModelsModal handles the "not in this pipeline any more" case, including the wait
// for the section to load before it can know.
window.ModelsModal?.openNode?.(t.id);
return;
}
if (!window.SettingsWindow?.hasSection?.(t.id)) {
alert(`"${describe(t)}" is not available in this build any more.\n\n`
+ "Pin something else to that slot to replace it.");
return;
}
window.SettingsWindow.open(t.id);
}
// ── the toolbar ───────────────────────────────────────────────────────────────
function render() {
if (!host) return;
clear(host);
visibleSlots(pins()).forEach(({ slotNo, target }) => {
// Deliberately NOT .on: that state means "this panel is showing", and a shortcut is
// not a toggle. .pinned-tab carries its own look.
const btn = el("button", "dock-tab pinned-tab", `Custom ${slotNo}`);
btn.type = "button";
btn.title = tooltip(target, slotNo);
btn.onclick = () => openTarget(target);
host.append(btn);
});
host.hidden = !host.children.length;
}
// ── the pin dialog ────────────────────────────────────────────────────────────
let dialog = null;
function closeDialog() { dialog?.remove(); dialog = null; }
function slotRow(i, target, current) {
const occupied = pins()[i];
const row = el("button", "pin-slot" + (occupied ? " occupied" : ""));
row.type = "button";
const left = el("div", "pin-slot-text");
left.append(el("div", "pin-slot-name", `Custom ${i + 1}`));
left.append(el("div", "pin-slot-sub", occupied ? `Opens up ${describe(occupied)}` : "Empty"));
row.append(left);
row.append(el("span", "pin-slot-pos", i === 0 ? "leftmost" : i === SLOTS - 1 ? "nearest Assets" : "middle"));
row.onclick = () => {
// Overwriting is the one destructive thing this dialog does, so it is the one thing
// it asks about.
if (occupied && !confirm(
`Custom ${i + 1} already opens ${describe(occupied)}.\n\n`
+ `Replace it with ${describe(current)}?`
)) return;
setPins(placePin(pins(), i, current));
closeDialog();
};
return row;
}
function pinCurrent() {
const current = window.SettingsWindow?.currentTarget?.();
if (!current) {
alert("Open the page or node you want to pin first.");
return;
}
openDialog(current);
}
function openDialog(current) {
closeDialog();
dialog = el("div", "modal-overlay pin-overlay");
const box = el("div", "modal pin-modal");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "Pin to a button"));
const closeBtn = el("button", "btn ghost tiny", "✕");
closeBtn.type = "button";
closeBtn.onclick = closeDialog;
const hr = el("div", "modal-head-right");
hr.append(closeBtn);
head.append(hr);
box.append(head);
const content = el("div", "modal-content");
content.append(el("div", "pin-lead", `Put ${describe(current)} on the timeline toolbar.`));
content.append(el("div", "pin-hint",
"Slot 1 is the leftmost button and slot 3 the one nearest Assets — an empty slot "
+ "closes up, and the buttons that remain keep their own numbers and places."));
const list = el("div", "pin-slots");
for (let i = 0; i < SLOTS; i++) list.append(slotRow(i, current, current));
content.append(list);
// Clearing has to live somewhere, and this is the only screen that shows the slots.
const filled = pins().filter(Boolean).length;
if (filled) {
const clearRow = el("div", "pin-clear-row");
pins().forEach((p, i) => {
if (!p) return;
const b = el("button", "btn ghost tiny", `Clear Custom ${i + 1}`);
b.type = "button";
b.onclick = () => {
const l = pins(); l[i] = null; setPins(l);
closeDialog();
};
clearRow.append(b);
});
content.append(clearRow);
}
box.append(content);
dialog.append(box);
dialog.addEventListener("click", (e) => { if (e.target === dialog) closeDialog(); });
document.body.append(dialog);
}
// Pins live in editor settings, which a project restores on open — so the toolbar has to
// follow the store rather than being built once at load.
let _last = "";
function syncFromStore() {
const now = JSON.stringify(pins());
if (now === _last) return;
_last = now;
render();
}
S.subscribe(syncFromStore);
render();
_last = JSON.stringify(pins());
window.PinnedButtons = { pinCurrent, render, openDialog };
})();
@@ -0,0 +1,51 @@
const test = require("node:test");
const assert = require("node:assert");
const { SLOTS, normalizePins, visibleSlots, placePin } = require("./pinned_buttons.js");
const A = { kind: "section", id: "engine", label: "Engine" };
const B = { kind: "node", id: "fp_lora", label: "FunPack LoRA Loader" };
const C = { kind: "section", id: "models", label: "Models & Pipeline" };
test("slots are always exactly three, so slot N is index N-1", () => {
assert.strictEqual(SLOTS, 3);
assert.deepStrictEqual(normalizePins(null), [null, null, null]);
assert.deepStrictEqual(normalizePins([A]), [A, null, null]);
assert.strictEqual(normalizePins([A, B, C, A]).length, 3);
});
test("a malformed entry becomes an empty slot, not a broken button", () => {
assert.deepStrictEqual(normalizePins([{ kind: "section" }, "junk", { id: "x" }]),
[null, null, null]);
assert.deepStrictEqual(normalizePins("not an array"), [null, null, null]);
});
test("holes close up but the buttons keep their own numbers", () => {
// The user's spec: slots 1 and 3 filled draws slot 1 then slot 3, with no gap and no
// renumbering — position is the address, so it must not shift when a neighbour goes.
assert.deepStrictEqual(visibleSlots([A, null, C]).map((e) => e.slotNo), [1, 3]);
assert.deepStrictEqual(visibleSlots([null, B, null]).map((e) => e.slotNo), [2]);
assert.deepStrictEqual(visibleSlots([A, B, C]).map((e) => e.slotNo), [1, 2, 3]);
assert.deepStrictEqual(visibleSlots([null, null, null]), []);
});
test("visible order is left to right by slot number", () => {
assert.deepStrictEqual(visibleSlots([A, null, C]).map((e) => e.target), [A, C]);
});
test("placing a pin fills the slot asked for", () => {
assert.deepStrictEqual(placePin([null, null, null], 1, B), [null, B, null]);
assert.deepStrictEqual(placePin([A, null, null], 0, B), [B, null, null]);
});
test("the same destination never occupies two slots", () => {
assert.deepStrictEqual(placePin([A, null, null], 2, A), [null, null, A]);
// Matching is by kind AND id: a node and a section could share an id string.
const nodeEngine = { kind: "node", id: "engine", label: "a node called engine" };
assert.deepStrictEqual(placePin([A, null, null], 2, nodeEngine), [A, null, nodeEngine]);
});
test("placing does not mutate the list it was given", () => {
const before = [A, null, null];
placePin(before, 1, B);
assert.deepStrictEqual(before, [A, null, null]);
});
+27 -2
View File
@@ -119,9 +119,14 @@
subEl = el("div", "sw-head-sub");
ht.append(titleEl, subEl);
actionsEl = el("div", "sw-head-actions");
const pin = el("button", "btn ghost tiny sw-pin", "📌 Pin to a button");
pin.type = "button";
pin.title = "Put a shortcut to whatever is open here on the timeline toolbar";
pin.onclick = () => window.PinnedButtons?.pinCurrent();
const x = el("button", "btn ghost tiny sw-close", "✕");
x.onclick = close;
head.append(ht, actionsEl, x);
head.append(ht, actionsEl, pin, x);
if (!window.PinnedButtons) pin.hidden = true;
bodyEl = el("div", "sw-body");
main.append(head, bodyEl);
@@ -171,7 +176,27 @@
function hasSection(id) { return registry.some((s) => s.id === id); }
window.SettingsWindow = { open, close, register, navItem, mountSection, hasSection };
/** What a pinned button should reopen to get back to what is on screen now.
*
* A section can override this (Models points at the open NODE rather than at itself),
* which is the whole value of the feature: the deep places are the slow ones to reach.
*/
function currentTarget() {
const spec = registry.find((s) => s.id === activeId);
if (!spec) return null;
let custom = null;
try { custom = typeof spec.pinTarget === "function" ? spec.pinTarget() : null; } catch (_) {}
return custom || { kind: "section", id: spec.id, label: spec.title };
}
/** Every section, for the pin dialog's "somewhere else" list. */
function sectionList() {
return orderedSections().map((s) => ({ id: s.id, title: s.title, group: s.group || "" }));
}
window.SettingsWindow = {
open, close, register, navItem, mountSection, hasSection, currentTarget, sectionList,
};
// ── shared row builders for settings panels ────────────────────────────
function actionRow(title, hint, btnLabel, onClick, opts = {}) {
+4
View File
@@ -52,6 +52,10 @@
// cycle. Carried here purely so the project can put it back on a new instance;
// the server copy stays the single source of truth while the app is running.
revolver: null, // {enabled, random} | null = never recorded
// Up to three toolbar shortcuts: [slot1, slot2, slot3], each null or
// {kind:"section"|"node", id, label}. Node ids are project-scoped, which is why these
// ride with the project rather than living in the browser alone.
pinnedButtons: null,
};
function _loadEditorSettings() {
try {
+29
View File
@@ -445,6 +445,35 @@ a.brand:hover .brand-sub { color: var(--accent-hi); }
.dock-tab.on { background: var(--accent-soft); color: var(--accent-hi); border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); }
.dock-tab:only-child { cursor: default; }
/* Pinned shortcuts: same chip shape as the panel toggles so the row reads as one strip,
with a leading dot marking them as "goes somewhere" rather than "shows a panel". */
.pinned-tabs { margin-left: 0; margin-right: 4px; }
.pinned-tab { color: var(--muted); border-style: dashed; cursor: pointer; }
.pinned-tab::before { content: "◆"; margin-right: 5px; color: var(--accent); font-size: 9px; }
.pinned-tab:hover { color: var(--text); border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); }
/* A lone pin is still a button — the panel-toggle rule that disables the last visible
panel must not reach into this nav. */
.pinned-tab:only-child { cursor: pointer; }
.pin-modal { max-width: 460px; }
.pin-lead { font-size: 13px; color: var(--text); margin-bottom: 4px; }
.pin-hint { font-size: 11px; color: var(--faint); line-height: 1.45; margin-bottom: 10px; }
.pin-slots { display: flex; flex-direction: column; gap: 6px; }
.pin-slot {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
width: 100%; text-align: left; cursor: pointer;
padding: 9px 11px; border-radius: 8px;
background: var(--ink-2); border: 1px solid var(--line);
transition: border-color .12s, background .12s;
}
.pin-slot:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); background: var(--ink-3); }
.pin-slot.occupied { border-style: solid; border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
.pin-slot-name { font-size: 12px; font-weight: 600; color: var(--text); }
.pin-slot-sub { font-size: 11px; color: var(--faint); margin-top: 2px; }
.pin-slot-pos { font-size: 10px; color: var(--faint); font-family: var(--font-mono); flex-shrink: 0; }
.pin-clear-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px;
padding-top: 10px; border-top: 1px solid var(--line); }
.zone-head {
/* min-height, not height: the right-hand status block wraps when the zone is narrow,
and a fixed height let it spill over the zone above AND the toolbar below. */