updated stuff
This commit is contained in:
@@ -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
|
||||
@@ -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)');
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user