Three Editable Surfaces, One Source of Truth: Retrofitting the Controlled-Input Pattern
TLDR: The PDF processor had three editable surfaces showing the same HTML: the HTML tab preview, the visual diff right pane, and the Monaco code editor. Each had a different lifecycle. Each silently overwrote the others. The fix is the controlled-input pattern applied to multiple DOM surfaces: a shared _syncing guard flag, a single applyHtmlEverywhere coordinator, and a skip-source rule so the user's caret survives. This is part 3 of a series on silent math bugs in our PDF editor.
Repo: tools/pdf-processor
The mess
The PDF processor has three editable surfaces that all show the extracted HTML:
#html-previewin the HTML tab.contenteditable="true".#visual-diff-htmlin the Visual Diff right pane. Alsocontenteditable="true".- The Monaco editor. Editable through Monaco's own model.
Reading the original code, I found three completely different lifecycles:
- The HTML tab was populated once on extraction. Edits made by the user persisted in that DOM, but never propagated anywhere.
- The Visual Diff HTML pane was re-rendered from state every time the user clicked the Visual Diff tab, which silently overwrote any edits made in the HTML tab.
- The Monaco editor synced one-way. Monaco changes propagated to the HTML preview. The HTML preview did not propagate back.
The fix
The controlled-input pattern, applied to a multi-surface DOM. A single coordinator. A single guard flag. Every change handler routes through the coordinator. Every change handler returns early if the coordinator is mid-write.
// htmlSync.js
import { state } from '../state.js';
import { initTableFeatures } from '../utils/tableLogic.js';
let _syncing = false; const _debouncers = new WeakMap();
const SURFACE_IDS = ['html-preview', 'visual-diff-html']; const DEBOUNCE_MS = 200;
export function isSyncing() { return _syncing; }
export function initHTMLSync() { SURFACE_IDS.forEach(wirePreview); }
function wirePreview(id) { const el = document.getElementById(id); if (!el) return; el.addEventListener('input', () => { if (_syncing) return; const prev = _debouncers.get(el); if (prev) clearTimeout(prev); const t = setTimeout(() => applyHtmlEverywhere(el.innerHTML, el), DEBOUNCE_MS); _debouncers.set(el, t); }); }
export function applyHtmlEverywhere(html, skipEl = null) { if (_syncing) return; _syncing = true; try { state.pdf1.extractedHTML = html; const clean = sanitize(html);
for (const id of SURFACE_IDS) { const el = document.getElementById(id); if (!el || el === skipEl) continue; if (el.innerHTML !== clean) { el.innerHTML = clean; initTableFeatures(el); } }
const editor = state.monacoEditor; if (editor && editor.getValue() !== html) { editor.getModel()?.setValue(html); } } finally { _syncing = false; } }
function sanitize(html) { return typeof window.DOMPurify !== 'undefined' ? window.DOMPurify.sanitize(html, { ADD_TAGS: ['img'], ALLOW_DATA_ATTR: true }) : html; }
Three things in this module deserve attention.
The shared flag, exported
Monaco's onDidChangeModelContent lives in another module. It needs to skip the synchronous re-fire that happens when applyHtmlEverywhere itself calls model.setValue(). Rather than pass the flag through props, we export isSyncing() and let any change handler in the codebase ask whether a write is in progress:
// monacoSetup.js
editor.onDidChangeModelContent(() => {
if (isSyncing()) return;
applyHtmlEverywhere(editor.getValue(), null);
});
The skip-source pattern
When the user is typing in #html-preview, we want their edits to flow to Monaco and to #visual-diff-html, but we do not want to overwrite #html-preview's own innerHTML on every keystroke. That would obliterate their caret.
So applyHtmlEverywhere takes a skipEl argument. The source surface gets skipped on cross-write. Its own natural typing keeps it correct.
Asymmetric sanitization
DOMPurify sanitizes the HTML before mirroring it across surfaces. But the surface the user is typing into is left raw.
Sanitizing on every keystroke would strip half-typed tags and re-create the DOM on every input event, which destroys the cursor. We accept some risk on the typed-in surface because the alternative is unusable.
Debounce, briefly
The debounce on the input listeners (200ms) is there for performance. The user can type freely; the cross-write only fires when they pause briefly. Monaco's setValue is heavyweight enough that doing it on every keystroke would tear the experience.
The result
After this module landed, the visual diff pane stopped clobbering edits on tab switch. The download button now picks up edits made anywhere. The Monaco editor and the rendered preview agree at all times.
Next in the series: The pattern under all three. The bugs in parts 1, 2, and 3 were the same bug in different clothes.