From 9fb64a41cae7e48f0a1cd2d43e2f170a4e45625e Mon Sep 17 00:00:00 2001
From: digital-garbage <133010426+digital-garbage@users.noreply.github.com>
Date: Sat, 22 Aug 2026 22:24:55 +0300
Subject: [PATCH] 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.
---
CHANGELOG.md | 10 +
movie_editor/frontend/index.html | 2 +
movie_editor/frontend/models.js | 38 +++-
movie_editor/frontend/pinned_buttons.js | 211 +++++++++++++++++++
movie_editor/frontend/pinned_buttons.test.js | 51 +++++
movie_editor/frontend/settings_window.js | 29 ++-
movie_editor/frontend/store.js | 4 +
movie_editor/frontend/styles.css | 29 +++
8 files changed, 371 insertions(+), 3 deletions(-)
create mode 100644 movie_editor/frontend/pinned_buttons.js
create mode 100644 movie_editor/frontend/pinned_buttons.test.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6045ddc0..2f3a1962 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html
index 994da04e..f4032a3f 100644
--- a/movie_editor/frontend/index.html
+++ b/movie_editor/frontend/index.html
@@ -98,6 +98,7 @@
+
β Composer
@@ -130,6 +131,7 @@
+
diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js
index 8b6f57a4..67d5d15a 100644
--- a/movie_editor/frontend/models.js
+++ b/movie_editor/frontend/models.js
@@ -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: ' ',
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(() => {});
diff --git a/movie_editor/frontend/pinned_buttons.js b/movie_editor/frontend/pinned_buttons.js
new file mode 100644
index 00000000..5a0ed7b3
--- /dev/null
+++ b/movie_editor/frontend/pinned_buttons.js
@@ -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 };
+})();
diff --git a/movie_editor/frontend/pinned_buttons.test.js b/movie_editor/frontend/pinned_buttons.test.js
new file mode 100644
index 00000000..f2ee736a
--- /dev/null
+++ b/movie_editor/frontend/pinned_buttons.test.js
@@ -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]);
+});
diff --git a/movie_editor/frontend/settings_window.js b/movie_editor/frontend/settings_window.js
index 5a28e543..7854bee3 100644
--- a/movie_editor/frontend/settings_window.js
+++ b/movie_editor/frontend/settings_window.js
@@ -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 = {}) {
diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js
index 590be185..124d3348 100644
--- a/movie_editor/frontend/store.js
+++ b/movie_editor/frontend/store.js
@@ -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 {
diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css
index 1ebdca9a..d3c7b67d 100644
--- a/movie_editor/frontend/styles.css
+++ b/movie_editor/frontend/styles.css
@@ -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. */