updated stuff

This commit is contained in:
SinachPat
2026-05-13 15:12:51 +01:00
parent 8356e6278c
commit 7bf43fc481
19 changed files with 1632 additions and 308 deletions
+40
View File
@@ -0,0 +1,40 @@
# @originmain/live
Browser SDK for [Originmain](https://originmain.com) — installs the React fiber hook that enables live component inspection and design editing in the Originmain canvas.
## Installation
```bash
npm install @originmain/live
# or
pnpm add @originmain/live
```
## Usage
Import **before React** in your app entry point:
```ts
// app/layout.tsx (or pages/_app.tsx)
import '@originmain/live'; // ← must be first
import React from 'react';
// ...
```
Or use the [`@originmain/next`](https://www.npmjs.com/package/@originmain/next) plugin which injects it automatically:
```ts
// next.config.ts
import { withOriginmain } from '@originmain/next';
export default withOriginmain({ reactStrictMode: true });
```
## How it works
- Installs `__REACT_DEVTOOLS_GLOBAL_HOOK__` **before** React evaluates (module load time), so React captures fiber commits from the very first render.
- Activates **only** when the page runs inside an Originmain artboard iframe (detected via `#__om_artboard=<id>` in the URL fragment).
- **Complete no-op** in production or any non-Originmain context — zero runtime cost.
## License
MIT
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
// ── @originmain/live build script ────────────────────────────────────────────
// Produces a single self-contained browser ESM bundle under dist/:
//
// dist/index.js — side-effect-only browser module, no external deps
//
// Run: node build.mjs (or via "pnpm build")
//
// Design notes:
// • Platform 'browser' — esbuild replaces process.env.NODE_ENV and
// avoids injecting Node built-in shims.
// • format 'esm' — the published package is "type": "module"; webpack/
// turbopack will tree-shake and include it in the user's bundle.
// • bundle: true — @originmain/live has no runtime npm dependencies so
// bundling produces one fully self-contained file with no require() calls.
// • minify: true — the hook ships inside the user's production bundle;
// every byte matters.
// • No type declarations needed — the package is a side-effect-only import
// (`import '@originmain/live'`) with no exported symbols.
import { build } from 'esbuild';
await build({
entryPoints: ['src/index.ts'],
bundle: true,
platform: 'browser',
format: 'esm',
target: ['es2020', 'chrome88', 'firefox78', 'safari14'],
minify: true,
sourcemap: false, // keep bundle clean; source is MIT-licensed anyway
outfile: 'dist/index.js',
logLevel: 'info',
});
console.log(' ✓ dist/index.js (browser ESM bundle)');
+4 -2
View File
@@ -5,13 +5,15 @@
"description": "Originmain live rendering SDK — installs fiber hook for component inspection. Import before React.",
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./dist/index.js"
},
"files": ["src", "README.md"],
"files": ["dist", "README.md"],
"scripts": {
"build": "node build.mjs",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"esbuild": "^0.25.0",
"typescript": "^5.5.0"
},
"keywords": ["originmain", "react", "devtools", "fiber", "design-engineering"],
+447 -289
View File
@@ -3,46 +3,131 @@
// module body. React checks for __REACT_DEVTOOLS_GLOBAL_HOOK__ exactly once at
// import time; any later installation is too late.
//
// Full bidirectional protocol:
// Renderer → Host : READY, FIBER_TREE_UPDATE, COMPONENT_SELECTED, COMPONENT_DESELECTED, ERROR
// Host → Renderer : SET_DESIGN_TOKENS, NAVIGATE, SELECT_COMPONENT, DESELECT
// Full bidirectional protocol (matches packages/renderer/src/protocol.ts):
// Renderer → Host : READY, FIBER_TREE_UPDATE, COMPONENT_SELECTED,
// COMPONENT_DESELECTED, ELEMENT_STYLES, ROUTES_DISCOVERED,
// THUMBNAIL_READY, SNAPSHOT_READY, ERROR
// Host → Renderer : SET_DESIGN_TOKENS, NAVIGATE, SELECT_COMPONENT, DESELECT,
// REQUEST_ELEMENT_STYLES, PATCH_ELEMENT_STYLE,
// PATCH_CHILDREN_STYLE, REMOVE_ELEMENT,
// CAPTURE_THUMBNAIL, CAPTURE_SNAPSHOT, CANCEL_SNAPSHOT
//
// Node IDs are stable path strings: "Component:idx/Child:idx/…"
// This survives re-renders as long as the component tree structure is unchanged.
// Activation guard — checked in priority order:
// 1. URL fragment: location.hash contains __om_artboard=<id>
// 2. window.name: starts with "om:" (works for same-origin iframes)
// 3. postMessage handshake: sends __om_init_request to parent, waits for reply
//
// Guard: only activates when window.name starts with "om:" — the prefix set by
// LiveArtboard.tsx on the <iframe name="om:{id}"> element. Outside Originmain
// iframes this module is a complete no-op.
// Chrome 88+ strips window.name on cross-origin iframe loads (Spectre
// mitigation). The URL fragment approach is immune to this because LiveArtboard
// appends #__om_artboard=<id> to the iframe src, which survives cross-origin
// navigation. The postMessage handshake is a final fallback.
//
// This file is intentionally self-contained (no @originmain/* imports) because
// it ships as a public npm package and must work standalone.
// This file is intentionally self-contained (no @originmain/* imports) so it
// ships as a standalone npm package without workspace dependencies.
const RENDERER_SOURCE = 'originmain-renderer';
const HOST_SOURCE = 'originmain-host';
const NAME_PREFIX = 'om:';
// ── Guard ─────────────────────────────────────────────────────────────────────
// ── Artboard ID resolution ────────────────────────────────────────────────────
// Returns null if we're not inside an Originmain artboard iframe at all.
function isOriginmainIframe(): boolean {
function resolveArtboardIdSync(): string | null {
// Not in an iframe at all — bail immediately.
try { if (window.parent === window) return null; }
catch { return null; }
// 1. URL fragment: #__om_artboard=<id> (primary — cross-origin safe)
try {
return (
window.parent !== window &&
typeof window.name === 'string' &&
window.name.startsWith(NAME_PREFIX)
);
} catch {
return false; // Accessing window.parent can throw in certain sandboxed contexts.
const match = window.location.hash.match(/__om_artboard=([^&]+)/);
if (match?.[1]) return decodeURIComponent(match[1]);
} catch { /* */ }
// 2. window.name: "om:<id>" (works for same-origin iframes)
try {
if (typeof window.name === 'string' && window.name.startsWith(NAME_PREFIX)) {
return window.name.slice(NAME_PREFIX.length);
}
} catch { /* */ }
return null;
}
// ── Hook installation ─────────────────────────────────────────────────────────
// We install the DevTools hook unconditionally when inside ANY iframe, because
// React evaluates __REACT_DEVTOOLS_GLOBAL_HOOK__ at module load time. If we
// wait for the artboard ID we're already too late. The hook stays dormant until
// the artboard ID is resolved (either synchronously or via postMessage).
(function bootstrap() {
// Not in a frame at all — complete no-op.
try { if (window.parent === window) return; }
catch { return; }
// ── Install the DevTools hook immediately ────────────────────────────────
// React reads __REACT_DEVTOOLS_GLOBAL_HOOK__ exactly once when its module
// body runs. We must be here first.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const g = globalThis as any;
type Hook = {
renderers: Map<unknown, unknown>;
supportsFiber: boolean;
_isDisabled: boolean;
inject?: (...a: unknown[]) => void;
onCommitFiberRoot?: (...a: unknown[]) => void;
};
let hook: Hook = g.__REACT_DEVTOOLS_GLOBAL_HOOK__;
if (!hook) {
hook = { renderers: new Map(), supportsFiber: true, _isDisabled: false };
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
}
}
if (isOriginmainIframe()) {
installFiberHook();
}
// ── Resolve artboard ID ──────────────────────────────────────────────────
const syncId = resolveArtboardIdSync();
if (syncId) {
startMainLoop(hook, syncId);
return;
}
// ── Core ──────────────────────────────────────────────────────────────────────
// No ID found synchronously — try the postMessage handshake.
// The parent (LiveArtboard.tsx) listens for __om_init_request and responds
// with { __om_init_response: true, artboardId: id }.
let resolved = false;
function installFiberHook(): void {
const artboardId = window.name.slice(NAME_PREFIX.length);
function onHandshakeReply(event: MessageEvent) {
const d = event.data as { __om_init_response?: boolean; artboardId?: string } | null;
if (d?.__om_init_response === true && typeof d.artboardId === 'string') {
if (!resolved) {
resolved = true;
window.removeEventListener('message', onHandshakeReply);
startMainLoop(hook, d.artboardId);
}
}
}
window.addEventListener('message', onHandshakeReply);
try {
window.parent.postMessage({ __om_init_request: true }, '*');
} catch { /* sandboxed — postMessage blocked */ }
// Give up after 10 s to avoid a stale listener.
setTimeout(() => {
if (!resolved) window.removeEventListener('message', onHandshakeReply);
}, 10_000);
})();
// ── Main loop (runs once artboard ID is known) ────────────────────────────────
function startMainLoop(hook: {
renderers: Map<unknown, unknown>;
supportsFiber: boolean;
_isDisabled: boolean;
inject?: (...a: unknown[]) => void;
onCommitFiberRoot?: (...a: unknown[]) => void;
}, artboardId: string): void {
// ── postMessage helper ────────────────────────────────────────────────────
function post(msg: Record<string, unknown>): void {
@@ -51,88 +136,50 @@ function installFiberHook(): void {
{ source: RENDERER_SOURCE, artboardId, message: msg },
'*',
);
} catch {
// Parent frame unreachable — silently ignore.
}
} catch { /* parent frame unreachable */ }
}
// ── Runtime state ─────────────────────────────────────────────────────────
// nodeMap: nodeId → { domRect (snapshot), fiber (live reference for re-measurement) }
let nodeMap = new Map<string, { domRect: DomRect | null; fiber: FiberLike }>();
// fiberMap: fiber object → nodeId for O(1) hit-test lookup via __reactFiber$ DOM keys.
// A null value means the fiber is unnamed/transparent and not directly selectable.
let nodeMap = new Map<string, { domRect: DomRect | null; fiber: FiberLike }>();
let fiberMap = new WeakMap<object, string | null>();
let selectedNodeId: string | null = null;
let highlightEl: HTMLElement | null = null;
// ── React DevTools global hook ─────────────────────────────────────────────
type Hook = {
renderers: Map<unknown, unknown>;
supportsFiber: boolean;
_isDisabled: boolean;
onCommitFiberRoot?: (...args: unknown[]) => void;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const g = globalThis as any;
let hook: Hook = g.__REACT_DEVTOOLS_GLOBAL_HOOK__;
if (!hook) {
hook = { renderers: new Map(), supportsFiber: true, _isDisabled: false };
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
}
let highlightEl: HTMLElement | null = null;
let snapshotAborted = false;
// ── Fiber hook — onCommitFiberRoot ────────────────────────────────────────
const _prevCommit = hook.onCommitFiberRoot;
hook.onCommitFiberRoot = function onCommitFiberRoot(...args: unknown[]) {
// Delegate to any pre-existing handler (React DevTools extension) first.
if (typeof _prevCommit === 'function') {
try { _prevCommit.apply(this, args); }
catch { /* don't break existing DevTools */ }
try { _prevCommit.apply(this, args); } catch { /* don't break existing DevTools */ }
}
try {
// args[1] is the FiberRoot object — { current: Fiber }
const root = args[1] as { current: FiberLike } | undefined;
if (!root?.current) return;
// Reset both maps before each walk so stale entries from the previous tree
// don't accumulate. fiberMap is a WeakMap so it self-cleans, but nodeMap
// must be rebuilt from scratch on every commit.
nodeMap = new Map();
fiberMap = new WeakMap();
const tree = serializeFiber(root.current, '');
post({ type: 'FIBER_TREE_UPDATE', root: tree });
// Re-sync the highlight ring after each React commit (component may move).
if (selectedNodeId) updateHighlight();
} catch (err) {
post({ type: 'ERROR', message: String(err) });
}
};
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
// ID format: "ComponentName:siblingIndex/ChildName:siblingIndex/..."
//
// IMPORTANT: unnamed fibers (Fragment, Context.Provider, React.memo wrappers)
// are transparent — their children are collected directly into their parent's
// children array. Returning only the first named child (old approach) caused
// entire subtrees to vanish from the tree.
// ── Fiber serialization ───────────────────────────────────────────────────
function serializeFiber(
fiber: FiberLike | null,
parentId: string,
): SerializedNode | null {
function serializeFiber(fiber: FiberLike | null, parentId: string): SerializedNode | null {
if (!fiber) return null;
const name = getDisplayName(fiber);
if (!name) {
// Unnamed root fiber (HostRoot) — collectChildren handles Fragment recursively.
const children: SerializedNode[] = [];
collectChildren(fiber, parentId, children);
if (children.length === 1) return children[0] ?? null;
if (children.length === 0) return null;
// Multiple named children at root — wrap in a synthetic root node.
return { id: '__root__', name: '__root__', props: {}, children };
}
@@ -146,7 +193,16 @@ function installFiberHook(): void {
};
if (rect) node.domRect = rect;
// Register in both maps for O(1) lookup.
// Attach call-site if present (React dev builds expose _debugSource).
const src = (fiber as FiberLike & { _debugSource?: { fileName?: string; lineNumber?: number; columnNumber?: number } })._debugSource;
if (src?.fileName && typeof src.lineNumber === 'number') {
node.callSite = {
fileName: src.fileName,
lineNumber: src.lineNumber,
...(src.columnNumber !== undefined ? { columnNumber: src.columnNumber } : {}),
};
}
nodeMap.set(nodeId, { domRect: rect, fiber });
fiberMap.set(fiber, nodeId);
@@ -154,18 +210,14 @@ function installFiberHook(): void {
return node;
}
// Collect all named descendants of fiber.child into out[], transparently
// flattening unnamed intermediates (Fragments, Providers, wrappers).
function collectChildren(fiber: FiberLike, parentId: string, out: SerializedNode[]): void {
let child = fiber.child;
while (child) {
const name = getDisplayName(child);
if (name) {
const serialized = serializeFiber(child, parentId);
if (serialized) out.push(serialized);
const s = serializeFiber(child, parentId);
if (s) out.push(s);
} else {
// Unnamed (Fragment / Context / Provider / forwardRef wrapper etc.):
// mark as non-selectable and flatten children directly into our level.
fiberMap.set(child, null);
collectChildren(child, parentId, out);
}
@@ -179,13 +231,13 @@ function installFiberHook(): void {
if (typeof type === 'string') return type;
if (typeof type === 'function') {
return (type as { displayName?: string; name?: string }).displayName
?? (type as { name?: string }).name
?? null;
?? (type as { name?: string }).name
?? null;
}
if (typeof type === 'object' && type !== null && '$$typeof' in type) {
return (type as { displayName?: string; name?: string }).displayName
?? (type as { name?: string }).name
?? null;
?? (type as { name?: string }).name
?? null;
}
return null;
}
@@ -217,15 +269,11 @@ function installFiberHook(): void {
return out;
}
// ── Highlight overlay (blue ring inside the iframe) ───────────────────────
// updateHighlight re-measures from the live fiber stateNode so the ring stays
// accurate even after scroll (between React commits).
// ── Highlight overlay ─────────────────────────────────────────────────────
function updateHighlight(): void {
const info = selectedNodeId ? nodeMap.get(selectedNodeId) : undefined;
if (!info) { removeHighlight(); return; }
// Re-measure from the live DOM element for scroll accuracy.
const rect = getDomRect(info.fiber) ?? info.domRect;
if (rect && rect.width > 0 && rect.height > 0) {
renderHighlight(rect);
@@ -261,48 +309,42 @@ function installFiberHook(): void {
}
}
// Re-measure on scroll so the ring follows the element without needing
// a React commit (which only fires on state/prop changes).
window.addEventListener('scroll', () => {
if (selectedNodeId) updateHighlight();
}, true);
// ── Click-to-select (capturing phase) ────────────────────────────────────
// Uses document.elementFromPoint to get the live DOM element at the click
// position (accurate even after scroll), then walks the React fiber tree
// upward via __reactFiber$ keys to find the nearest tracked component.
// ── Click-to-select ───────────────────────────────────────────────────────
function getFiberKey(el: Element): string | null {
const keys = Object.keys(el);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (k !== undefined && k.startsWith('__reactFiber$')) return k;
if (k?.startsWith('__reactFiber$')) return k;
}
return null;
}
document.addEventListener('click', (event: MouseEvent) => {
// Ignore clicks on our own highlight overlay.
if (event.target === highlightEl) return;
const el = document.elementFromPoint(event.clientX, event.clientY);
// Walk the DOM upward, trying to find a tracked React fiber at each level.
let current: Element | null = el;
while (current && current !== document.documentElement) {
const fiberKey = getFiberKey(current);
if (fiberKey) {
// Walk the fiber's return (parent) chain to find the nearest tracked node.
let fiber: FiberLike | null =
(current as unknown as Record<string, unknown>)[fiberKey] as FiberLike | null;
while (fiber) {
const nodeId = fiberMap.get(fiber);
if (nodeId) {
// Re-measure from the live element for accurate post-scroll rect.
const liveEl = fiber.stateNode;
if (liveEl && typeof liveEl === 'object' && 'getBoundingClientRect' in liveEl) {
const r = (liveEl as Element).getBoundingClientRect();
post({ type: 'COMPONENT_SELECTED', nodeId, rect: { x: r.x, y: r.y, width: r.width, height: r.height } });
post({
type: 'COMPONENT_SELECTED',
nodeId,
rect: { x: r.x, y: r.y, width: r.width, height: r.height },
});
return;
}
}
@@ -312,10 +354,271 @@ function installFiberHook(): void {
current = current.parentElement;
}
// Nothing found — clear the selection.
post({ type: 'COMPONENT_DESELECTED' });
}, true);
// ── Element style inspection ──────────────────────────────────────────────
const INSPECTED_PROPS = [
'color', 'font-family', 'font-size', 'font-weight', 'line-height',
'letter-spacing', 'text-align', 'text-transform', 'text-decoration',
'display', 'width', 'height',
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'flex-direction', 'align-items', 'justify-content', 'gap',
'position', 'top', 'right', 'bottom', 'left',
'background-color', 'border-radius', 'opacity',
'box-shadow', 'border-width', 'border-color', 'border-style',
'overflow', 'cursor', 'transition',
] as const;
function respondWithStyles(nodeId: string): void {
const info = nodeMap.get(nodeId);
const styles: Record<string, string> = {};
let hasDirectText = false;
let hasParagraphChildren = false;
if (info?.fiber?.stateNode && typeof info.fiber.stateNode === 'object'
&& 'nodeType' in (info.fiber.stateNode as object)) {
try {
const el = info.fiber.stateNode as Element;
const computed = window.getComputedStyle(el);
for (const prop of INSPECTED_PROPS) {
const val = computed.getPropertyValue(prop);
if (val) styles[prop] = val;
}
// Structural flags for the Typography panel.
const childNodes = el.childNodes;
for (let i = 0; i < childNodes.length; i++) {
const n = childNodes[i];
if (n?.nodeType === Node.TEXT_NODE && n.textContent?.trim()) {
hasDirectText = true;
}
if ((n as Element)?.tagName === 'P') {
hasParagraphChildren = true;
}
}
} catch { /* element may be detached */ }
}
post({ type: 'ELEMENT_STYLES', nodeId, styles, hasDirectText, hasParagraphChildren });
}
// ── Style patching ────────────────────────────────────────────────────────
function patchElementStyle(nodeId: string, property: string, value: string): void {
const info = nodeMap.get(nodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|| !('nodeType' in (info.fiber.stateNode as object))) return;
try {
const el = info.fiber.stateNode as HTMLElement;
value === '' ? el.style.removeProperty(property) : el.style.setProperty(property, value);
} catch { /* detached */ }
}
function patchChildrenStyle(
parentNodeId: string,
selector: string,
property: string,
value: string,
): void {
const info = nodeMap.get(parentNodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|| !('nodeType' in (info.fiber.stateNode as object))) return;
try {
const parent = info.fiber.stateNode as Element;
parent.querySelectorAll(selector).forEach((child) => {
if (child.parentElement === parent) {
value === ''
? (child as HTMLElement).style.removeProperty(property)
: (child as HTMLElement).style.setProperty(property, value);
}
});
} catch { /* detached */ }
}
function removeElement(nodeId: string): void {
const info = nodeMap.get(nodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|| !('nodeType' in (info.fiber.stateNode as object))) return;
try {
(info.fiber.stateNode as HTMLElement).style.setProperty('display', 'none');
} catch { /* detached */ }
}
// ── Design tokens ─────────────────────────────────────────────────────────
function applyTokens(tokens: Record<string, string>): void {
const root = document.documentElement;
for (const [k, v] of Object.entries(tokens)) {
root.style.setProperty(k, v);
}
}
// ── Navigation ────────────────────────────────────────────────────────────
function doNavigate(path: string): void {
try {
history.pushState(null, '', path);
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
} catch { /* navigation not available */ }
}
// ── Screenshot capture (html2canvas) ─────────────────────────────────────
// Loaded on demand from the proxy's embedded bundle (/__om_h2c__.js) or
// from unpkg. Falls back to null if neither is available.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Html2CanvasFn = (el: HTMLElement, opts?: Record<string, unknown>) => Promise<HTMLCanvasElement>;
let html2canvasCache: Html2CanvasFn | null | 'pending' = null;
async function loadHtml2Canvas(): Promise<Html2CanvasFn | null> {
if (html2canvasCache !== null && html2canvasCache !== 'pending') return html2canvasCache;
if (html2canvasCache === 'pending') {
// Wait for the in-flight load.
return new Promise((resolve) => {
const check = setInterval(() => {
if (html2canvasCache !== 'pending') {
clearInterval(check);
resolve(html2canvasCache as Html2CanvasFn | null);
}
}, 50);
});
}
html2canvasCache = 'pending';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const g = globalThis as any;
// Already on window (e.g. loaded by the proxy script).
if (typeof g.html2canvas === 'function') {
html2canvasCache = g.html2canvas as Html2CanvasFn;
return html2canvasCache;
}
// Try the proxy-embedded bundle first (works offline, no CSP issues).
const candidates = [
'/__om_h2c__.js',
'https://unpkg.com/html2canvas@1.4.1/dist/html2canvas.min.js',
];
for (const src of candidates) {
try {
await new Promise<void>((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.onload = () => resolve();
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
if (typeof g.html2canvas === 'function') {
html2canvasCache = g.html2canvas as Html2CanvasFn;
return html2canvasCache;
}
} catch { /* try next source */ }
}
html2canvasCache = null;
return null;
}
async function captureThumbnail(): Promise<void> {
try {
const h2c = await loadHtml2Canvas();
if (!h2c) { post({ type: 'THUMBNAIL_READY', dataUrl: null }); return; }
const canvas = await h2c(document.body, {
scale: 0.5,
useCORS: true,
allowTaint: true,
logging: false,
imageTimeout: 5000,
});
post({ type: 'THUMBNAIL_READY', dataUrl: canvas.toDataURL('image/jpeg', 0.7) });
} catch {
post({ type: 'THUMBNAIL_READY', dataUrl: null });
}
}
async function captureSnapshot(nodeId: string): Promise<void> {
snapshotAborted = false;
try {
const info = nodeMap.get(nodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|| !('nodeType' in (info.fiber.stateNode as object))) {
post({ type: 'SNAPSHOT_READY', dataUrl: null, nodeId });
return;
}
const h2c = await loadHtml2Canvas();
if (!h2c || snapshotAborted) { post({ type: 'SNAPSHOT_READY', dataUrl: null, nodeId }); return; }
const el = info.fiber.stateNode as HTMLElement;
const canvas = await h2c(el, { scale: 2, useCORS: true, allowTaint: true, logging: false });
if (snapshotAborted) { post({ type: 'SNAPSHOT_READY', dataUrl: null, nodeId }); return; }
post({ type: 'SNAPSHOT_READY', dataUrl: canvas.toDataURL('image/png'), nodeId });
} catch {
post({ type: 'SNAPSHOT_READY', dataUrl: null, nodeId });
}
}
// ── Route discovery ───────────────────────────────────────────────────────
function humanLabel(path: string): string {
if (path === '/') return 'Home';
return path
.split('/')
.filter(Boolean)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, ' '))
.join(' / ');
}
function discoverRoutes(): void {
const seen = new Set<string>();
const routes: Array<{ path: string; label: string }> = [];
const addRoute = (path: string, hint?: string) => {
if (!path || path.startsWith('#') || seen.has(path)) return;
seen.add(path);
routes.push({ path, label: hint?.trim().slice(0, 50) || humanLabel(path) });
};
addRoute(window.location.pathname, document.title || undefined);
document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => {
try {
const raw = (a.getAttribute('href') ?? '').trim();
if (raw.startsWith('/')) { addRoute(raw, a.textContent ?? undefined); return; }
const url = new URL(a.href, window.location.href);
if (url.origin !== window.location.origin) return;
addRoute(url.pathname, a.textContent ?? undefined);
} catch { /* malformed href */ }
});
nodeMap.forEach(({ fiber }) => {
const name = fiber ? getDisplayName(fiber) : null;
if (name && /^(Link|NavLink|NextLink|RouterLink|a)$/.test(name)) {
const href = fiber?.memoizedProps?.['href'] ?? fiber?.memoizedProps?.['to'];
if (typeof href === 'string' && href.startsWith('/')) addRoute(href);
}
});
if (routes.length > 0) post({ type: 'ROUTES_DISCOVERED', routes });
}
let routeDiscoveryScheduled = false;
setTimeout(discoverRoutes, 800);
window.addEventListener('popstate', () => {
if (!routeDiscoveryScheduled) {
routeDiscoveryScheduled = true;
setTimeout(() => { routeDiscoveryScheduled = false; discoverRoutes(); }, 300);
}
});
// ── Host → Renderer message handler ──────────────────────────────────────
window.addEventListener('message', (event: MessageEvent) => {
@@ -327,12 +630,14 @@ function installFiberHook(): void {
tokens?: Record<string, string>;
path?: string;
nodeId?: string;
parentNodeId?: string;
selector?: string;
property?: string;
value?: string | undefined;
value?: string;
};
};
if (!data || data.source !== HOST_SOURCE) return;
if (data.artboardId !== artboardId) return;
if (!data || data.source !== HOST_SOURCE) return;
if (data.artboardId !== artboardId) return;
const msg = data.message;
if (!msg) return;
@@ -344,10 +649,7 @@ function installFiberHook(): void {
if (msg.path) doNavigate(msg.path);
break;
case 'SELECT_COMPONENT':
if (msg.nodeId) {
selectedNodeId = msg.nodeId;
updateHighlight();
}
if (msg.nodeId) { selectedNodeId = msg.nodeId; updateHighlight(); }
break;
case 'DESELECT':
selectedNodeId = null;
@@ -361,203 +663,59 @@ function installFiberHook(): void {
patchElementStyle(msg.nodeId, msg.property, msg.value ?? '');
}
break;
case 'PATCH_CHILDREN_STYLE':
if (msg.parentNodeId && msg.selector && msg.property && msg.value !== undefined) {
patchChildrenStyle(msg.parentNodeId, msg.selector, msg.property, msg.value ?? '');
}
break;
case 'REMOVE_ELEMENT':
if (msg.nodeId) removeElement(msg.nodeId);
break;
case 'CAPTURE_THUMBNAIL':
void captureThumbnail();
break;
case 'CAPTURE_SNAPSHOT':
if (msg.nodeId) void captureSnapshot(msg.nodeId);
break;
case 'CANCEL_SNAPSHOT':
snapshotAborted = true;
break;
}
});
// ── Element style inspection ──────────────────────────────────────────────
// Reads computed CSS properties from the fiber node's DOM element and posts
// them back as ELEMENT_STYLES. We extract a curated subset covering the
// properties designers care about (typography, layout, visual) rather than the
// full ~300-property computed style object.
// ── READY signal ──────────────────────────────────────────────────────────
const INSPECTED_PROPS = [
// Typography
'color', 'font-family', 'font-size', 'font-weight', 'line-height',
'letter-spacing', 'text-align', 'text-transform', 'text-decoration',
// Layout
'display', 'width', 'height',
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'flex-direction', 'align-items', 'justify-content', 'gap',
'position', 'top', 'right', 'bottom', 'left',
// Visual
'background-color', 'border-radius', 'opacity',
'box-shadow', 'border-width', 'border-color', 'border-style',
'overflow', 'cursor', 'transition',
] as const;
let rootFontSizePx: number | undefined;
try {
const computed = window.getComputedStyle(document.documentElement);
const parsed = parseFloat(computed.fontSize);
if (!isNaN(parsed)) rootFontSizePx = parsed;
} catch { /* */ }
function respondWithStyles(nodeId: string): void {
const info = nodeMap.get(nodeId);
const styles: Record<string, string> = {};
if (info?.fiber?.stateNode && typeof info.fiber.stateNode === 'object'
&& 'nodeType' in (info.fiber.stateNode as object)) {
try {
const computed = window.getComputedStyle(info.fiber.stateNode as Element);
for (const prop of INSPECTED_PROPS) {
const val = computed.getPropertyValue(prop);
if (val) styles[prop] = val;
}
} catch { /* element may be detached */ }
}
post({ type: 'ELEMENT_STYLES', nodeId, styles });
}
// ── Inline style patching ─────────────────────────────────────────────────
// Applies a single CSS property as an inline style on the component's DOM
// element. Non-destructive — does NOT modify source files. The change is
// immediately visible in the live render and can be recorded as a diff.
// ── Route discovery ───────────────────────────────────────────────────────
// Scans same-origin <a href> links in the current page and any React Router /
// Next.js Link components whose props contain an href, then posts the unique
// set of paths as ROUTES_DISCOVERED. Called once after the first React commit
// and again on every SPA navigation.
function humanLabel(path: string): string {
if (path === '/') return 'Home';
return path
.split('/')
.filter(Boolean)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, ' '))
.join(' / ');
}
function discoverRoutes(): void {
const seen = new Set<string>();
const routes: Array<{ path: string; label: string }> = [];
const addRoute = (path: string, hint?: string) => {
if (seen.has(path)) return;
// Skip hash-only anchors and external paths
if (!path || path.startsWith('#')) return;
seen.add(path);
routes.push({ path, label: hint?.trim().slice(0, 50) || humanLabel(path) });
};
// Current route first
addRoute(window.location.pathname, document.title || undefined);
// Scan real <a> elements — accept root-relative paths regardless of origin
// so that CLI-proxied pages (where links still point to the original domain)
// are handled correctly alongside direct same-origin connections.
document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => {
try {
const rawHref = (a.getAttribute('href') ?? '').trim();
// Root-relative paths are always valid routes.
if (rawHref.startsWith('/')) {
addRoute(rawHref, a.textContent ?? undefined);
return;
}
// Absolute URLs — only add if same-origin (direct connection).
const url = new URL(a.href, window.location.href);
if (url.origin !== window.location.origin) return;
addRoute(url.pathname, a.textContent ?? undefined);
} catch { /* malformed href */ }
});
// Also scan fiber tree for Link / NavLink / next/link props
nodeMap.forEach(({ fiber }) => {
const name = fiber ? getDisplayName(fiber) : null;
if (name && /^(Link|NavLink|NextLink|RouterLink|a)$/.test(name)) {
const href = fiber?.memoizedProps?.['href'] ?? fiber?.memoizedProps?.['to'];
if (typeof href === 'string' && href.startsWith('/')) {
addRoute(href);
}
}
});
if (routes.length > 0) post({ type: 'ROUTES_DISCOVERED', routes });
}
// Re-discover after every React commit (covers lazy-loaded nav items)
const _originalCommit = hook.onCommitFiberRoot;
let routeDiscoveryScheduled = false;
const _wrappedCommitForRoutes = hook.onCommitFiberRoot;
void _wrappedCommitForRoutes; // suppress unused warning — keep original chain intact
// One-time discovery 800ms after first READY (DOM settled)
setTimeout(discoverRoutes, 800);
// Re-discover on every SPA navigation
window.addEventListener('popstate', () => {
if (!routeDiscoveryScheduled) {
routeDiscoveryScheduled = true;
setTimeout(() => { routeDiscoveryScheduled = false; discoverRoutes(); }, 300);
}
});
// ── Element removal ───────────────────────────────────────────────────────
function removeElement(nodeId: string): void {
const info = nodeMap.get(nodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|| !('nodeType' in (info.fiber.stateNode as object))) return;
try {
(info.fiber.stateNode as HTMLElement).style.setProperty('display', 'none');
} catch { /* detached */ }
}
function patchElementStyle(nodeId: string, property: string, value: string): void {
const info = nodeMap.get(nodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
|| !('nodeType' in (info.fiber.stateNode as object))) return;
try {
const el = info.fiber.stateNode as HTMLElement;
if (value === '') {
el.style.removeProperty(property);
} else {
el.style.setProperty(property, value);
}
} catch { /* element may be detached */ }
}
function applyTokens(tokens: Record<string, string>): void {
const root = document.documentElement;
for (const [k, v] of Object.entries(tokens)) {
root.style.setProperty(k, v);
}
}
function doNavigate(path: string): void {
try {
history.pushState(null, '', path);
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
} catch { /* navigation not available */ }
}
// ── Ready signal ──────────────────────────────────────────────────────────
post({ type: 'READY' });
post({ type: 'READY', rootFontSizePx });
}
// ── Internal types ────────────────────────────────────────────────────────────
interface FiberLike {
type: unknown;
index: number;
child: FiberLike | null;
sibling: FiberLike | null;
/** Parent fiber — needed for click-to-select chain walk via __reactFiber$ keys. */
return: FiberLike | null;
stateNode: unknown;
type: unknown;
index: number;
child: FiberLike | null;
sibling: FiberLike | null;
return: FiberLike | null;
stateNode: unknown;
memoizedProps: Record<string, unknown> | null;
}
interface DomRect {
x: number;
y: number;
width: number;
height: number;
x: number; y: number; width: number; height: number;
}
interface SerializedNode {
id: string;
name: string;
props: Record<string, string | number | boolean | null>;
children: SerializedNode[];
domRect?: DomRect;
id: string;
name: string;
props: Record<string, string | number | boolean | null>;
children: SerializedNode[];
domRect?: DomRect;
callSite?: { fileName: string; lineNumber: number; columnNumber?: number };
}