Engineering Journal
Ginexys
Ginexys

Pdf Cad Howto

2026-05-22

How to Build a CAD-Style Layout Editor on Top of Extracted HTML

_A technical walkthrough of Selection Mode, Edit Code, and the export chain in Ginexys PDF Processor_


If you've extracted a PDF to HTML and want to give users a way to rearrange and edit the output without leaving the browser, this is the pattern I used. No absolute positioning. No canvas overlay. No extra framework.


The core insight: the extracted DOM is already a box model

The output from the geometry extraction pipeline is structured HTML:

section.pdf-page-content
  └── div.pdf-zone.pdf-zone--cols-2        ← CSS Grid container
        ├── div.pdf-col.pdf-col--left
        │     └── div.pdf-region           ← carries data-rx, data-ry
        │           └── h3.f0.ta-l         ← actual content
        └── div.pdf-col.pdf-col--right
              └── div.pdf-region
                    └── div.pdf-table-wrap
                          └── table

Zones use CSS Grid (grid-template-columns: repeat(N, 1fr)). Regions are flow children. Everything is normal document flow — no position: absolute anywhere.

This means HTML5 drag-and-drop + DOM insertBefore / appendChild gives you free layout reflow. The browser recalculates the grid when you move a node. No coordinate math required.


Step 1: Mode toggle

A single button toggles between Edit Mode and Selection Mode. The implementation is a class on the preview container and a contentEditable flip:

function _toggle() {
    _active = !_active;
    _preview.classList.toggle('selection-mode', _active);
    _preview.contentEditable = _active ? 'false' : 'true';
    _btnSelect.classList.toggle('active', _active);

if (_active) { _attachHandles(); _preview.addEventListener('mousedown', _onMarqueeStart); _preview.addEventListener('click', _onSelectClick, true); } else { _clearSelection(); _removeHandles(); _preview.removeEventListener('mousedown', _onMarqueeStart); _preview.removeEventListener('click', _onSelectClick, true); } }

CSS does the rest. In selection-mode, regions and zones get dashed outlines on hover and solid accent outlines when selected:

#html-preview.selection-mode .pdf-region:hover,
#html-preview.selection-mode .pdf-zone:hover {
    outline: 2px dashed var(--accent);
    cursor: grab;
}

#html-preview.selection-mode .sel-selected { outline: 2px solid var(--accent); background: rgba(0, 120, 212, 0.06); }


Step 2: Drag handles + HTML5 drag events

On entering selection mode, inject a drag handle span as the first child of every zone and region:

function _wireEl(el) {
    if (!el.querySelector(':scope > .sel-drag-handle')) {
        const handle = document.createElement('span');
        handle.className = 'sel-drag-handle';
        handle.textContent = '⠿';
        handle.setAttribute('draggable', 'false');
        el.prepend(handle);
    }
    el.draggable = true;
    el.addEventListener('dragstart', _onDragStart);
    el.addEventListener('dragover',  _onDragOver);
    el.addEventListener('drop',      _onDrop);
    el.addEventListener('dragend',   _onDragEnd);
}

The drop handler does the work. No coordinates — just DOM insertion:

