improved a lot of things
This commit is contained in:
@@ -1,23 +1,15 @@
|
||||
import { RENDERER_SOURCE } from './protocol.js';
|
||||
import { RENDERER_SOURCE, HOST_SOURCE } from './protocol.js';
|
||||
import type { FiberNode, DOMRectLike } from './protocol.js';
|
||||
|
||||
// ── Fiber hook script ─────────────────────────────────────────────────────────
|
||||
// This script is injected into the sandboxed iframe before the remote app
|
||||
// initialises. It installs a React DevTools global hook so React reports every
|
||||
// commit. On each commit, we walk the Fiber tree, serialize it to FiberNode[],
|
||||
// and postMessage the result to the host.
|
||||
//
|
||||
// The script must be self-contained (no imports) because it runs in the iframe.
|
||||
// We generate it as a string via buildFiberHookScript() so it can be injected
|
||||
// via a <script> tag or a blob URL.
|
||||
// ── Legacy fiber hook script ──────────────────────────────────────────────────
|
||||
// @deprecated Use buildProxyFiberHookScript() instead. This version bakes
|
||||
// the artboard ID into the script at generation time; the proxy-compatible
|
||||
// version reads it from window.name at runtime.
|
||||
|
||||
export function buildFiberHookScript(artboardId: string): string {
|
||||
// Inline the constants and logic — the iframe has no access to this module.
|
||||
return `(function(artboardId) {
|
||||
var SOURCE = ${JSON.stringify(RENDERER_SOURCE)};
|
||||
|
||||
// Install the React DevTools global hook BEFORE React loads.
|
||||
// React checks for this object at module evaluation time and registers itself.
|
||||
var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
if (!hook) {
|
||||
hook = { renderers: new Map(), _isDisabled: false };
|
||||
@@ -102,7 +94,6 @@ export function buildFiberHookScript(artboardId: string): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Signal that the renderer iframe is ready.
|
||||
window.parent.postMessage(
|
||||
{ source: SOURCE, artboardId: artboardId, message: { type: 'READY' } },
|
||||
'*'
|
||||
@@ -111,76 +102,108 @@ export function buildFiberHookScript(artboardId: string): string {
|
||||
}
|
||||
|
||||
// ── Proxy-compatible fiber hook script ────────────────────────────────────────
|
||||
// Unlike buildFiberHookScript (which bakes in an artboard ID), this version
|
||||
// reads the artboard ID from `window.name` at runtime. The iframe element sets
|
||||
// `name="om:<artboardId>"` and this script extracts the ID.
|
||||
// Reads the artboard ID from window.name at runtime (set by LiveArtboard:
|
||||
// <iframe name="om:{id}">). Supports full bidirectional protocol:
|
||||
//
|
||||
// This script is injected by the CLI proxy (`@originmain/cli`) into every HTML
|
||||
// response, and is also used by the `@originmain/live` SDK. It is fully
|
||||
// self-contained — no imports, no dependencies.
|
||||
// Renderer → Host : READY, FIBER_TREE_UPDATE, COMPONENT_SELECTED, ERROR
|
||||
// Host → Renderer : SET_DESIGN_TOKENS, NAVIGATE, SELECT_COMPONENT, DESELECT
|
||||
//
|
||||
// Node IDs are stable path strings: "Component:idx/Child:idx/…"
|
||||
// This survives re-renders as long as tree structure is unchanged.
|
||||
//
|
||||
// Used by @originmain/cli (proxy injection) and @originmain/live (SDK).
|
||||
|
||||
export function buildProxyFiberHookScript(): string {
|
||||
return `(function() {
|
||||
// Only activate inside an Originmain iframe
|
||||
'use strict';
|
||||
|
||||
// ── Guard: only activate inside an Originmain iframe ─────────────────────
|
||||
if (window.parent === window) return;
|
||||
|
||||
var NAME_PREFIX = 'om:';
|
||||
var artboardId = '';
|
||||
var artboardId = '';
|
||||
try {
|
||||
if (typeof window.name === 'string' && window.name.indexOf(NAME_PREFIX) === 0) {
|
||||
artboardId = window.name.slice(NAME_PREFIX.length);
|
||||
}
|
||||
} catch (e) { /* window.name access denied — not our iframe */ }
|
||||
} catch (e) { /* sandboxed context — not our iframe */ }
|
||||
if (!artboardId) return;
|
||||
|
||||
var SOURCE = ${JSON.stringify(RENDERER_SOURCE)};
|
||||
var RENDERER_SOURCE = ${JSON.stringify(RENDERER_SOURCE)};
|
||||
var HOST_SOURCE = ${JSON.stringify(HOST_SOURCE)};
|
||||
|
||||
// ── Runtime state ─────────────────────────────────────────────────────────
|
||||
var currentTree = null; // latest serialized FiberNode tree
|
||||
var nodeMap = {}; // nodeId → { domRect } (flat for O(1) lookup)
|
||||
var selectedNodeId = null; // currently highlighted component
|
||||
var highlightEl = null; // the blue-ring DOM overlay element
|
||||
|
||||
// ── postMessage helper ────────────────────────────────────────────────────
|
||||
function post(msg) {
|
||||
try { window.parent.postMessage({ source: SOURCE, artboardId: artboardId, message: msg }, '*'); }
|
||||
catch (e) { /* parent unreachable */ }
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{ source: RENDERER_SOURCE, artboardId: artboardId, message: msg }, '*'
|
||||
);
|
||||
} catch (e) { /* parent unreachable — swallow silently */ }
|
||||
}
|
||||
|
||||
// Install the React DevTools global hook BEFORE React loads.
|
||||
// If React DevTools extension is already present, wrap its handler.
|
||||
// ── React DevTools global hook ────────────────────────────────────────────
|
||||
// Must be installed before React evaluates its module body. React checks for
|
||||
// __REACT_DEVTOOLS_GLOBAL_HOOK__ exactly once at import time.
|
||||
var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
if (!hook) {
|
||||
hook = { renderers: new Map(), supportsFiber: true, _isDisabled: false };
|
||||
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
|
||||
}
|
||||
|
||||
var originalOnCommit = hook.onCommitFiberRoot;
|
||||
var _prevCommit = hook.onCommitFiberRoot;
|
||||
|
||||
hook.onCommitFiberRoot = function(rendererId, root, priorityLevel, didError) {
|
||||
if (typeof originalOnCommit === 'function') {
|
||||
try { originalOnCommit.call(this, rendererId, root, priorityLevel, didError); }
|
||||
catch (e) { /* don't break DevTools */ }
|
||||
// Delegate to any pre-existing handler (e.g. React DevTools extension).
|
||||
if (typeof _prevCommit === 'function') {
|
||||
try { _prevCommit.call(this, rendererId, root, priorityLevel, didError); }
|
||||
catch (e) { /* don't break existing DevTools */ }
|
||||
}
|
||||
try {
|
||||
var fiberRoot = root.current;
|
||||
var tree = serializeFiber(fiberRoot);
|
||||
var tree = serializeFiber(root.current, '');
|
||||
currentTree = tree;
|
||||
rebuildNodeMap(tree);
|
||||
post({ type: 'FIBER_TREE_UPDATE', root: tree });
|
||||
// Re-sync the highlight ring after each React commit (position may shift).
|
||||
if (selectedNodeId) updateHighlight();
|
||||
} catch (err) {
|
||||
post({ type: 'ERROR', message: String(err) });
|
||||
}
|
||||
};
|
||||
|
||||
function serializeFiber(fiber) {
|
||||
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
|
||||
// ID format: "ComponentName:siblingIndex/ChildName:siblingIndex/..."
|
||||
// Stable across re-renders provided the tree structure doesn't change.
|
||||
|
||||
function serializeFiber(fiber, parentId) {
|
||||
if (!fiber) return null;
|
||||
var name = getDisplayName(fiber);
|
||||
if (!name) return serializeFiber(fiber.child) || null;
|
||||
|
||||
var rect = getDomRect(fiber);
|
||||
var node = {
|
||||
id: String(fiber.index || Math.random()),
|
||||
name: name,
|
||||
props: serializeProps(fiber.memoizedProps),
|
||||
children: [],
|
||||
};
|
||||
if (!name) {
|
||||
// Unnamed fiber (Fragment, Context, Provider) — skip this level,
|
||||
// but keep walking children so named descendants are not lost.
|
||||
var c = fiber.child;
|
||||
while (c) {
|
||||
var s = serializeFiber(c, parentId);
|
||||
if (s) return s;
|
||||
c = c.sibling;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var nodeId = (parentId ? parentId + '/' : '') + name + ':' + String(fiber.index);
|
||||
var rect = getDomRect(fiber);
|
||||
var node = { id: nodeId, name: name, props: serializeProps(fiber.memoizedProps), children: [] };
|
||||
if (rect) node.domRect = rect;
|
||||
|
||||
var child = fiber.child;
|
||||
while (child) {
|
||||
var serialized = serializeFiber(child);
|
||||
var serialized = serializeFiber(child, nodeId);
|
||||
if (serialized) node.children.push(serialized);
|
||||
child = child.sibling;
|
||||
}
|
||||
@@ -192,7 +215,7 @@ export function buildProxyFiberHookScript(): string {
|
||||
if (!type) return null;
|
||||
if (typeof type === 'string') return type;
|
||||
if (typeof type === 'function') return type.displayName || type.name || null;
|
||||
if (type.$$typeof) return type.displayName || type.name || null;
|
||||
if (type && type.$$typeof) return type.displayName || type.name || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -203,7 +226,7 @@ export function buildProxyFiberHookScript(): string {
|
||||
var r = dom.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||
}
|
||||
} catch (e) { /* no DOM node */ }
|
||||
} catch (e) { /* stateNode has no layout (Context, Memo, etc.) */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -214,14 +237,144 @@ export function buildProxyFiberHookScript(): string {
|
||||
if (key === 'children') continue;
|
||||
var val = props[key];
|
||||
var t = typeof val;
|
||||
if (t === 'string' || t === 'number' || t === 'boolean' || val === null) {
|
||||
out[key] = val;
|
||||
}
|
||||
if (t === 'string' || t === 'number' || t === 'boolean' || val === null) out[key] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Signal that the fiber hook is installed and the iframe is ready.
|
||||
// ── Node map: flat O(1) lookup by stable ID ───────────────────────────────
|
||||
function rebuildNodeMap(node) {
|
||||
nodeMap = {};
|
||||
fillMap(node);
|
||||
}
|
||||
function fillMap(node) {
|
||||
if (!node) return;
|
||||
nodeMap[node.id] = { domRect: node.domRect || null };
|
||||
var children = node.children;
|
||||
for (var i = 0; i < children.length; i++) fillMap(children[i]);
|
||||
}
|
||||
|
||||
// ── Highlight overlay (blue ring inside the iframe) ───────────────────────
|
||||
function updateHighlight() {
|
||||
var info = nodeMap[selectedNodeId];
|
||||
if (info && info.domRect) renderHighlight(info.domRect);
|
||||
else removeHighlight();
|
||||
}
|
||||
|
||||
function renderHighlight(rect) {
|
||||
if (!highlightEl) {
|
||||
highlightEl = document.createElement('div');
|
||||
highlightEl.id = '__om_sel__';
|
||||
highlightEl.setAttribute('aria-hidden', 'true');
|
||||
// CSS kept inline so no stylesheet dependency. Transition animates when
|
||||
// the selected component moves (e.g. during a re-render or scroll).
|
||||
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() {
|
||||
if (highlightEl && highlightEl.parentNode) {
|
||||
highlightEl.parentNode.removeChild(highlightEl);
|
||||
highlightEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Click-to-select (capturing phase) ────────────────────────────────────
|
||||
// Finds the deepest named fiber node at the click point and reports it back.
|
||||
// In normal canvas usage the SelectionOverlay sits on top of the iframe and
|
||||
// this listener fires when the overlay is bypassed (e.g. direct preview mode).
|
||||
|
||||
document.addEventListener('click', function(event) {
|
||||
var node = findDeepestAt(currentTree, event.clientX, event.clientY);
|
||||
if (node && node.domRect) {
|
||||
post({ type: 'COMPONENT_SELECTED', nodeId: node.id, rect: node.domRect });
|
||||
}
|
||||
}, true);
|
||||
|
||||
function findDeepestAt(node, x, y) {
|
||||
if (!node) return null;
|
||||
var r = node.domRect;
|
||||
var hit = r && x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height;
|
||||
|
||||
if (hit) {
|
||||
// Matched — recurse children for a deeper (more specific) match.
|
||||
var children = node.children;
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
var deeper = findDeepestAt(children[i], x, y);
|
||||
if (deeper) return deeper;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// No hit on this node — still check children for overflow:visible cases.
|
||||
var children = node.children;
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
var found = findDeepestAt(children[i], x, y);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Host → Renderer message handler ──────────────────────────────────────
|
||||
window.addEventListener('message', function(event) {
|
||||
var data = event.data;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (data.source !== HOST_SOURCE) return;
|
||||
if (data.artboardId !== artboardId) return;
|
||||
var msg = data.message;
|
||||
if (!msg) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'SET_DESIGN_TOKENS':
|
||||
if (msg.tokens && typeof msg.tokens === 'object') applyTokens(msg.tokens);
|
||||
break;
|
||||
case 'NAVIGATE':
|
||||
if (typeof msg.path === 'string') doNavigate(msg.path);
|
||||
break;
|
||||
case 'SELECT_COMPONENT':
|
||||
if (typeof msg.nodeId === 'string') {
|
||||
selectedNodeId = msg.nodeId;
|
||||
updateHighlight();
|
||||
}
|
||||
break;
|
||||
case 'DESELECT':
|
||||
selectedNodeId = null;
|
||||
removeHighlight();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Apply DLF design tokens as CSS custom properties on :root.
|
||||
function applyTokens(tokens) {
|
||||
var root = document.documentElement;
|
||||
for (var k in tokens) {
|
||||
if (Object.prototype.hasOwnProperty.call(tokens, k)) {
|
||||
root.style.setProperty(k, String(tokens[k]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SPA-safe navigation: push to history and fire popstate so framework
|
||||
// routers (React Router, Next.js) pick up the route change.
|
||||
function doNavigate(path) {
|
||||
try {
|
||||
history.pushState(null, '', path);
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
|
||||
} catch (e) { /* navigation not available in this context */ }
|
||||
}
|
||||
|
||||
// ── Ready signal ──────────────────────────────────────────────────────────
|
||||
post({ type: 'READY' });
|
||||
})();`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user