improved a lot of things

This commit is contained in:
SinachPat
2026-04-30 01:21:48 +01:00
parent 1edf7b8a94
commit 0c3ac48a3b
15 changed files with 1322 additions and 176 deletions
+271 -102
View File
@@ -1,28 +1,37 @@
// ── Originmain Fiber Hook ────────────────────────────────────────────────────
// This module installs a React DevToolscompatible global hook BEFORE React
// evaluates its module body. React checks for __REACT_DEVTOOLS_GLOBAL_HOOK__
// exactly once at import time; any later installation is too late.
// ── Originmain Fiber Hook (Live SDK) ─────────────────────────────────────────
// Installs a React DevToolscompatible global hook BEFORE React evaluates its
// module body. React checks for __REACT_DEVTOOLS_GLOBAL_HOOK__ exactly once at
// import time; any later installation is too late.
//
// The hook only activates when the app is iframed by Originmain (detected by
// the `om:` prefix in `window.name`, which LiveArtboard.tsx sets on the
// <iframe> element). Outside an Originmain iframe the module is a no-op.
// Full bidirectional protocol:
// Renderer → Host : READY, FIBER_TREE_UPDATE, COMPONENT_SELECTED, ERROR
// Host → Renderer : SET_DESIGN_TOKENS, NAVIGATE, SELECT_COMPONENT, DESELECT
//
// This file is intentionally self-contained — no imports from other
// @originmain/* packages — because it ships as a public npm package.
// Node IDs are stable path strings: "Component:idx/Child:idx/…"
// This survives re-renders as long as the component tree structure is unchanged.
//
// 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.
//
// This file is intentionally self-contained (no @originmain/* imports) because
// it ships as a public npm package and must work standalone.
const RENDERER_SOURCE = 'originmain-renderer';
const NAME_PREFIX = 'om:';
const HOST_SOURCE = 'originmain-host';
const NAME_PREFIX = 'om:';
// ── Guard: only run inside an Originmain iframe ──────────────────────────────
// ── Guard ─────────────────────────────────────────────────────────────────────
function isOriginmainIframe(): boolean {
try {
return window.parent !== window
&& typeof window.name === 'string'
&& window.name.startsWith(NAME_PREFIX);
return (
window.parent !== window &&
typeof window.name === 'string' &&
window.name.startsWith(NAME_PREFIX)
);
} catch {
// Accessing window.parent can throw in certain sandboxed contexts.
return false;
return false; // Accessing window.parent can throw in certain sandboxed contexts.
}
}
@@ -30,11 +39,12 @@ if (isOriginmainIframe()) {
installFiberHook();
}
// ── Core ─────────────────────────────────────────────────────────────────────
// ── Core ─────────────────────────────────────────────────────────────────────
function installFiberHook(): void {
const artboardId = window.name.slice(NAME_PREFIX.length);
// ── postMessage helper ────────────────────────────────────────────────────
function post(msg: Record<string, unknown>): void {
try {
window.parent.postMessage(
@@ -46,8 +56,13 @@ function installFiberHook(): void {
}
}
// Install or wrap the global hook. If React DevTools is already present,
// we wrap its onCommitFiberRoot so both receive commits.
// ── Runtime state ─────────────────────────────────────────────────────────
let currentTree: SerializedNode | null = null;
const nodeMap = new Map<string, { domRect: DomRect | null }>();
let selectedNodeId: string | null = null;
let highlightEl: HTMLElement | null = null;
// ── React DevTools global hook ─────────────────────────────────────────────
type Hook = {
renderers: Map<unknown, unknown>;
supportsFiber: boolean;
@@ -64,31 +79,248 @@ function installFiberHook(): void {
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
}
const originalOnCommit = hook.onCommitFiberRoot;
const _prevCommit = hook.onCommitFiberRoot;
hook.onCommitFiberRoot = function onCommitFiberRoot(...args: unknown[]) {
// Delegate to the previous handler (React DevTools) first.
if (typeof originalOnCommit === 'function') {
try { originalOnCommit.apply(this, args); }
catch { /* don't break DevTools */ }
// 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 {
// args[1] is the FiberRoot — { current: Fiber }
// args[1] is the FiberRoot object — { current: Fiber }
const root = args[1] as { current: FiberLike } | undefined;
if (!root?.current) return;
const tree = serializeFiber(root.current);
const tree = serializeFiber(root.current, '');
currentTree = tree;
rebuildNodeMap(tree);
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) });
}
};
// Signal readiness.
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
function serializeFiber(
fiber: FiberLike | null,
parentId: string,
): SerializedNode | null {
if (!fiber) return null;
const name = getDisplayName(fiber);
if (!name) {
// Unnamed fiber — skip level but keep walking children.
let child = fiber.child;
while (child) {
const s = serializeFiber(child, parentId);
if (s) return s;
child = child.sibling;
}
return null;
}
const nodeId = (parentId ? parentId + '/' : '') + name + ':' + String(fiber.index);
const rect = getDomRect(fiber);
const node: SerializedNode = {
id: nodeId,
name,
props: serializeProps(fiber.memoizedProps),
children: [],
};
if (rect) node.domRect = rect;
let child = fiber.child;
while (child) {
const serialized = serializeFiber(child, nodeId);
if (serialized) node.children.push(serialized);
child = child.sibling;
}
return node;
}
function getDisplayName(fiber: FiberLike): string | null {
const type = fiber.type;
if (!type) return null;
if (typeof type === 'string') return type;
if (typeof type === 'function') {
return (type as { displayName?: string; name?: string }).displayName
?? (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;
}
return null;
}
function getDomRect(fiber: FiberLike): DomRect | null {
try {
const dom = fiber.stateNode;
if (dom && typeof dom === 'object' && 'getBoundingClientRect' in dom) {
const r = (dom as Element).getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}
} catch { /* stateNode has no layout */ }
return null;
}
function serializeProps(
props: Record<string, unknown> | null,
): Record<string, string | number | boolean | null> {
if (!props || typeof props !== 'object') return {};
const out: Record<string, string | number | boolean | null> = {};
for (const key in props) {
if (key === 'children') continue;
const val = props[key];
const t = typeof val;
if (t === 'string' || t === 'number' || t === 'boolean' || val === null) {
out[key] = val as string | number | boolean | null;
}
}
return out;
}
// ── Node map: flat O(1) lookup by stable ID ───────────────────────────────
function rebuildNodeMap(node: SerializedNode | null): void {
nodeMap.clear();
fillMap(node);
}
function fillMap(node: SerializedNode | null): void {
if (!node) return;
nodeMap.set(node.id, { domRect: node.domRect ?? null });
for (const child of node.children) fillMap(child);
}
// ── Highlight overlay (blue ring inside the iframe) ───────────────────────
function updateHighlight(): void {
const info = selectedNodeId ? nodeMap.get(selectedNodeId) : undefined;
if (info?.domRect) renderHighlight(info.domRect);
else removeHighlight();
}
function renderHighlight(rect: DomRect): void {
if (!highlightEl) {
highlightEl = document.createElement('div');
highlightEl.id = '__om_sel__';
highlightEl.setAttribute('aria-hidden', 'true');
highlightEl.style.cssText = [
'position:fixed', 'pointer-events:none', 'z-index:2147483647',
'box-shadow:0 0 0 2px #3385FF',
'outline:3px solid rgba(51,133,255,0.18)',
'border-radius:3px',
'transition:left .07s ease,top .07s ease,width .07s ease,height .07s ease',
].join(';');
document.body.appendChild(highlightEl);
}
highlightEl.style.left = `${rect.x}px`;
highlightEl.style.top = `${rect.y}px`;
highlightEl.style.width = `${rect.width}px`;
highlightEl.style.height = `${rect.height}px`;
}
function removeHighlight(): void {
if (highlightEl?.parentNode) {
highlightEl.parentNode.removeChild(highlightEl);
highlightEl = null;
}
}
// ── Click-to-select (capturing phase) ────────────────────────────────────
document.addEventListener('click', (event: MouseEvent) => {
const node = findDeepestAt(currentTree, event.clientX, event.clientY);
if (node?.domRect) {
post({ type: 'COMPONENT_SELECTED', nodeId: node.id, rect: node.domRect });
}
}, true);
function findDeepestAt(
node: SerializedNode | null,
x: number,
y: number,
): SerializedNode | null {
if (!node) return null;
const r = node.domRect;
const hit = r && x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height;
if (hit) {
for (const child of node.children) {
const deeper = findDeepestAt(child, x, y);
if (deeper) return deeper;
}
return node;
}
// No hit — still check children for overflow:visible scenarios.
for (const child of node.children) {
const found = findDeepestAt(child, x, y);
if (found) return found;
}
return null;
}
// ── Host → Renderer message handler ──────────────────────────────────────
window.addEventListener('message', (event: MessageEvent) => {
const data = event.data as {
source?: string;
artboardId?: string;
message?: { type: string; tokens?: Record<string, string>; path?: string; nodeId?: string };
};
if (!data || data.source !== HOST_SOURCE) return;
if (data.artboardId !== artboardId) return;
const msg = data.message;
if (!msg) return;
switch (msg.type) {
case 'SET_DESIGN_TOKENS':
if (msg.tokens) applyTokens(msg.tokens);
break;
case 'NAVIGATE':
if (msg.path) doNavigate(msg.path);
break;
case 'SELECT_COMPONENT':
if (msg.nodeId) {
selectedNodeId = msg.nodeId;
updateHighlight();
}
break;
case 'DESELECT':
selectedNodeId = null;
removeHighlight();
break;
}
});
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' });
}
// ── Fiber Serialization ──────────────────────────────────────────────────────
// ── Internal types ────────────────────────────────────────────────────────────
interface FiberLike {
type: unknown;
@@ -99,80 +331,17 @@ interface FiberLike {
memoizedProps: Record<string, unknown> | null;
}
interface DomRect {
x: number;
y: number;
width: number;
height: number;
}
interface SerializedNode {
id: string;
name: string;
props: Record<string, string | number | boolean | null>;
id: string;
name: string;
props: Record<string, string | number | boolean | null>;
children: SerializedNode[];
domRect?: { x: number; y: number; width: number; height: number };
}
function serializeFiber(fiber: FiberLike | null): SerializedNode | null {
if (!fiber) return null;
const name = getDisplayName(fiber);
if (!name) return serializeFiber(fiber.child);
const rect = getDomRect(fiber);
const node: SerializedNode = {
id: String(fiber.index || Math.random()),
name,
props: serializeProps(fiber.memoizedProps),
children: [],
};
if (rect) {
node.domRect = rect;
}
let child = fiber.child;
while (child) {
const serialized = serializeFiber(child);
if (serialized) node.children.push(serialized);
child = child.sibling;
}
return node;
}
function getDisplayName(fiber: FiberLike): string | null {
const type = fiber.type;
if (!type) return null;
if (typeof type === 'string') return type;
if (typeof type === 'function') {
return (type as { displayName?: string; name?: string }).displayName
?? (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;
}
return null;
}
function getDomRect(fiber: FiberLike): { x: number; y: number; width: number; height: number } | null {
try {
const dom = fiber.stateNode;
if (dom && typeof dom === 'object' && 'getBoundingClientRect' in dom) {
const r = (dom as Element).getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}
} catch { /* no DOM node */ }
return null;
}
function serializeProps(
props: Record<string, unknown> | null,
): Record<string, string | number | boolean | null> {
if (!props || typeof props !== 'object') return {};
const out: Record<string, string | number | boolean | null> = {};
for (const key in props) {
if (key === 'children') continue;
const val = props[key];
const t = typeof val;
if (t === 'string' || t === 'number' || t === 'boolean' || val === null) {
out[key] = val as string | number | boolean | null;
}
}
return out;
domRect?: DomRect;
}