Selection handles that never touch the DOM they decorate
TLDR
If you bolt drag handles onto editable DOM by inserting elements into it, every other feature that walks that DOM now has to know about your handles. A single body-level overlay with layered pointer-events gives you move, stretch, and fill gestures without adding one node to the content tree. Hit testing during the drag comes free fromelementFromPoint.
The problem class
Any editor that renders its document as live DOM eventually wants spreadsheet-style direct manipulation: select a block of cells, drag it somewhere, stretch it over its neighbors. The document is also editable in place, exported as HTML, walked by geometry code, and diffed by an undo system.That last sentence is the constraint. Whatever UI chrome you add for the gestures must be invisible to all of that machinery.
The naive approach
The obvious move is to decorate the cells themselves: append a handle<span> into the corner cell, add wrapper divs, or absolutely position markers inside the table. It seems reasonable because the handles then move with the content for free, and CSS anchoring is trivial.
I had a version of this pattern already in the codebase. An earlier drag feature injected a dedicated handle row into the table, and every consumer downstream had to filter it out:
const rows = Array.from(table.rows).filter(r =>
!r.classList.contains('drag-row') &&
!r.classList.contains('drop-indicator-row'));
That filter appears in the ruler renderer, the grid mapper, and the exporter. Three places that exist only because chrome lives inside content.
Why it breaks
Injected chrome fails on three axes at once. Exports contain it unless every exporter strips it. Undo snapshots capture it, so undoing past a handle insertion resurrects dead UI. And structural code that counts rows or columns silently miscounts.The failure is not any single bug. It is a tax on every future feature that touches the table.
The better model
Render all gesture chrome as one element, appended todocument.body, positioned fixed over the selection's bounding box. The content DOM stays byte-identical to what the user built.
The interesting part is the pointer-events layering. The overlay covers the selected cells, but the cells must stay clickable and editable underneath it:
.sel-overlay {
position: fixed;
pointer-events: none; / interior passes clicks through /
}
.sel-overlay .edge,
.sel-overlay .node,
.sel-overlay .fill-handle {
pointer-events: auto; / only the handles catch the mouse /
}
The interior is transparent to the pointer, so clicking a cell inside the selection starts editing exactly as before. Only the border strips (move), edge nodes (span stretch), and corner square (fill) intercept anything.
During a drag there is a second trick. The gesture needs to know which cell is under the cursor, but the cursor is over the overlay. Instead of ray-casting or maintaining a geometry index, flip the whole overlay to pointer-events: none for the duration of the gesture and ask the browser:
function cellUnderPoint(x, y) {
const el = document.elementFromPoint(x, y);
const cell = el && el.closest('td, th');
return cell && cell.closest('table') === activeTable ? cell : null;
}
elementFromPoint skips elements with pointer-events: none, so with the overlay transparent it resolves straight to the cell underneath. The browser's hit tester is the geometry index.
Live preview during a gesture needs no extra machinery either. The editor already has a selection highlight class that paints cells. While the user drags an edge node, the gesture recomputes the prospective rectangle and re-applies that same class to the cells it would cover. The preview is the selection system, borrowed for a moment, so it is guaranteed to look identical to a real selection and it costs zero new CSS. On release the gesture either commits (and the highlight becomes the actual new selection) or the original selection is re-painted. One rendering path for both states means the preview cannot drift out of sync with reality.
Positioning is a plain recompute, not a data binding. On selection change, scroll, resize, or after any mutation, rebuild the bounding box from getBoundingClientRect of the selected cells and move the overlay. A capture-phase scroll listener catches scrolling inside nested containers, not just the window:
document.addEventListener('scroll', reposition, true);
Evidence
The gesture data operations (block move, pattern fill, span growth) run against the real grid-mapping code in a jsdom harness that loads the production source files unmodified. 37 assertions pass, including the case where a block move overlaps its own source region and the case where an upward span stretch has to relocate a<td> into a new row:
MOVE block:
ok block moved, source cleared, target overwritten
SPAN top grow (relocates anchor up):
ok C moved to row0, rowspan=2, A absorbed
37 passed, 0 failed
None of those tests filter out chrome, because there is no chrome in the tree to filter.
Tradeoffs
A fixed overlay does not track content automatically. Every scroll, zoom, or reflow needs an explicit reposition call, and if you miss a trigger the handles visibly drift. Injected chrome never drifts, since the layout engine moves it for you.position: fixed also breaks inside ancestors with CSS transforms, where "fixed" becomes relative to the transformed ancestor. If your editor can be embedded in a transformed container, you need to detect that and fall back to absolute positioning.
The one thing to watch for
When a stretch gesture grows a cell'scolspan, you must remove the cells it absorbs. A <td colspan="2"> consumes two column slots in the table processing model; it does not float over its neighbor. Keeping the neighbor makes that row one column wider than the rest of the table. I verified this with the grid mapper: a 3-column table with colspan=2 added and nothing removed maps to maxCols: 4, perRow: [4, 3]. Ragged, and every exporter downstream inherits the raggedness.