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:
@@ -605,6 +605,90 @@ function paintPreview(): void {
|
|||||||
const host = document.getElementById("submission-draft-preview");
|
const host = document.getElementById("submission-draft-preview");
|
||||||
if (!host || !state.view) return;
|
if (!host || !state.view) return;
|
||||||
host.innerHTML = state.view.preview_html ?? "";
|
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;
|
if (!state.pendingOverrides) return;
|
||||||
const payload = { variables: state.pendingOverrides };
|
const payload = { variables: state.pendingOverrides };
|
||||||
state.pendingOverrides = null;
|
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 {
|
try {
|
||||||
const view = await patchDraft(payload);
|
const view = await patchDraft(payload);
|
||||||
state.view = view;
|
state.view = view;
|
||||||
paintVariables();
|
paintVariables();
|
||||||
paintPreview();
|
paintPreview();
|
||||||
|
restoreVarFocus(focusSnap);
|
||||||
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name === "AbortError") return;
|
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> {
|
async function renameDraft(newName: string): Promise<void> {
|
||||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5880,6 +5880,66 @@ dialog.modal::backdrop {
|
|||||||
font-style: italic;
|
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 {
|
.submission-edit-btn {
|
||||||
margin-right: 0.4rem;
|
margin-right: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,33 @@ type PlaceholderMap map[string]string
|
|||||||
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
|
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
|
||||||
type MissingPlaceholderFn func(key string) string
|
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
|
// DefaultMissingMarker returns the standard missing-value marker for
|
||||||
// the given UI language.
|
// the given UI language.
|
||||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
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)
|
return nil, fmt.Errorf("submission render: read %s: %w", entry.Name, err)
|
||||||
}
|
}
|
||||||
if isWordXMLEntry(entry.Name) {
|
if isWordXMLEntry(entry.Name) {
|
||||||
body = substituteInDocumentXML(body, vars, missing)
|
body = substituteInDocumentXML(body, vars, missing, nil)
|
||||||
}
|
}
|
||||||
w, err := zw.CreateHeader(&zip.FileHeader{
|
w, err := zw.CreateHeader(&zip.FileHeader{
|
||||||
Name: entry.Name,
|
Name: entry.Name,
|
||||||
@@ -165,7 +192,7 @@ func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars PlaceholderMa
|
|||||||
if docXML == nil {
|
if docXML == nil {
|
||||||
return "", fmt.Errorf("submission render html: word/document.xml missing")
|
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
|
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
|
// 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 paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
|
||||||
// the formatting properties of the first run.
|
// the formatting properties of the first run.
|
||||||
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
|
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||||
replaced := substituteInTextNodes(body, vars, missing)
|
replaced := substituteInTextNodes(body, vars, missing, wrap)
|
||||||
if !needsCrossRunMerge(replaced) {
|
if !needsCrossRunMerge(replaced) {
|
||||||
return replaced
|
return replaced
|
||||||
}
|
}
|
||||||
return substituteAcrossRuns(replaced, vars, missing)
|
return substituteAcrossRuns(replaced, vars, missing, wrap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wTextNodeRegex matches one <w:t …>contents</w:t> element, capturing
|
// 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
|
// substituteInTextNodes runs the placeholder replacement inside each
|
||||||
// <w:t> text node independently. Format-preserving for single-run
|
// <w:t> text node independently. Format-preserving for single-run
|
||||||
// placeholders.
|
// 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 {
|
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
|
||||||
sub := wTextNodeRegex.FindSubmatch(match)
|
sub := wTextNodeRegex.FindSubmatch(match)
|
||||||
attrs := string(sub[1])
|
attrs := string(sub[1])
|
||||||
contents := xmlDecode(string(sub[2]))
|
contents := xmlDecode(string(sub[2]))
|
||||||
replaced := replacePlaceholders(contents, vars, missing)
|
replaced := replacePlaceholders(contents, vars, missing, wrap)
|
||||||
if replaced == contents {
|
if replaced == contents {
|
||||||
return match
|
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
|
// substituteAcrossRuns is pass 2: concatenate every text node in a
|
||||||
// fragmented-placeholder paragraph, run replacement, rewrite as one run.
|
// 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 {
|
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
|
||||||
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
|
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
|
||||||
if len(textNodes) == 0 {
|
if len(textNodes) == 0 {
|
||||||
@@ -284,7 +311,7 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace
|
|||||||
if !strings.Contains(original, "{{") {
|
if !strings.Contains(original, "{{") {
|
||||||
return para
|
return para
|
||||||
}
|
}
|
||||||
replaced := replacePlaceholders(original, vars, missing)
|
replaced := replacePlaceholders(original, vars, missing, wrap)
|
||||||
if replaced == original {
|
if replaced == original {
|
||||||
return para
|
return para
|
||||||
}
|
}
|
||||||
@@ -307,18 +334,29 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace
|
|||||||
}
|
}
|
||||||
|
|
||||||
// replacePlaceholders performs the actual substitution on a plain
|
// replacePlaceholders performs the actual substitution on a plain
|
||||||
// string. Unbound placeholders render the missing marker.
|
// string. Unbound placeholders render the missing marker. When wrap is
|
||||||
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn) string {
|
// 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 {
|
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
|
||||||
sub := placeholderRegex.FindStringSubmatch(match)
|
sub := placeholderRegex.FindStringSubmatch(match)
|
||||||
if len(sub) < 2 {
|
if len(sub) < 2 {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
key := sub[1]
|
key := sub[1]
|
||||||
if value, ok := vars[key]; ok {
|
var value string
|
||||||
return value
|
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 {
|
if italic {
|
||||||
out.WriteString("<em>")
|
out.WriteString("<em>")
|
||||||
}
|
}
|
||||||
out.WriteString(htmlEscape(text))
|
out.WriteString(emitTextWithDraftVars(text))
|
||||||
if italic {
|
if italic {
|
||||||
out.WriteString("</em>")
|
out.WriteString("</em>")
|
||||||
}
|
}
|
||||||
@@ -412,6 +450,52 @@ func paragraphToHTML(para []byte) string {
|
|||||||
return out.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
|
// extractRunText concatenates every <w:t> inside a run, XML-decoding
|
||||||
// the content as it goes.
|
// the content as it goes.
|
||||||
func extractRunText(run []byte) string {
|
func extractRunText(run []byte) string {
|
||||||
|
|||||||
@@ -265,7 +265,9 @@ func TestPatentNumberUPC(t *testing.T) {
|
|||||||
|
|
||||||
// TestRenderHTML_ExtractsParagraphsAndFormatting verifies the preview
|
// TestRenderHTML_ExtractsParagraphsAndFormatting verifies the preview
|
||||||
// HTML emitter walks <w:p> / <w:r> / <w:t> correctly and carries
|
// 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) {
|
func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
|
||||||
doc := `<w:document><w:body>` +
|
doc := `<w:document><w:body>` +
|
||||||
`<w:p><w:r><w:t>Hello {{firm.name}}</w:t></w:r></w:p>` +
|
`<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 {
|
if err != nil {
|
||||||
t.Fatalf("render html: %v", err)
|
t.Fatalf("render html: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(html, "<p>Hello HLC</p>") {
|
if !strings.Contains(html, `<p>Hello <span class="draft-var" data-var="firm.name">HLC</span></p>`) {
|
||||||
t.Errorf("expected merged paragraph, got %q", html)
|
t.Errorf("expected merged paragraph with draft-var span, got %q", html)
|
||||||
}
|
}
|
||||||
if !strings.Contains(html, "<strong>Bold line</strong>") {
|
if !strings.Contains(html, "<strong>Bold line</strong>") {
|
||||||
t.Errorf("expected bold span, got %q", html)
|
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
|
// 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) {
|
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>`
|
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)
|
tmpl := minimalMergeDOCX(t, doc)
|
||||||
@@ -301,7 +304,50 @@ func TestRenderHTML_EscapesContent(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("render html: %v", err)
|
t.Fatalf("render html: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(html, "M&S <Inc> "X"") {
|
want := `<span class="draft-var" data-var="user.display_name">M&S <Inc> "X"</span>`
|
||||||
t.Errorf("expected escaped value in HTML, got %q", html)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user