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:
co-authored by
Claude Opus 4.8
parent
a1bf407e39
commit
df3ac62e46
@@ -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 };
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user