mAi: #92 - t-paliad-261 — submission-draft autosave focus + click-variable-in-preview jump

Two related editor polish fixes.

(A) Autosave-refresh focus preservation
  paintVariables() replaces every input via innerHTML, blowing away
  the focused-input reference and dropping the cursor mid-edit. Fix:
  capture the active variable input's data-var key + selectionStart/
  End/Direction before the repaint, restore on the new element after
  (by data-var lookup + setSelectionRange). Cursor stays put across
  autosave, rename, and reset cycles. Works for <input> and
  <textarea> via the shared selectionRange contract.

(B) Click variable in preview → jump to sidebar input
  Go renderer wraps every substituted placeholder value in the HTML
  preview with <span class="draft-var" data-var="key">…</span>.
  Implemented via a valueWrapperFn plumbed through
  substituteInDocumentXML → substituteInTextNodes /
  substituteAcrossRuns → replacePlaceholders. RenderHTML passes
  htmlPreviewWrapper which marks values with three PUA sentinels
  (U+E100/U+E101/U+E102) that emitTextWithDraftVars converts to the
  span pair inside docXMLToHTML. Missing-marker text is wrapped too
  so a clicked [KEIN WERT: foo] jumps to the empty field.

  Render() (.docx export) passes nil for wrap → output is byte-
  identical to pre-261. New test
  TestRender_DocxOutputUnchangedByPreviewWrap asserts the .docx never
  carries draft-var/data-var markup or PUA sentinels.

  Client wireDraftVars() adds .draft-var--has-input only to spans
  whose key resolves to a sidebar input — derived variables (e.g.
  today.iso) stay non-clickable. Click handler:
    scrollIntoView(smooth, center) → focus + select after 50ms →
    1.2s lime flash on the row.
  Keyboard accessible (Enter / Space) with role=button + aria-label.

CSS adds a subtle lime tint to every .draft-var so the user sees
what was substituted; --has-input layers cursor: pointer + brighter
hover background. Flash animation respects prefers-reduced-motion
via a steps(1, end) fallback.

Tests: TestRenderHTML_ExtractsParagraphsAndFormatting updated to
assert the new span wrap. New tests for missing-marker wrap +
.docx-path-untouched. Go + frontend builds clean.
This commit is contained in:
mAi
2026-05-25 15:12:10 +02:00
parent ef21e43375
commit 7e66da8def
4 changed files with 360 additions and 21 deletions

View File

