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");
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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&S <Inc> "X"") {
|
||||
t.Errorf("expected escaped value in HTML, got %q", html)
|
||||
want := `<span class="draft-var" data-var="user.display_name">M&S <Inc> "X"</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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user