opencut_ui: shortcut autocomplete, Add-shortcut picker, anchor toggle + Editor settings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-19 22:57:06 +03:00
co-authored by Claude Opus 4.8
parent a1bf407e39
commit df3ac62e46
8 changed files with 403 additions and 3 deletions
+146
View File
@@ -0,0 +1,146 @@
// Shortcut autocomplete: as the user types in a prompt field, suggest matching
// shortcut triggers. Toggled by the "Prompt autocomplete" editor setting.
// Reusable: ShortcutAutocomplete.attach(textarea) — safe to call once per field.
(function () {
const { el } = window.dom;
const S = window.Store;
const DELIMS = /[,\n;]/; // token boundaries — a trigger lives between delimiters
const MIN_QUERY = 2;
const MAX_ITEMS = 8;
function enabled() { return !!S.getEditorSetting("autocomplete"); }
// Every (trigger, shortcut) pair — a shortcut with multiple triggers is searchable by each.
function triggerIndex() {
const out = [];
(S.get().shortcuts || []).forEach((s) => {
if (s.enabled === false) return;
(s.triggers || []).forEach((t) => {
const trig = String(t || "").trim();
if (trig) out.push({ trigger: trig, sc: s });
});
});
return out;
}
function matches(query) {
const q = query.toLowerCase();
const scored = [];
for (const e of triggerIndex()) {
const tl = e.trigger.toLowerCase();
const at = tl.indexOf(q);
if (at < 0) continue;
scored.push({ ...e, rank: at === 0 ? 0 : 1, pos: at });
}
scored.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.trigger.length - b.trigger.length);
// De-dupe identical triggers (same phrase shared by multiple shortcuts).
const seen = new Set();
const uniq = [];
for (const e of scored) {
const k = e.trigger.toLowerCase();
if (seen.has(k)) continue;
seen.add(k); uniq.push(e);
if (uniq.length >= MAX_ITEMS) break;
}
return uniq;
}
// The token currently under the caret: text since the last delimiter, left-trimmed.
function currentQuery(ta) {
const caret = ta.selectionStart;
if (caret == null || caret !== ta.selectionEnd) return null;
const text = ta.value.slice(0, caret);
let start = caret;
while (start > 0 && !DELIMS.test(text[start - 1])) start--;
while (start < caret && /\s/.test(text[start])) start++; // skip leading whitespace
const query = text.slice(start, caret);
if (query.trim().length < MIN_QUERY) return null;
return { start, end: caret, query };
}
function attach(ta) {
if (!ta || ta._acAttached) return;
ta._acAttached = true;
let menu = null;
let items = [];
let active = -1;
let span = null;
function close() {
if (menu) { menu.remove(); menu = null; }
items = []; active = -1; span = null;
}
function accept(i) {
if (!span || !items[i]) return;
const trig = items[i].trigger;
const v = ta.value;
ta.value = v.slice(0, span.start) + trig + v.slice(span.end);
const caret = span.start + trig.length;
ta.setSelectionRange(caret, caret);
close();
ta.dispatchEvent(new Event("input", { bubbles: true }));
ta.focus();
}
function paintActive() {
if (!menu) return;
[...menu.children].forEach((c, i) => c.classList.toggle("active", i === active));
const cur = menu.children[active];
if (cur) cur.scrollIntoView({ block: "nearest" });
}
function open(found, sp) {
close();
span = sp; items = found; active = 0;
menu = el("div", "ac-menu");
found.forEach((e, i) => {
const row = el("div", "ac-item" + (i === 0 ? " active" : ""));
row.append(el("span", "ac-trigger", e.trigger));
const rep = (e.sc.replacements || [])[0];
if (rep) row.append(el("span", "ac-prompt", rep));
const tag = [e.sc.category, e.sc.sub_category].filter(Boolean).join(" · ");
if (tag) row.append(el("span", "ac-cat", tag));
row.addEventListener("mousedown", (ev) => { ev.preventDefault(); accept(i); });
row.addEventListener("mouseenter", () => { active = i; paintActive(); });
menu.append(row);
});
document.body.append(menu);
position();
}
function position() {
if (!menu) return;
const r = ta.getBoundingClientRect();
menu.style.left = r.left + "px";
menu.style.top = (r.bottom + 2) + "px";
menu.style.minWidth = Math.min(r.width, 420) + "px";
menu.style.maxWidth = Math.max(r.width, 320) + "px";
}
function refresh() {
if (!enabled()) { close(); return; }
const sp = currentQuery(ta);
if (!sp) { close(); return; }
const found = matches(sp.query);
if (!found.length) { close(); return; }
open(found, sp);
}
ta.addEventListener("input", refresh);
ta.addEventListener("click", refresh);
ta.addEventListener("keydown", (e) => {
if (!menu) return;
if (e.key === "ArrowDown") { e.preventDefault(); active = (active + 1) % items.length; paintActive(); }
else if (e.key === "ArrowUp") { e.preventDefault(); active = (active - 1 + items.length) % items.length; paintActive(); }
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); accept(active); }
else if (e.key === "Escape") { e.preventDefault(); close(); }
});
ta.addEventListener("blur", () => setTimeout(close, 120));
window.addEventListener("scroll", () => { if (menu) position(); }, true);
}
window.ShortcutAutocomplete = { attach };
})();
+130 -1
View File
@@ -168,7 +168,14 @@
|| (pv && (pv.display_prompt != null ? pv.display_prompt : pv.combined_prompt)) || "";
const val = gpDraft != null ? gpDraft : live;
wrap.append(el("div", "lib-form-title", "Global prompt"));
const titleRow = el("div", "compose-head");
titleRow.append(el("div", "lib-form-title", "Global prompt"));
const addSc = el("button", "btn ghost tiny", "+ Add shortcut");
addSc.title = "Insert a shortcut trigger — browse by category";
addSc.onclick = () => openShortcutPicker((trig) => insertIntoCompose(trig));
titleRow.append(addSc);
wrap.append(titleRow);
const ta = el("textarea", "lib-in compose-ta"); ta.rows = 14; ta.value = val;
ta.placeholder = "Anchor, scene texts, and split markers — one combined montage prompt for generation.";
ta.oninput = () => { gpDraft = ta.value; S.scheduleGlobalPromptApply(ta.value); };
@@ -176,10 +183,132 @@
wrap.append(el("div", "insp-hint",
"Edits apply automatically and stay in sync with the timeline's per-scene prompts. Shortcuts expand at generation time."));
composeTextarea = ta;
if (window.ShortcutAutocomplete) window.ShortcutAutocomplete.attach(ta);
return wrap;
}
let composeTextarea = null;
// Insert text at the compose textarea's caret (or append), then re-sync the timeline.
function insertIntoCompose(text) {
const ta = composeTextarea;
if (!ta) return;
const v = ta.value;
let s = ta.selectionStart, e = ta.selectionEnd;
if (s == null) { s = e = v.length; }
// Pad with a space when butting up against existing words.
const before = v.slice(0, s), after = v.slice(e);
const lead = before && !/\s$/.test(before) ? " " : "";
const trail = after && !/^\s/.test(after) ? " " : "";
const ins = lead + text + trail;
ta.value = before + ins + after;
const caret = s + ins.length;
ta.focus(); ta.setSelectionRange(caret, caret);
gpDraft = ta.value;
S.scheduleGlobalPromptApply(ta.value);
}
// ── shortcut picker (browse by Category → [Sub-category →] Shortcut) ────────────
// Builds the grouped tree from the shortcut library + managed category list, then
// drills down. Direct (no sub-category) shortcuts sit beside the sub-category folders.
function buildShortcutTree() {
const lc = (s) => String(s || "").toLowerCase();
const cats = (S.get().shortcutCategories || []).map((c) => ({ name: c.name, subs: (c.sub_categories || []).slice() }));
const find = (name) => cats.find((c) => lc(c.name) === lc(name));
const tree = new Map(); // category name → { subs: Map<sub, [sc]>, direct: [sc] }
const ensure = (name) => {
if (!tree.has(name)) tree.set(name, { subs: new Map(), direct: [] });
return tree.get(name);
};
cats.forEach((c) => { const n = ensure(c.name); c.subs.forEach((s) => n.subs.set(s, [])); });
const UNCAT = "Uncategorized";
(S.get().shortcuts || []).forEach((sc) => {
if (sc.enabled === false) return;
const cat = (sc.category || "").trim();
if (!cat) { ensure(UNCAT).direct.push(sc); return; }
const node = ensure(cat);
const sub = (sc.sub_category || "").trim();
if (!sub) { node.direct.push(sc); return; }
// Match sub-category case-insensitively to its managed name, else create.
const known = find(cat) && find(cat).subs.find((s) => lc(s) === lc(sub));
const key = known || sub;
if (!node.subs.has(key)) node.subs.set(key, []);
node.subs.get(key).push(sc);
});
// Drop empty categories that have no shortcuts at all (managed-but-unused).
for (const [name, node] of [...tree]) {
const hasAny = node.direct.length || [...node.subs.values()].some((a) => a.length);
if (!hasAny) tree.delete(name);
}
return tree;
}
function openShortcutPicker(onPick) {
const tree = buildShortcutTree();
if (!tree.size) { alert("No shortcuts yet. Add some in the Shortcuts tab."); return; }
let path = []; // [] | [category] | [category, sub]
openModal("Add shortcut", (content, close) => {
const box = el("div", "sc-picker");
function pickShortcut(sc) {
const trig = (sc.triggers || [])[0] || sc.name;
if (trig) onPick(trig);
close();
}
function shortcutRow(sc) {
const r = el("button", "sc-pick-item");
r.append(el("span", "sc-pick-name", (sc.triggers || [])[0] || sc.name));
const rep = (sc.replacements || [])[0];
if (rep) r.append(el("span", "sc-pick-prompt", rep));
r.onclick = () => pickShortcut(sc);
return r;
}
function paint() {
clear(box);
const crumb = el("div", "sc-crumb");
const home = el("button", "sc-crumb-link", "Categories");
home.onclick = () => { path = []; paint(); };
crumb.append(home);
path.forEach((p, i) => {
crumb.append(el("span", "sc-crumb-sep", "›"));
const link = el("button", "sc-crumb-link", p);
link.onclick = () => { path = path.slice(0, i + 1); paint(); };
crumb.append(link);
});
box.append(crumb);
const list = el("div", "sc-pick-list");
if (path.length === 0) {
[...tree.keys()].forEach((name) => {
const node = tree.get(name);
const count = node.direct.length + [...node.subs.values()].reduce((n, a) => n + a.length, 0);
const b = el("button", "sc-pick-folder");
b.append(el("span", "sc-pick-name", name));
b.append(el("span", "sc-pick-count", String(count)));
b.onclick = () => { path = [name]; paint(); };
list.append(b);
});
} else if (path.length === 1) {
const node = tree.get(path[0]);
[...node.subs.entries()].forEach(([sub, arr]) => {
if (!arr.length) return;
const b = el("button", "sc-pick-folder");
b.append(el("span", "sc-pick-name", sub));
b.append(el("span", "sc-pick-count", String(arr.length)));
b.onclick = () => { path = [path[0], sub]; paint(); };
list.append(b);
});
node.direct.forEach((sc) => list.append(shortcutRow(sc)));
} else {
const node = tree.get(path[0]);
(node.subs.get(path[1]) || []).forEach((sc) => list.append(shortcutRow(sc)));
}
box.append(list);
}
paint();
content.append(box);
});
}
// ── shortcuts ─────────────────────────────────────────────────────────────────
function openShortcutEditor(item) {
const isNew = !item.name;
+48
View File
@@ -0,0 +1,48 @@
// Editor Settings modal: per-browser editor preferences (autocomplete, anchor).
// Opened from the Settings menu. Distinct from Engine settings (which configures
// Studio / Chain Sampler behavior on the project).
(function () {
const { el } = window.dom;
const S = window.Store;
let overlay = null;
function close() { if (overlay) { overlay.remove(); overlay = null; } }
function toggleRow(label, hint, key) {
const row = el("div", "es-row");
const lbl = el("label", "chk es-toggle");
const cb = el("input"); cb.type = "checkbox"; cb.checked = !!S.getEditorSetting(key);
cb.onchange = () => S.setEditorSetting(key, cb.checked);
lbl.append(cb, el("span", null, label));
row.append(lbl);
if (hint) row.append(el("div", "es-hint", hint));
return row;
}
function open() {
close();
overlay = el("div", "modal-overlay");
const box = el("div", "modal");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "Editor settings"));
const hr = el("div", "modal-head-right");
const x = el("button", "btn ghost tiny", "✕"); x.onclick = close;
hr.append(x); head.append(hr); box.append(head);
const content = el("div", "modal-content es-content");
content.append(toggleRow(
"Prompt autocomplete",
"Suggest matching shortcuts while you type in the global prompt and scene prompts. Shows the trigger, its prompt, and category.",
"autocomplete"));
content.append(toggleRow(
"Use anchor",
"Text before the first split trigger is a shared anchor prepended to every scene. Turn off to make that leading text Scene 1 instead.",
"anchorEnabled"));
box.append(content); overlay.append(box);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
document.body.append(overlay);
}
window.EditorSettingsModal = { open, close };
})();
+2
View File
@@ -123,6 +123,8 @@
<script src="overlay_preview.js"></script>
<script src="sampler_panel.js"></script>
<script src="engine_settings.js"></script>
<script src="editor_settings.js"></script>
<script src="autocomplete.js"></script>
<script src="slot_picker.js"></script>
<script src="welcome.js"></script>
<script src="pipeline_setup.js"></script>
+8 -2
View File
@@ -245,6 +245,7 @@
const ta = el("textarea"); ta.rows = 4; ta.value = root.text || ""; ta.placeholder = "Describe this scene…"; ta.dataset.k = "sc-text";
ta.oninput = () => S.patchSceneQuiet(scene.id, { text: ta.value });
body.append(field("Prompt", ta));
if (window.ShortcutAutocomplete) window.ShortcutAutocomplete.attach(ta);
renderSourceField(st, root, scene);
if (PC()?.usesChainSampler(st)) {
if ((root.source?.type) === "generated_frame") renderGeneratedFrameSource(st, root);
@@ -493,11 +494,16 @@
}
if (pv.parse_error) { const w = el("div", "pv-warn"); w.append(el("span", null, "▲")); w.append(el("span", null, "ComfyUI offline — preview paused")); box.append(w); }
const parsed = pv.parsed || {};
if (parsed.anchor) { const l = el("div", "pv-line"); l.append(el("span", "pv-badge anchor", "anchor")); l.append(el("span", null, parsed.anchor)); box.append(l); }
// Anchor disabled (editor setting): the backend still reports a leading anchor, but
// generation folds it into Scene 1 — show it that way so the preview matches.
const foldAnchor = parsed.anchor && !S.getEditorSetting("anchorEnabled");
if (parsed.anchor && !foldAnchor) { const l = el("div", "pv-line"); l.append(el("span", "pv-badge anchor", "anchor")); l.append(el("span", null, parsed.anchor)); box.append(l); }
if (foldAnchor) { const l = el("div", "pv-line"); l.append(el("span", "pv-badge", "S1")); l.append(el("span", null, parsed.anchor)); box.append(l); }
const sBase = foldAnchor ? 2 : 1;
const rkeys = pv.scene_refinement_keys || [];
(parsed.scenes || []).forEach((s, i) => {
const l = el("div", "pv-line");
l.append(el("span", "pv-badge", "S" + (i + 1)));
l.append(el("span", "pv-badge", "S" + (i + sBase)));
// Refinement key(s) this scene will steer with (matches generation exactly)
const rk = rkeys[i];
if (rk && rk.keys && rk.keys.length) {
+1
View File
@@ -240,6 +240,7 @@
} },
],
Settings: [
{ label: "Editor settings…", action: () => window.EditorSettingsModal.open() },
{ label: "Engine settings…", disabled: !hasProject() || (!studioOn && !chainOn),
action: () => window.EngineSettingsModal.open() },
{ sep: true },
+34
View File
@@ -35,6 +35,33 @@
ratingLabels: [], // FunPack Studio V2 rating options
};
// ── editor settings ─────────────────────────────────────────────────────────
// Per-browser editor preferences (not part of the project file). Govern how the
// prompt is parsed/edited, not what is generated from already-distributed scenes.
const EDITOR_SETTINGS_KEY = "funpack_editor_settings";
const EDITOR_SETTINGS_DEFAULTS = { autocomplete: true, anchorEnabled: true };
function _loadEditorSettings() {
try {
const raw = JSON.parse(localStorage.getItem(EDITOR_SETTINGS_KEY) || "{}");
return { ...EDITOR_SETTINGS_DEFAULTS, ...(raw && typeof raw === "object" ? raw : {}) };
} catch (_) { return { ...EDITOR_SETTINGS_DEFAULTS }; }
}
let _editorSettings = _loadEditorSettings();
function getEditorSettings() { return { ..._editorSettings }; }
function getEditorSetting(key) { return _editorSettings[key]; }
function setEditorSetting(key, val) {
if (_editorSettings[key] === val) return;
_editorSettings = { ..._editorSettings, [key]: val };
try { localStorage.setItem(EDITOR_SETTINGS_KEY, JSON.stringify(_editorSettings)); } catch (_) {}
notify();
// Anchor toggle changes how the global prompt maps onto the timeline — re-split now.
if (key === "anchorEnabled" && state.project) {
const cur = (state.project.global_prompt
|| state.preview?.display_prompt || state.preview?.combined_prompt || "").trim();
if (cur) { _pinnedGlobalPrompt = cur; applyGlobalPromptQuiet(cur).catch(() => {}); }
}
}
const listeners = new Set();
const SAVE_DEBOUNCE_MS = 5000; // discrete edits (dropdowns, toggles)
const SAVE_SILENT_DEBOUNCE_MS = 20000; // typing in fields — flushed on blur / generate
@@ -446,6 +473,12 @@
if (!hasSceneText && anchor) {
return { anchor: "", scenes: [{ text: anchor }], transitions: [] };
}
// Anchor disabled (editor setting): the leading pre-first-split text is Scene 1,
// not a shared anchor. Fold it in as the first scene; the split trigger that began
// the old Scene 1 becomes the seam between new Scene 1 and Scene 2 (inferred from text).
if (!_editorSettings.anchorEnabled && anchor) {
return { anchor: "", scenes: [{ text: anchor }, ...scenes], transitions };
}
return { anchor: v.anchor || "", scenes, transitions };
}
@@ -3783,6 +3816,7 @@
setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, unsetSamplerInput, setStudioInput, setStudioInputNow,
loadMedia, uploadMedia, deleteMedia, deleteMediaMany, renameMedia, previewMedia, clearMediaPreview, assignMediaToScene, exportMediaAsset,
loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, addCategory,
getEditorSettings, getEditorSetting, setEditorSetting,
loadCharacters, saveCharacter, deleteCharacter,
loadTransitions, saveTransition, deleteTransition, importTransitions,
loadNleLibrary, applyNleEffect, applyNleVideoTransition,
+34
View File
@@ -1531,3 +1531,37 @@ body.tour-mode { overflow: hidden; }
font-family: var(--font-mono); font-size: 11.5px; padding: 6px 9px; border-radius: 5px; }
.composer-add-item:hover { background: var(--ink-3); color: var(--text); }
/* Compose-tab header row (title + Add shortcut) */
.compose-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
/* Shortcut autocomplete dropdown (anchored under the prompt field) */
.ac-menu { position: fixed; z-index: 9000; max-height: 240px; overflow-y: auto;
background: #16140f; border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow); padding: 4px; }
.ac-item { display: flex; align-items: baseline; gap: 8px; padding: 6px 9px; border-radius: 5px; cursor: pointer; }
.ac-item.active { background: var(--amber-soft); }
.ac-trigger { color: var(--amber-2); font-family: var(--font-mono); font-size: 12px; flex-shrink: 0; }
.ac-prompt { color: var(--muted); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
.ac-cat { color: var(--teal); font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .5px; text-transform: uppercase; flex-shrink: 0; }
/* Editor settings modal */
.es-content { display: flex; flex-direction: column; gap: 18px; }
.es-row { display: flex; flex-direction: column; gap: 5px; }
.es-toggle { font-size: 13px; }
.es-hint { color: var(--muted); font-size: 11.5px; line-height: 1.45; padding-left: 24px; }
/* Add-shortcut picker (Category → [Sub-category →] Shortcut) */
.sc-picker { display: flex; flex-direction: column; gap: 10px; }
.sc-crumb { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-family: var(--font-mono); font-size: 11px; }
.sc-crumb-link { background: transparent; border: 0; color: var(--teal); cursor: pointer; padding: 2px 4px; border-radius: 4px; font-family: inherit; font-size: inherit; }
.sc-crumb-link:hover { background: var(--ink-3); color: var(--amber-2); }
.sc-crumb-sep { color: var(--faint); }
.sc-pick-list { display: flex; flex-direction: column; gap: 4px; max-height: 52vh; overflow-y: auto; }
.sc-pick-folder, .sc-pick-item { display: flex; align-items: baseline; gap: 10px; text-align: left;
background: var(--ink-2); border: 1px solid var(--line); border-radius: 7px; color: var(--text);
cursor: pointer; padding: 9px 12px; font-family: inherit; font-size: 12.5px; }
.sc-pick-folder:hover, .sc-pick-item:hover { border-color: var(--amber); background: var(--ink-3); }
.sc-pick-folder .sc-pick-name::before { content: "▸ "; color: var(--amber); }
.sc-pick-name { color: var(--text); flex-shrink: 0; }
.sc-pick-prompt { color: var(--muted); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
.sc-pick-count { margin-left: auto; color: var(--faint); font-family: var(--font-mono); font-size: 10.5px; flex-shrink: 0; }