@@ -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
// <span class="draft-var" data-var="<key>">…</span>; 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<HTMLElement>(".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<HTMLInputElement>(
`.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<HTMLElement>(".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<void> {
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<void> {
}
}
// 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 <input> and <textarea> via the shared
// HTMLInputElement|HTMLTextAreaElement contract for selectionStart /
// selectionEnd / selectionDirection / setSelectionRange.
interface VarFocusSnapshot {
key: string;
start: number | null;
end: number | null;
dir: "forward" | "backward" | "none";
}
type SelectableEl = HTMLInputElement | HTMLTextAreaElement;
function isVarField(el: Element | null): el is SelectableEl {
if (!el) return false;
if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement)) {
return false;
}
return el.classList.contains("submission-draft-var-input");
}
function captureVarFocus(): VarFocusSnapshot | null {
const active = document.activeElement;
if (!isVarField(active)) return null;
const key = active.dataset.var;
if (!key) return null;
return {
key,
start: active.selectionStart,
end: active.selectionEnd,
dir: (active.selectionDirection as "forward" | "backward" | "none" | null) ?? "forward",
};
}
function restoreVarFocus(snap: VarFocusSnapshot | null): void {
if (!snap) return;
const host = document.getElementById("submission-draft-variables");
if (!host) return;
const next = host.querySelector<SelectableEl>(
`.submission-draft-var-input[data-var="${cssEscape(snap.key)}"]`,
);
if (!next) return;
next.focus();
if (snap.start !== null && snap.end !== null) {
try {
next.setSelectionRange(snap.start, snap.end, snap.dir);
} catch {
/* setSelectionRange throws on inputs whose type doesn't support
selection ranges (number, email, etc.); safe to ignore — the
focus() call above is enough for those. */
}
}
}
async function renameDraft(newName: string): Promise<void> {
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {

View File

@@ -5880,6 +5880,66 @@ dialog.modal::backdrop {
font-style: italic;
}
/* t-paliad-261 (B) — substituted variables in the preview are wrapped
in <span class="draft-var" data-var="…"> by the Go HTML renderer.
.draft-var by itself shows a subtle dotted underline so the lawyer
can SEE which text was filled in from a variable. .draft-var--has-input
(added client-side when a matching sidebar input exists) layers on
the clickable affordance — pointer cursor + brighter hover background.
Non-matching draft-vars (derived variables not exposed in the
sidebar) stay visually distinct but non-interactive. */
.draft-var {
background-color: rgba(198, 244, 28, 0.12);
border-radius: 2px;
padding: 0 2px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
transition: background-color 0.15s ease;
}
.draft-var--has-input {
cursor: pointer;
}
.draft-var--has-input:hover,
.draft-var--has-input:focus-visible {
background-color: rgba(198, 244, 28, 0.45);
outline: none;
}
/* t-paliad-261 (B) — brief lime flash on the sidebar row after a
click-jump from the preview, so the user's eye lands on the right
input even after the smooth-scroll motion. Animation restarts on
each click via class-remove + reflow + class-add. */
.submission-draft-var-row--flash {
animation: paliad-var-flash 1.2s ease;
border-radius: 4px;
}
@keyframes paliad-var-flash {
0% {
background-color: rgba(198, 244, 28, 0.55);
box-shadow: 0 0 0 4px rgba(198, 244, 28, 0.25);
}
100% {
background-color: transparent;
box-shadow: 0 0 0 4px transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.submission-draft-var-row--flash {
animation: paliad-var-flash-still 1.2s steps(1, end);
}
@keyframes paliad-var-flash-still {
0%, 99% { background-color: rgba(198, 244, 28, 0.55); }
100% { background-color: transparent; }
}
.draft-var {
transition: none;
}
}
.submission-edit-btn {
margin-right: 0.4rem;
}

View File

@@ -47,6 +47,33 @@ type PlaceholderMap map[string]string
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
type MissingPlaceholderFn func(key string) string
// valueWrapperFn wraps a substituted value with a marker the HTML
// preview emitter can recognise — used by RenderHTML to turn each
// substituted value into a clickable <span class="draft-var" …>
// (t-paliad-261, click-variable-in-preview → jump-to-field). nil means
// no wrapping; the .docx export path uses nil so its output is
// byte-identical to the wrapper-free build. The wrapper is invoked for
// both resolved values and missing-marker text so clicking a missing
// placeholder still jumps to the corresponding sidebar input.
type valueWrapperFn func(key, value string) string
// Private-Use-Area sentinels for the HTML preview wrap. PUA characters
// are valid in XML 1.0 content, never appear in legitimate template
// text, pass unchanged through xmlEncode/xmlDecode/htmlEscape, and are
// stripped by emitTextWithDraftVars when the preview HTML is assembled.
const (
previewVarBegin = ""
previewVarMid = ""
previewVarEnd = ""
)
// htmlPreviewWrapper wraps a substituted value with the PUA sentinels
// emitTextWithDraftVars recognises. Used only by RenderHTML; the .docx
// Render path uses nil so its output is identical to the pre-261 build.
func htmlPreviewWrapper(key, value string) string {
return previewVarBegin + key + previewVarMid + value + previewVarEnd
}
// DefaultMissingMarker returns the standard missing-value marker for
// the given UI language.
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
@@ -107,7 +134,7 @@ func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, m
return nil, fmt.Errorf("submission render: read %s: %w", entry.Name, err)
}
if isWordXMLEntry(entry.Name) {
body = substituteInDocumentXML(body, vars, missing)
body = substituteInDocumentXML(body, vars, missing, nil)
}
w, err := zw.CreateHeader(&zip.FileHeader{
Name: entry.Name,
@@ -165,7 +192,7 @@ func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars PlaceholderMa
if docXML == nil {
return "", fmt.Errorf("submission render html: word/document.xml missing")
}
merged := substituteInDocumentXML(docXML, vars, missing)
merged := substituteInDocumentXML(docXML, vars, missing, htmlPreviewWrapper)
return docXMLToHTML(merged), nil
}
@@ -214,12 +241,12 @@ func readMergeZipEntry(f *zip.File) ([]byte, error) {
// paragraph, run the replacement on the merged text, and rewrite
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
// the formatting properties of the first run.
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
replaced := substituteInTextNodes(body, vars, missing)
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
replaced := substituteInTextNodes(body, vars, missing, wrap)
if !needsCrossRunMerge(replaced) {
return replaced
}
return substituteAcrossRuns(replaced, vars, missing)
return substituteAcrossRuns(replaced, vars, missing, wrap)
}
// wTextNodeRegex matches one <w:t …>contents</w:t> element, capturing
@@ -229,12 +256,12 @@ var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
// substituteInTextNodes runs the placeholder replacement inside each
// <w:t> text node independently. Format-preserving for single-run
// placeholders.
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
sub := wTextNodeRegex.FindSubmatch(match)
attrs := string(sub[1])
contents := xmlDecode(string(sub[2]))
replaced := replacePlaceholders(contents, vars, missing)
replaced := replacePlaceholders(contents, vars, missing, wrap)
if replaced == contents {
return match
}
@@ -270,7 +297,7 @@ var wParagraphPropsRegex = regexp.MustCompile(`(?s)<w:pPr>.*?</w:pPr>`)
// substituteAcrossRuns is pass 2: concatenate every text node in a
// fragmented-placeholder paragraph, run replacement, rewrite as one run.
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
if len(textNodes) == 0 {
@@ -284,7 +311,7 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace
if !strings.Contains(original, "{{") {
return para
}
replaced := replacePlaceholders(original, vars, missing)
replaced := replacePlaceholders(original, vars, missing, wrap)
if replaced == original {
return para
}
@@ -307,18 +334,29 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace
}
// replacePlaceholders performs the actual substitution on a plain
// string. Unbound placeholders render the missing marker.
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn) string {
// string. Unbound placeholders render the missing marker. When wrap is
// non-nil, both the resolved value AND the missing-marker text are
// passed through wrap(key, value) — the HTML preview path uses this to
// emit clickable spans around every substituted placeholder, including
// missing ones (clicking a missing marker jumps to the corresponding
// sidebar input).
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) string {
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
sub := placeholderRegex.FindStringSubmatch(match)
if len(sub) < 2 {
return match
}
key := sub[1]
if value, ok := vars[key]; ok {
return value
var value string
if v, ok := vars[key]; ok {
value = v
} else {
value = missing(key)
}
return missing(key)
if wrap != nil {
return wrap(key, value)
}
return value
})
}
@@ -401,7 +439,7 @@ func paragraphToHTML(para []byte) string {
if italic {
out.WriteString("<em>")
}
out.WriteString(htmlEscape(text))
out.WriteString(emitTextWithDraftVars(text))
if italic {
out.WriteString("</em>")
}
@@ -412,6 +450,52 @@ func paragraphToHTML(para []byte) string {
return out.String()
}
// emitTextWithDraftVars HTML-escapes run text while converting any
// preview-only sentinels emitted by htmlPreviewWrapper into
// <span class="draft-var" data-var="<key>">…</span>. The key is
// restricted to [A-Za-z][A-Za-z0-9_.]* by placeholderRegex, so no
// attribute-escaping is needed on the key; the value is HTML-escaped
// normally. Sentinel-free text (the Render path output, or template
// text outside placeholders) is passed straight through htmlEscape, so
// callers that never invoked wrap see byte-identical HTML.
//
// t-paliad-261: makes substituted variables clickable in the preview
// pane so the user can jump to the matching input in the sidebar.
func emitTextWithDraftVars(text string) string {
if !strings.Contains(text, previewVarBegin) {
return htmlEscape(text)
}
var out strings.Builder
rest := text
for {
i := strings.Index(rest, previewVarBegin)
if i < 0 {
out.WriteString(htmlEscape(rest))
return out.String()
}
out.WriteString(htmlEscape(rest[:i]))
body := rest[i+len(previewVarBegin):]
mid := strings.Index(body, previewVarMid)
end := strings.Index(body, previewVarEnd)
if mid < 0 || end < 0 || mid > end {
// Malformed sentinel — emit the marker as plain escaped
// text and continue past it so the rest of the run still
// renders.
out.WriteString(htmlEscape(previewVarBegin))
rest = body
continue
}
key := body[:mid]
value := body[mid+len(previewVarMid) : end]
out.WriteString(`<span class="draft-var" data-var="`)
out.WriteString(key)
out.WriteString(`">`)
out.WriteString(htmlEscape(value))
out.WriteString(`</span>`)
rest = body[end+len(previewVarEnd):]
}
}
// extractRunText concatenates every <w:t> inside a run, XML-decoding
// the content as it goes.
func extractRunText(run []byte) string {

View File

@@ -265,7 +265,9 @@ func TestPatentNumberUPC(t *testing.T) {
// TestRenderHTML_ExtractsParagraphsAndFormatting verifies the preview
// HTML emitter walks <w:p> / <w:r> / <w:t> correctly and carries
// bold/italic through to <strong>/<em>.
// bold/italic through to <strong>/<em>. Substituted placeholders are
// wrapped in <span class="draft-var" data-var="…"> so the client can
// make them clickable (t-paliad-261).
func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
doc := `<w:document><w:body>` +
`<w:p><w:r><w:t>Hello {{firm.name}}</w:t></w:r></w:p>` +
@@ -278,8 +280,8 @@ func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
if err != nil {
t.Fatalf("render html: %v", err)
}
if !strings.Contains(html, "<p>Hello HLC</p>") {
t.Errorf("expected merged paragraph, got %q", html)
if !strings.Contains(html, `<p>Hello <span class="draft-var" data-var="firm.name">HLC</span></p>`) {
t.Errorf("expected merged paragraph with draft-var span, got %q", html)
}
if !strings.Contains(html, "<strong>Bold line</strong>") {
t.Errorf("expected bold span, got %q", html)
@@ -290,7 +292,8 @@ func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
}
// TestRenderHTML_EscapesContent confirms the preview emitter HTML-escapes
// special characters in placeholder values.
// special characters in placeholder values even inside the draft-var
// span wrapper.
func TestRenderHTML_EscapesContent(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
@@ -301,7 +304,50 @@ func TestRenderHTML_EscapesContent(t *testing.T) {
if err != nil {
t.Fatalf("render html: %v", err)
}
if !strings.Contains(html, "M&amp;S &lt;Inc&gt; &quot;X&quot;") {
t.Errorf("expected escaped value in HTML, got %q", html)
want := `<span class="draft-var" data-var="user.display_name">M&amp;S &lt;Inc&gt; &quot;X&quot;</span>`
if !strings.Contains(html, want) {
t.Errorf("expected escaped value inside draft-var span, got %q", html)
}
}
// TestRenderHTML_WrapsMissingMarker confirms that an unbound placeholder
// is still rendered as a clickable draft-var span so the user can click
// the [KEIN WERT: …] marker in the preview and jump to the field.
func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
html, err := r.RenderHTML(tmpl, PlaceholderMap{}, nil)
if err != nil {
t.Fatalf("render html: %v", err)
}
want := `<span class="draft-var" data-var="project.case_number">[KEIN WERT: project.case_number]</span>`
if !strings.Contains(html, want) {
t.Errorf("expected missing marker wrapped in draft-var span, got %q", html)
}
}
// TestRender_DocxOutputUnchangedByPreviewWrap asserts the hard rule from
// t-paliad-261: the .docx export path must NOT carry the preview-only
// draft-var sentinels or any draft-var span markup. Renders the same
// template through Render (.docx) and asserts the merged document.xml
// has only the resolved value, not a wrapped one.
func TestRender_DocxOutputUnchangedByPreviewWrap(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
if err != nil {
t.Fatalf("render docx: %v", err)
}
body := readMergeDocumentXML(t, out)
if !strings.Contains(body, `<w:t>HLC</w:t>`) {
t.Errorf("expected raw resolved value in .docx, got %q", body)
}
// PUA sentinels and any span markup must NOT appear in the .docx.
for _, forbidden := range []string{"draft-var", "data-var", previewVarBegin, previewVarMid, previewVarEnd} {
if strings.Contains(body, forbidden) {
t.Errorf("docx output unexpectedly contains %q: %q", forbidden, body)
}
}
}