diff --git a/frontend/src/client/submission-draft.ts b/frontend/src/client/submission-draft.ts index 044ac29..8f195d5 100644 --- a/frontend/src/client/submission-draft.ts +++ b/frontend/src/client/submission-draft.ts @@ -605,6 +605,90 @@ function paintPreview(): void { const host = document.getElementById("submission-draft-preview"); if (!host || !state.view) return; host.innerHTML = state.view.preview_html ?? ""; + wireDraftVars(host); +} + +// t-paliad-261 (B) — click a substituted variable in the preview to +// jump to the matching sidebar input. Re-wires on every paintPreview +// since the preview HTML is replaced wholesale. The server side wraps +// each substituted placeholder (resolved OR missing marker) in +// ; clicks here scroll +// the corresponding input into view, focus + select, and flash the row. +// If the key has no matching sidebar input (derived variables not +// exposed in VARIABLE_GROUPS), the click is a silent no-op — the span +// is still rendered so the user gets the visible hint that this is a +// resolved variable. +function wireDraftVars(previewHost: HTMLElement): void { + previewHost.querySelectorAll(".draft-var").forEach((el) => { + const key = el.dataset.var; + if (!key) return; + if (findVarInput(key)) { + el.classList.add("draft-var--has-input"); + el.setAttribute("role", "button"); + el.setAttribute("tabindex", "0"); + el.setAttribute( + "aria-label", + (isEN() ? "Edit variable " : "Variable bearbeiten: ") + labelFor(key), + ); + } + el.addEventListener("click", (ev) => onDraftVarClick(key, ev)); + el.addEventListener("keydown", (ev) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + onDraftVarClick(key, ev); + } + }); + }); +} + +function findVarInput(key: string): HTMLInputElement | null { + const host = document.getElementById("submission-draft-variables"); + if (!host) return null; + return host.querySelector( + `.submission-draft-var-input[data-var="${cssEscape(key)}"]`, + ); +} + +function cssEscape(s: string): string { + // CSS.escape covers our placeholder keys ([A-Za-z][A-Za-z0-9_.]*) but + // older browsers may lack it; defensive fallback escapes characters + // CSS treats as special. Placeholder keys never carry whitespace or + // quotes so escaping is straightforward. + if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { + return CSS.escape(s); + } + return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1"); +} + +function onDraftVarClick(key: string, ev: Event): void { + const input = findVarInput(key); + if (!input) return; + ev.preventDefault(); + ev.stopPropagation(); + // Smooth-scroll the input into view, then focus on the next tick so + // the scroll animation has started and the focus call doesn't trigger + // a second jarring jump. + input.scrollIntoView({ behavior: "smooth", block: "center" }); + window.setTimeout(() => { + input.focus(); + try { + input.select(); + } catch { + /* select() throws on number/email inputs; safe to ignore */ + } + }, 50); + flashVarRow(input); +} + +function flashVarRow(input: HTMLElement): void { + const row = input.closest(".submission-draft-var-row"); + if (!row) return; + row.classList.remove("submission-draft-var-row--flash"); + // Force reflow so removing+re-adding the class restarts the animation + // even on rapid successive clicks. + void row.offsetWidth; + row.classList.add("submission-draft-var-row--flash"); + window.setTimeout(() => row.classList.remove("submission-draft-var-row--flash"), 1200); } // ───────────────────────────────────────────────────────────────────── @@ -643,11 +727,18 @@ async function flushAutosave(): Promise { if (!state.pendingOverrides) return; const payload = { variables: state.pendingOverrides }; state.pendingOverrides = null; + // t-paliad-261 (A) — paintVariables() below replaces every input in + // the sidebar via innerHTML, which blows away the active-element + // reference. Capture the focused input's key + selection range before + // the repaint and restore on the new element after, so the user can + // keep typing without clicking back into the field. + const focusSnap = captureVarFocus(); try { const view = await patchDraft(payload); state.view = view; paintVariables(); paintPreview(); + restoreVarFocus(focusSnap); setSaveStatus(isEN() ? "Saved" : "Gespeichert"); } catch (err) { if ((err as Error).name === "AbortError") return; @@ -656,6 +747,64 @@ async function flushAutosave(): Promise { } } +// captureVarFocus / restoreVarFocus — focus-preservation across the +// paintVariables() innerHTML-replace cycle (t-paliad-261 part A). +// Tracks selection start/end/direction so the cursor lands exactly +// where it was before the repaint, including any active selection +// range. Handles both and