258 lines
8.9 KiB
TypeScript
258 lines
8.9 KiB
TypeScript
// 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<string, unknown>) => {
|
|
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<HTMLElement>('[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<HTMLElement, string> => {
|
|
const seen = new Map<string, number>();
|
|
const ids = new Map<HTMLElement, string>();
|
|
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<string, Rect> = {};
|
|
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<History['pushState']>) {
|
|
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 {};
|