The Browser Already Computed the Layout. Build Your CAD Editor on Top of It.
TLDR
A rendered HTML document is already a CAD drawing. Every element has a position from getBoundingClientRect(), a parent relationship from the DOM, and a layout engine from the browser. Building a WYSIWYG reorder editor means adding drag handles, wiring drag events to DOM moves, and letting the browser reflow the grid. No canvas, no coordinate system, no layout engine of your own.
Repo: tools/pdf-processor
The Model
Structured document output tends to look like this:
section.page[data-page]
└── div.zone ← CSS Grid container
├── div.column.left
│ └── div.region[data-x][data-y]
│ └── h3
└── div.column.right
└── div.region[data-x][data-y]
└── table
Zones are display: grid; grid-template-columns: repeat(N, 1fr). Regions are flow children of columns. The browser already computed every element's dimensions and position. There is no additional coordinate system to maintain.
A selection mode toggle is a CSS class. A drag is an insertBefore. A column reorder is element.before(target). The browser reflows the grid after each move.
Mode Toggle
One function, one CSS class, one contentEditable flip:
function toggleSelectionMode(active) {
preview.classList.toggle('selection-mode', active);
preview.contentEditable = active ? 'false' : 'true';
}
contentEditable = false in selection mode prevents the browser's native text editing behavior from interfering with drag events. All visual chrome for selection mode, including outlines, drag handles, and resize dividers, is scoped to .selection-mode in CSS. Nothing about the document content changes.
Why Handles Must Be position:absolute
The first attempt prepended drag handles as <span> children. Zones use CSS Grid. The span became a grid item, occupying column 0 and pushing content right. In selection mode the layout was completely different from edit mode, making reorder unpredictable.
The fix: position: absolute; top: 4px; left: 4px on handles. Parent zones and regions get position: relative in selection mode. The handle overlays the element without participating in layout. Document structure is identical in both modes.
Three Drop Targets
Zone reorder: zones drag within their page container. Drop handler:
if (dragAbove) target.before(dragged);
else target.after(dragged);
The browser reflows the entire page grid after the DOM move.
Column transfer: regions move between columns in the same zone. Drop handler: target.appendChild(dragged) or target.prepend(dragged). Cross-column moves work because the drop validator accepts region-to-column targets.
Quadrant split: dropping a region on the left or right 25% of another region creates a two-column zone:
function splitIntoColumns(dragged, target, side) {
const bookmark = document.createElement('div');
target.before(bookmark); // save position before touching either element
const [left, right] = side === 'left' ? [dragged, target] : [target, dragged]; const zone = document.createElement('div'); zone.className = 'zone cols-2'; ['left', 'right'].forEach((name, i) => { const col = document.createElement('div'); col.className = column column--${name}; col.appendChild(i === 0 ? left : right); zone.appendChild(col); }); bookmark.replaceWith(zone); }
The bookmark is the critical detail. Both dragged and target are removed from the DOM before the new zone exists. Without saving the insertion point first, there is no anchor to place the new zone at.
Column Resize
Resize dividers are position: absolute within each multi-column zone. On drag start, getComputedStyle(zone).gridTemplateColumns gives the browser-resolved pixel widths, at which point the browser has already computed 1fr into actual pixels.
function onResizeDrag(e) {
const delta = e.clientX - dragStart.x;
const widths = [...dragStart.widths];
widths[col] = Math.max(40, dragStart.widths[col] + delta);
widths[col + 1] = Math.max(40, dragStart.widths[col + 1] - delta);
zone.style.gridTemplateColumns = widths.map(w => w + 'px').join(' ');
}
The final widths are stored in the document's zone metadata as colWidths: ['320px', '288px'] and used as inline overrides on next render. The class-level repeat(N, 1fr) provides the default; the stored widths override it when present.
Critical: dragState must be nulled before calling saveColumnWidths. The save function calls the cleanup that removes all dividers. If dragState is still set during cleanup, the cleanup reads stale references.
State Sync
Every mutation goes through one function:
function syncState() {
removeUIChrome(); // drag handles, ghost columns, resize dividers
applyToAllSurfaces(preview.innerHTML); // push to editor, diff pane
if (selectionModeActive) {
attachHandles();
injectResizeDividers();
}
}
UI chrome is stripped before serializing innerHTML. The editor and diff surfaces always see clean document HTML without drag infrastructure. Chrome is re-injected after sync. This keeps all three surfaces in sync without any of them receiving drag handles.
What to Watch For
Ghost column expansion reads column count from the zone's CSS class, not from querySelectorAll('.column').length. A single-column zone has no column children: counting them always returns 0. The class name is the source of truth for column count.