Error Fix: 5 Bugs in the HTML CAD Editor (And Why Each One Was Predictable)
TLDR: Every bug came from a wrong assumption about how the browser handles one of: drag events, contenteditable, CSS Grid, Vite module bundling, or mutable state in callbacks.
Bug 1: Shift+marquee cleared the selection immediately
Symptom: Shift+drag draws the marquee, regions highlight, mouseup fires — then all highlights disappear. Group button never appears.
Root cause: _onMarqueeEnd had this line:
if (!e.ctrlKey && !e.metaKey) _clearSelection();
Shift is held during the entire marquee drag. On mouseup, e.shiftKey is true but neither Ctrl nor Meta is. The condition evaluated to true and wiped everything just selected.
Fix:
if (!e.ctrlKey && !e.metaKey && !e.shiftKey) _clearSelection();
Guard: Any "clear selection unless modifier key" check must include all relevant modifiers. Shift, Ctrl, and Meta all have different conventional meanings — don't forget Shift.
Bug 2: Ghost column showed on cols-1 zones even at the limit
Symptom: A zone that already had 4 columns still showed a ghost column on hover. A zone with 1 column showed no ghost at all.
Root cause: Ghost count was read from child elements:
const cols = zone.querySelectorAll(':scope > .pdf-col').length;
if (cols >= 4) return;
A pdf-zone--cols-1 has no pdf-col children — regions are direct children. length returned 0. Ghost appeared for every 1-column zone regardless of position.
Fix:
const colsMatch = zone.className.match(/pdf-zone--cols-(\d)/);
const cols = colsMatch ? parseInt(colsMatch[1]) : 1;
Read the column count from the class name, not the DOM structure. The class is the source of truth for column count.
Guard: Never infer structural state from child counts when a data attribute or class carries that state explicitly.
Bug 3: Column resize left _resizeDrag set during _syncState
Symptom: After releasing a column resize drag, the drag state persisted into the sync call. Dividers were removed and re-injected with the wrong positions.
Root cause:
function _onDividerMouseUp() {
const { dividerEl, zoneEl } = _resizeDrag;
const finalWidths = getComputedStyle(zoneEl).gridTemplateColumns.split(' ').map(parseFloat);
_saveColWidths(zoneEl, finalWidths); // ← _syncState fires here
_resizeDrag = null; // ← too late
}
_saveColWidths calls _syncState which calls _removeAllDividers. _removeAllDividers accesses nothing from _resizeDrag, but the state being non-null during the sync created ordering issues on the next re-inject.
Fix: Null _resizeDrag before calling _saveColWidths:
_resizeDrag = null;
_saveColWidths(zoneEl, finalWidths);
Guard: Any mutable state that guards a flow must be cleared before triggering downstream effects that read or depend on that state.
Bug 4: Quadrant drop indicator flickered on every dragover event
Symptom: The sel-drop-left / sel-drop-right CSS class (box-shadow highlight) flickered visibly during drag. The highlight appeared and disappeared multiple times per second.
Root cause: dragover fires continuously while the cursor moves. At the top of _onDragOver:
_removeIndicator(); // ← this stripped sel-drop-left/right immediately
Then two lines later it was re-added. Every frame: remove class → re-add class → browser repaints.
Fix: Track the quadrant target separately. Only update when the target changes:
if (_quadrantTarget && _quadrantTarget !== target) _clearQuadrantHighlight();
_quadrantTarget = target;
target.classList.remove('sel-drop-left', 'sel-drop-right');
target.classList.add(onLeft ? 'sel-drop-left' : 'sel-drop-right');
_clearQuadrantHighlight() only fires on target change or drag end — not on every event.
Guard: dragover fires at 60fps. Any state change inside it must be idempotent or gated on a diff check.
Bug 5: window.monaco was undefined in Vite builds
Symptom: Edit Code dialog opened. Monaco container was blank. No console error.
Root cause:
return window.monaco.editor.create(_container, { ... });
Vite bundles Monaco as an ES module and tree-shakes it into the app bundle. It never assigns window.monaco. The expression threw TypeError: Cannot read properties of undefined silently because the dialog's toggle event swallowed it.
Fix:
import * as monaco from 'monaco-editor';
// ...
return monaco.editor.create(_container, { ... });
Guard: Never access Vite-bundled libraries through window. If you didn't put it on window explicitly, it isn't there. Import it directly in every module that uses it.