From 7ed0717f73ae08b8714dc7500a5cca343d3221c4 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:22:33 +0300 Subject: [PATCH] opencut_ui: fix shortcut autocomplete never suggesting in prose prompts The query was the whole token since the last , ; \n delimiter, and a trigger only matched if that entire token was a substring of it. Typing a normal multi-word prompt grew the token past any short trigger, so suggestions never appeared. Now match TRAILING WORD-RUNS of the token, longest first ("golden ho" before "ho"), keeping substring matching so "@golden" still matches "golden". The longest run that hits any trigger wins; its span is what acceptance replaces, so completing a multi-word trigger can't duplicate words. Verified the matching across start-of- field, mid-prose partials, post-comma, and no-match prose cases. Also lifted .ac-menu z-index (9000 -> 99999) so it can never render behind the Composer floating window when completing in the Compose tab. Co-Authored-By: Claude Opus 4.8 --- movie_editor/frontend/autocomplete.js | 44 ++++++++++++++++++--------- movie_editor/frontend/styles.css | 2 +- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/movie_editor/frontend/autocomplete.js b/movie_editor/frontend/autocomplete.js index 6988e81b..6f7fcb29 100644 --- a/movie_editor/frontend/autocomplete.js +++ b/movie_editor/frontend/autocomplete.js @@ -24,7 +24,9 @@ return out; } - function matches(query) { + // Triggers whose text contains `query` (substring, so "@golden" matches "golden"), + // prefix matches ranked first, identical triggers de-duped. + function matchTriggers(query) { const q = query.toLowerCase(); const scored = []; for (const e of triggerIndex()) { @@ -34,7 +36,6 @@ 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) { @@ -46,17 +47,32 @@ return uniq; } - // The token currently under the caret: text since the last delimiter, left-trimmed. - function currentQuery(ta) { + // What to suggest under the caret. The query is a TRAILING WORD-RUN of the current + // token (text since the last delimiter): we try the longest run first ("golden ho"), + // then shorter tails ("ho"), and use the longest run that matches any trigger. This + // means suggestions surface mid-prose (you don't have to retype from a comma), while + // the replaced span stays a clean unit so accepting can't duplicate words. + function suggestionsFor(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 }; + let dStart = caret; + while (dStart > 0 && !DELIMS.test(text[dStart - 1])) dStart--; + // Word-run start offsets within the token, in order (longest run → shortest). + const starts = []; + let prevWs = true; + for (let i = dStart; i < caret; i++) { + const ws = /\s/.test(text[i]); + if (!ws && prevWs) starts.push(i); + prevWs = ws; + } + for (const start of starts) { + const run = text.slice(start, caret); + if (run.trim().length < MIN_QUERY) continue; + const found = matchTriggers(run); + if (found.length) return { span: { start, end: caret }, items: found }; + } + return null; } function attach(ta) { @@ -122,11 +138,9 @@ 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); + const res = suggestionsFor(ta); + if (!res) { close(); return; } + open(res.items, res.span); } ta.addEventListener("input", refresh); diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index c2b0d930..442fb704 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -1502,7 +1502,7 @@ body.tour-mode { overflow: hidden; } .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; +.ac-menu { position: fixed; z-index: 99999; 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); }