// DevVibeCoder Design-mode runtime. Dev only: main.* imports it behind // `import.meta.env.DEV`, so it never reaches a production bundle. // // It never touches the source. It answers the host's handshake, reports which // stamped element (`data-vc-loc`, see inspector.ts) is under the pointer, was // clicked, or was dragged where, and keeps the host supplied with element // rectangles so the host can draw outlines itself. Messages only flow to and // from the ONE origin that said hello first. const V = 1; type Rect = { x: number; y: number; width: number; height: number }; type Node = { id: string; loc: string; tag: string; children: Node[] }; type Position = 'before' | 'after' | 'into'; let parentOrigin: string | null = null; let active = false; let raf = 0; const post = (msg: Record) => { if (parentOrigin) window.parent.postMessage({ vc: V, ...msg }, parentOrigin); }; const plain = (r: DOMRectReadOnly): Rect => ({ x: r.x, y: r.y, width: r.width, height: r.height }); const stamped = (): HTMLElement[] => Array.from(document.querySelectorAll('[data-vc-loc]')); // The same source element renders many times inside a list, so the identity // the host uses is `loc#n`: the nth stamped element in document order with // that loc. Recomputed on every report, never stored on the element. const idsOf = (all: HTMLElement[]): Map => { const seen = new Map(); const ids = new Map(); for (const el of all) { const loc = el.dataset.vcLoc || ''; const n = seen.get(loc) ?? 0; seen.set(loc, n + 1); ids.set(el, `${loc}#${n}`); } return ids; }; const idOf = (el: HTMLElement): string => idsOf(stamped()).get(el) || ''; // Stamped children of `el`, skipping unstamped wrappers in between. const stampedChildren = (el: Element): HTMLElement[] => { const out: HTMLElement[] = []; const walk = (n: Element) => { for (const c of Array.from(n.children)) { if ((c as HTMLElement).dataset?.vcLoc !== undefined) out.push(c as HTMLElement); else walk(c); } }; walk(el); return out; }; const tree = (): Node[] => { const all = stamped(); const ids = idsOf(all); const build = (el: HTMLElement): Node => ({ id: ids.get(el) || '', loc: el.dataset.vcLoc || '', tag: el.tagName.toLowerCase(), children: stampedChildren(el).map(build), }); return all.filter((el) => !el.parentElement?.closest('[data-vc-loc]')).map(build); }; const sendRects = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { const rects: Record = {}; const all = stamped(); const ids = idsOf(all); for (const el of all) rects[ids.get(el) || ''] = plain(el.getBoundingClientRect()); post({ type: 'rects', rects }); }); }; const sendTree = () => post({ type: 'tree', tree: tree(), route: location.pathname }); const closestStamped = (n: Element | null): HTMLElement | null => (n?.closest?.('[data-vc-loc]') as HTMLElement | null) ?? null; const target = (e: Event): HTMLElement | null => closestStamped(e.target as Element | null); // --- drag --------------------------------------------------------------------- // Pointer-driven, no HTML5 DnD: it works on every element, including ones with // their own click handlers, and never starts the app's own drag behaviour. let press: { el: HTMLElement; x: number; y: number } | null = null; let dragging: HTMLElement | null = null; let over: { el: HTMLElement; position: Position } | null = null; const isRow = (el: HTMLElement): boolean => { const p = el.parentElement; if (!p) return false; const cs = getComputedStyle(p); if (cs.display.includes('flex')) return cs.flexDirection.startsWith('row'); if (cs.display.includes('grid')) return true; return cs.display === 'inline' || getComputedStyle(el).display.startsWith('inline'); }; const dropAt = (x: number, y: number): { el: HTMLElement; position: Position } | null => { const hit = closestStamped(document.elementFromPoint(x, y)); if (!hit || !dragging || hit === dragging || dragging.contains(hit)) return null; const r = hit.getBoundingClientRect(); if (!stampedChildren(hit).length && !hit.textContent?.trim()) { // An empty container: the middle band drops inside it. const fx = (x - r.x) / r.width; const fy = (y - r.y) / r.height; if (fx > 0.25 && fx < 0.75 && fy > 0.25 && fy < 0.75) return { el: hit, position: 'into' }; } const half = isRow(hit) ? x < r.x + r.width / 2 : y < r.y + r.height / 2; return { el: hit, position: half ? 'before' : 'after' }; }; const endDrag = (commit: boolean) => { if (dragging) { if (commit && over) post({ type: 'drop', id: idOf(dragging), targetId: idOf(over.el), position: over.position }); else post({ type: 'dragover', targetId: null }); } press = null; dragging = null; over = null; document.documentElement.style.cursor = active ? 'crosshair' : ''; }; const onDown = (e: PointerEvent) => { if (e.button !== 0) return; const el = target(e); press = el ? { el, x: e.clientX, y: e.clientY } : null; }; const onMove = (e: PointerEvent) => { if (press && !dragging && Math.hypot(e.clientX - press.x, e.clientY - press.y) > 4) { dragging = press.el; document.documentElement.style.cursor = 'grabbing'; post({ type: 'dragstart', id: idOf(dragging) }); } if (dragging) { e.preventDefault(); const next = dropAt(e.clientX, e.clientY); if (next?.el !== over?.el || next?.position !== over?.position) { over = next; post({ type: 'dragover', targetId: over ? idOf(over.el) : null, position: over?.position ?? null, rect: over ? plain(over.el.getBoundingClientRect()) : null, row: over ? isRow(over.el) : false, }); } return; } const el = target(e); post({ type: 'hover', id: el ? idOf(el) : null, rect: el ? plain(el.getBoundingClientRect()) : null }); }; const onUp = () => { if (dragging) endDrag(true); press = null; }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && dragging) endDrag(false); }; const onClick = (e: MouseEvent) => { // Design mode: a click selects, it does not navigate or submit. e.preventDefault(); e.stopPropagation(); const el = target(e); if (!el) { post({ type: 'select', id: null }); return; } post({ type: 'select', id: idOf(el), loc: el.dataset.vcLoc, tag: el.tagName.toLowerCase(), rect: plain(el.getBoundingClientRect()), }); }; const observer = new MutationObserver(() => { sendRects(); sendTree(); }); const setActive = (on: boolean) => { if (on === active) return; active = on; const opts = { capture: true } as const; if (on) { document.addEventListener('pointerdown', onDown, opts); document.addEventListener('pointermove', onMove, opts); document.addEventListener('pointerup', onUp, opts); document.addEventListener('keydown', onKey, opts); document.addEventListener('click', onClick, opts); window.addEventListener('scroll', sendRects, opts); window.addEventListener('resize', sendRects); observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); document.documentElement.style.cursor = 'crosshair'; sendTree(); sendRects(); } else { endDrag(false); document.removeEventListener('pointerdown', onDown, opts); document.removeEventListener('pointermove', onMove, opts); document.removeEventListener('pointerup', onUp, opts); document.removeEventListener('keydown', onKey, opts); document.removeEventListener('click', onClick, opts); window.removeEventListener('scroll', sendRects, opts); window.removeEventListener('resize', sendRects); observer.disconnect(); document.documentElement.style.cursor = ''; } }; // Route changes: the host's page picker and the Layers tree follow them. for (const m of ['pushState', 'replaceState'] as const) { const orig = history[m]; history[m] = function (this: History, ...args: Parameters) { const r = orig.apply(this, args); queueMicrotask(sendTree); return r; }; } window.addEventListener('popstate', () => queueMicrotask(sendTree)); window.addEventListener('message', (e: MessageEvent) => { const d = e.data; if (!d || d.vc !== V || window.parent === window) return; if (d.type === 'hello') { // The first hello pins the host; any other origin is ignored for the // life of the page. if (parentOrigin && e.origin !== parentOrigin) return; parentOrigin = e.origin; post({ type: 'ready', route: location.pathname }); setActive(d.mode === 'design'); return; } if (e.origin !== parentOrigin) return; if (d.type === 'mode') setActive(d.mode === 'design'); else if (d.type === 'navigate' && typeof d.path === 'string') { history.pushState(null, '', d.path); dispatchEvent(new PopStateEvent('popstate')); } else if (d.type === 'refresh') { sendTree(); sendRects(); } }); export {};