function _onDrop(e) {
    e.preventDefault();
    const target = e.currentTarget;
    const rect = target.getBoundingClientRect();
    const after = e.clientY > rect.top + rect.height / 2;

if (after) { target.after(_draggedEl); } else { target.before(_draggedEl); }

_syncState(); // applyHtmlEverywhere(preview.innerHTML, preview) }

The drop indicator (a 2px accent line) is a div.sel-drop-indicator inserted as a sibling and removed on dragend.

Key rule: only allow same-kind drops. Zones drag within pdf-page-content. Regions drag within any pdf-zone or pdf-col. Check el.classList.contains('pdf-zone') on both dragged and target before inserting.


Step 3: Marquee select

mousedown on the preview background (not a region) starts the marquee:

function _onMarqueeStart(e) {
    if (e.target.closest('.pdf-region, .pdf-zone, .sel-drag-handle')) return;
    if (e.button !== 0) return;

const rect = _preview.getBoundingClientRect(); _marqueeOrigin = { x: e.clientX - rect.left + _preview.scrollLeft, y: e.clientY - rect.top + _preview.scrollTop, };

_marqueeEl = document.createElement('div'); _marqueeEl.className = 'sel-marquee'; _preview.appendChild(_marqueeEl); document.addEventListener('mousemove', _onMarqueeMove); document.addEventListener('mouseup', _onMarqueeEnd); }

On mouseup, intersect the marquee rect against every .pdf-region via getBoundingClientRect():

_preview.querySelectorAll('.pdf-region').forEach(el => {
    const r = el.getBoundingClientRect();
    const overlaps = !(r.right  < marqueeRect.left  ||
                       r.left   > marqueeRect.right ||
                       r.bottom < marqueeRect.top   ||
                       r.top    > marqueeRect.bottom);
    if (overlaps) {
        _selected.add(el);
        el.classList.add('sel-selected');
    }
});

Step 4: Group selected regions

"Group" wraps all selected regions into a new div.pdf-zone.pdf-zone--cols-1, inserted before the first selected region's parent zone:

function _groupSelected() {
    const regions = [..._selected].filter(el => el.classList.contains('pdf-region'));
    if (regions.length < 2) return;

const firstParentZone = regions[0].closest('.pdf-zone') || regions[0].parentElement; const newZone = document.createElement('div'); newZone.className = 'pdf-zone pdf-zone--cols-1';

regions.forEach(r => newZone.appendChild(r)); firstParentZone.before(newZone); _wireEl(newZone); // attach drag handle + events to new zone _clearSelection(); _syncState(); }


Step 5: Edit Code — Monaco in a native dialog

The "Edit Code" item in the right-click context menu opens a <dialog> with a dedicated Monaco editor instance.

Target resolution — walk up from e.target to find the nearest meaningful content element:

const CONTENT_TAGS = new Set(['H3','H4','H5','H6','DIV','P','ASIDE','UL','OL','HR','IMG','FIGURE']);

function _resolveTarget(node) { let el = node; while (el && el !== _preview) { if (CONTENT_TAGS.has(el.tagName) && el.closest('.pdf-region, .pdf-zone')) return el; if (el.matches?.('.pdf-region, .pdf-zone, .pdf-table-wrap')) return el; el = el.parentElement; } return null; }

Monaco import — critical: Monaco is bundled by Vite as an ES module, not a browser global. Use:

import * as monaco from 'monaco-editor'; // ✓
// NOT: window.monaco.editor.create(...)  // ✗ — undefined in Vite builds

Deferred setValue — same pattern as TAFNE's multi-cell edit. The dialog toggle event fires when it opens; use requestAnimationFrame to ensure the container has painted before calling layout():

_dialog.addEventListener('toggle', () => {
    if (!_dialog.open) return;
    if (!_editor) _editor = monaco.editor.create(_container, { language: 'html', ... });

requestAnimationFrame(() => { _editor.layout(); _editor.setValue(_pending); _editor.setPosition({ lineNumber: 1, column: 1 }); _pending = null; _editor.focus(); }); });

Apply — parse edited HTML and replace the element:

function _applyCode() {
    const raw = _editor.getValue();
    const doc = new DOMParser().parseFromString(raw, 'text/html');
    const parsed = doc.body.firstElementChild;
    if (!parsed) { _dialog.close(); return; }

_currentEl.replaceWith(parsed); applyHtmlEverywhere(_preview.innerHTML, _preview); // sync Monaco + visual diff _dialog.close(); }

Dialog centering — use margin: auto on the dialog, not position: fixed. Browsers honor margin: auto for showModal() dialogs:

#view-code-dialog {
    margin: auto;
    width: min(660px, 92vw);
}

Step 6: Standalone list wrapping

The geometry extractor produces <ul> / <ol> blocks but adjacent same-type lists in contenteditable collapse when the user backspaces between them. The fix: wrap each list in <div class="pdf-list-wrap">.

Additionally, ordered lists should carry start="N" so a list beginning at item 3 renders correctly:

function _buildStandaloneList(rawHtml, fontClass) {
    const isOrdered = rawHtml.trimStart().startsWith('<ol');
    const liContents = [...rawHtml.matchAll(/<li>([\s\S]*?)<\/li>/g)].map(m => m[1]);
    if (!liContents.length) return rawHtml;

const firstText = liContents[0].replace(/<[^>]+>/g, '').trim(); const startNum = parseInt((/^(\d+)[.)]\s/.exec(firstText) || [,'1'])[1], 10);

const liTags = liContents.map(content => { const stripped = content.replace(/^(\s(?:<[^>]+>\s))(?:\d+[.)]\s|[•‣◦▪-]\s*)/, '$1'); return &lt;li&gt;${stripped}&lt;/li&gt;; });

const tag = isOrdered ? 'ol' : 'ul'; const startAttr = (isOrdered && startNum !== 1) ? start="${startNum}" : ''; return &lt;div class="pdf-list-wrap"&gt;&lt;${tag} class="${fontClass}"${startAttr}&gt;${liTags.join('\n')}&lt;/${tag}&gt;&lt;/div&gt;; }


State sync

Every mutation — drag drop, group, Edit Code apply — calls the same function:

applyHtmlEverywhere(_preview.innerHTML, _preview);

This pushes the new HTML to state.pdf1.extractedHTML, the Monaco source editor model, and the Visual Diff tab. All export formats read from state, so the exported file always matches what you see.


Checklist

Read this post in the full Engineering Journal →