The editable surface is not the document
The editable surface is not the document
TLDR: an app can have several contenteditable elements that all look like "the document" but only some of them hold it. We assumed any surface the user can type into is authoritative and wrote a sync that pushed its HTML into the document store. A per-page PDF text overlay is editable too, and pushing its structureless innerHTML into the store would have corrupted the extracted HTML.
The assumption that seemed reasonable
The tool renders an extracted document in three editable places at once: a Doc tab, a Visual Diff pane, and a code editor. To keep them consistent we built a single sync function: read one surface's HTML, write it to the store and to the other surfaces.
function syncStructuralEdit() {
const el = document.getElementById('html-preview');
applyHtmlEverywhere(el.innerHTML, el);
}
The name and the comment both said the same thing: this pushes the document. We extended the same pattern without re-examining it. When the new formatting tools (border, highlight) needed to sync their manual DOM edits, the natural move was to call the same function.
When it failed
The PDF tab has a fourth editable surface: a per-page text layer laid over the rendered page, one .editable-text-layer div per page, each full of absolutely-positioned spans. It is contenteditable so the user can select and edit the text directly on the page image. It is in the same selector list that all the toolbar code uses to find "the editable surface". It even has the same class family.
It is not the document. It has no page structure, no tables, no images, no <style> block. It is a per-page overlay that is rebuilt from the document on every render.
Had the new border tool synced through the old function while the caret sat in that overlay, the store would have received the overlay's flat span soup as the whole document. Every other surface would then have rendered it. The corruption would have been total, immediate, and only discoverable in the PDF view, because nothing else in the pipeline reads the overlay.
What was actually wrong
We had one predicate, "is editable", and used it as if it meant "is the document". It conflated three distinct things:
- Surfaces that hold the document and may be pushed back (Doc tab, Visual Diff pane).
- Surfaces that are derived from the document and must never be pushed (the PDF overlay).
- The store itself, which is authoritative but is never the one being edited.
What got replaced
A single gated function replaced the blind sync for the new tools:
function syncStructuralEditFromSurface(surface) {
if (surface && surface.matches('#html-preview, #visual-diff-html')) {
applyHtmlEverywhere(surface.innerHTML, surface);
}
}
The surface decides its own authority. Anything else is simply not pushed. The old function stays for the code paths that provably always hold the document, and the new one is used wherever the caret could legally be in either a real surface or the overlay.
The generalizable lesson
When an app has multiple editable regions, do not ask "can the user edit this?" Ask "does this hold the source of truth?" Name each surface and record which one is authoritative, as an explicit allowlist, not as a default assumption. The moment a new editable element appears (an overlay, a preview, a secondary pane), the safe default is that it is read-only with respect to the store until proven otherwise. Editable and authoritative are different properties, and a tool that pretends they are the same will eventually push the wrong HTML into the wrong place.