improved a lot of things
This commit is contained in:
@@ -5,6 +5,7 @@ import { FluentProvider } from '@fluentui/react-components';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { originmainLightTheme, originmainDarkTheme } from '@originmain/ui';
|
||||
import { useTheme } from '@/store/theme';
|
||||
import { TourOverlay } from '@/components/walkthrough/TourOverlay';
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
// TanStack Query v5: create QueryClient inside useState so it's stable across
|
||||
@@ -35,6 +36,7 @@ export function Providers({ children }: { children: ReactNode }) {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<FluentProvider theme={theme} style={{ height: '100%' }}>
|
||||
{children}
|
||||
<TourOverlay />
|
||||
</FluentProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { serverClient } from '@/lib/supabase';
|
||||
import { AppHeader } from '@/components/shell/AppHeader';
|
||||
import { ProjectCard } from '@/components/shell/ProjectCard';
|
||||
import { DesignLanguageUpload } from '@/components/shell/DesignLanguageUpload';
|
||||
import { TourAutoStart } from '@/components/walkthrough/TourAutoStart';
|
||||
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
|
||||
import type { Workspace, Project } from '@originmain/origin-graph';
|
||||
|
||||
@@ -47,6 +48,8 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
||||
{/* Auto-start tour for first-time visitors (client component, renders null) */}
|
||||
<TourAutoStart />
|
||||
<AppHeader breadcrumbs={[
|
||||
{ label: 'Workspaces', href: '/workspaces' },
|
||||
{ label: workspace.name },
|
||||
@@ -54,8 +57,8 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
||||
|
||||
<main style={{ maxWidth: 960, margin: '0 auto', padding: '48px 24px' }}>
|
||||
|
||||
{/* Title row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 32 }}>
|
||||
{/* Title row — data-tour="projects-section" anchors the tour spotlight */}
|
||||
<div data-tour="projects-section" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 32 }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, letterSpacing: '-0.03em', color: 'var(--page-text)', margin: 0 }}>
|
||||
Projects
|
||||
@@ -92,7 +95,10 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
||||
</svg>
|
||||
Settings
|
||||
</Link>
|
||||
<Link href={`/workspace/${wid}/project/new`} style={{
|
||||
<Link
|
||||
href={`/workspace/${wid}/project/new`}
|
||||
data-tour="new-project-btn"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
background: 'var(--btn-bg)', color: 'var(--btn-fg)',
|
||||
fontSize: '0.875rem', fontWeight: 600,
|
||||
|
||||
@@ -28,7 +28,11 @@ const DIFF_STATUS_BADGE: Record<string, { color: string; bg: string; label: stri
|
||||
};
|
||||
|
||||
export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) {
|
||||
const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, setFiberRoot, selectComponent } = useCanvas();
|
||||
const {
|
||||
selectedArtboardId, selectArtboard, workspaceId, projectId,
|
||||
setArtboardLive, setFiberRoot, selectComponent,
|
||||
selectedComponentId,
|
||||
} = useCanvas();
|
||||
const selected = selectedArtboardId === id;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -305,6 +309,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
|
||||
src={renderUrl}
|
||||
width={width}
|
||||
height={height}
|
||||
selectedComponentId={selectedComponentId}
|
||||
onFiberTreeUpdate={handleFiberUpdate}
|
||||
onComponentSelected={handleComponentSelected}
|
||||
/>
|
||||
@@ -360,6 +365,7 @@ function EmptyArtboardContent({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tour="artboard-empty"
|
||||
style={{
|
||||
width, height, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
@@ -432,6 +438,7 @@ function EmptyArtboardContent({
|
||||
npx @originmain/cli dev --target :3000
|
||||
</p>
|
||||
<button
|
||||
data-tour="artboard-url-btn"
|
||||
onClick={() => setEditing(true)}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: 6, border: '1px solid rgba(0,0,0,0.12)',
|
||||
|
||||
@@ -17,7 +17,12 @@ export interface LiveArtboardProps {
|
||||
src: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** DLF design tokens to inject into the iframe as CSS custom properties. */
|
||||
designTokens?: Record<string, string>;
|
||||
/** The currently selected component node ID (from the canvas store).
|
||||
* When set, sends SELECT_COMPONENT to the iframe so the fiber hook renders
|
||||
* a blue highlight ring over that component's DOM element. */
|
||||
selectedComponentId?: string | null;
|
||||
onReady?: () => void;
|
||||
onFiberTreeUpdate?: (root: FiberNode) => void;
|
||||
onComponentSelected?: (nodeId: string) => void;
|
||||
@@ -32,25 +37,31 @@ export function LiveArtboard({
|
||||
width = 1280,
|
||||
height = 720,
|
||||
designTokens,
|
||||
selectedComponentId,
|
||||
onReady,
|
||||
onFiberTreeUpdate,
|
||||
onComponentSelected,
|
||||
style,
|
||||
}: LiveArtboardProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
// Track whether the iframe has sent READY so we don't send messages too early.
|
||||
const isReadyRef = useRef(false);
|
||||
|
||||
// Send a message to the iframe via the typed protocol
|
||||
// ── Send a typed message to the iframe ───────────────────────────────────
|
||||
const sendMessage = useCallback(
|
||||
(type: Parameters<typeof createHostEnvelope>[1]['type'], payload?: Record<string, unknown>) => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe?.contentWindow) return;
|
||||
const envelope = createHostEnvelope(id, { type, ...(payload ?? {}) } as Parameters<typeof createHostEnvelope>[1]);
|
||||
const envelope = createHostEnvelope(
|
||||
id,
|
||||
{ type, ...(payload ?? {}) } as Parameters<typeof createHostEnvelope>[1],
|
||||
);
|
||||
iframe.contentWindow.postMessage(envelope, '*');
|
||||
},
|
||||
[id]
|
||||
[id],
|
||||
);
|
||||
|
||||
// Handle messages from the renderer iframe
|
||||
// ── Handle messages from the renderer iframe ──────────────────────────────
|
||||
useEffect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (!isRendererEnvelope(event.data)) return;
|
||||
@@ -59,10 +70,13 @@ export function LiveArtboard({
|
||||
const msg: RendererMessage = event.data.message;
|
||||
switch (msg.type) {
|
||||
case 'READY':
|
||||
// The fiber hook is already installed — either by the CLI proxy
|
||||
// (injected into the HTML response) or by @originmain/live SDK
|
||||
// (imported before React in the user's app). No injection needed.
|
||||
isReadyRef.current = true;
|
||||
// Push current design tokens into the iframe immediately.
|
||||
if (designTokens) sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
|
||||
// Restore any active selection that existed before the iframe loaded.
|
||||
if (selectedComponentId) {
|
||||
sendMessage('SELECT_COMPONENT', { nodeId: selectedComponentId });
|
||||
}
|
||||
onReady?.();
|
||||
break;
|
||||
case 'FIBER_TREE_UPDATE':
|
||||
@@ -76,26 +90,39 @@ export function LiveArtboard({
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [id, designTokens, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected]);
|
||||
}, [id, designTokens, selectedComponentId, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected]);
|
||||
|
||||
// Push updated design tokens whenever they change
|
||||
// ── Push updated design tokens whenever they change ───────────────────────
|
||||
useEffect(() => {
|
||||
if (!designTokens) return;
|
||||
if (!isReadyRef.current || !designTokens) return;
|
||||
sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
|
||||
}, [designTokens, sendMessage]);
|
||||
|
||||
// ── Sync selection changes into the iframe ────────────────────────────────
|
||||
// Sends SELECT_COMPONENT on every selectedComponentId change so the blue
|
||||
// highlight ring stays in sync with the canvas selection store.
|
||||
useEffect(() => {
|
||||
if (!isReadyRef.current) return;
|
||||
if (selectedComponentId) {
|
||||
sendMessage('SELECT_COMPONENT', { nodeId: selectedComponentId });
|
||||
} else {
|
||||
sendMessage('DESELECT');
|
||||
}
|
||||
}, [selectedComponentId, sendMessage]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
// The name attribute carries the artboard ID to the fiber hook.
|
||||
// The hook reads window.name to tag postMessage envelopes.
|
||||
// The hook reads window.name to tag postMessage envelopes and to
|
||||
// guard against activating outside Originmain iframes.
|
||||
// Format: "om:<artboardId>"
|
||||
name={`om:${id}`}
|
||||
src={src}
|
||||
title={`artboard-${id}`}
|
||||
// Security: allow-scripts required to run React; allow-same-origin required
|
||||
// for postMessage with targeted origin validation. Do NOT combine these with
|
||||
// untrusted third-party content.
|
||||
// Security: allow-scripts required for React; allow-same-origin required
|
||||
// for postMessage origin validation. Do NOT add allow-top-navigation or
|
||||
// allow-popups unless explicitly needed — principle of least privilege.
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
style={{
|
||||
width,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useCanvas, type Tool } from '@/store/canvas';
|
||||
import { useViewport } from '@/store/viewport';
|
||||
import { useTheme } from '@/store/theme';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { useWalkthrough } from '@/store/walkthrough';
|
||||
|
||||
interface AppChromeProps {
|
||||
workspaceId?: string;
|
||||
@@ -26,6 +27,7 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
||||
const setActiveTool = useCanvas((s) => s.setActiveTool);
|
||||
const { mode: themeMode, toggle: toggleTheme } = useTheme();
|
||||
const CT = useCanvasTheme();
|
||||
const startTour = useWalkthrough((s) => s.start);
|
||||
|
||||
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
|
||||
useEffect(() => {
|
||||
@@ -168,6 +170,36 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Tour trigger */}
|
||||
<button
|
||||
onClick={startTour}
|
||||
title="Start product tour"
|
||||
aria-label="Start product tour"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: `1px solid ${CT.border}`,
|
||||
cursor: 'pointer',
|
||||
color: CT.fgMuted,
|
||||
width: 22, height: 22, borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '0.6875rem', fontWeight: 600, marginRight: 6,
|
||||
transition: 'color 0.12s, background 0.12s, border-color 0.12s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.color = CT.accent;
|
||||
e.currentTarget.style.borderColor = `${CT.accent}66`;
|
||||
e.currentTarget.style.background = `${CT.accent}14`;
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.color = CT.fgMuted;
|
||||
e.currentTarget.style.borderColor = CT.border;
|
||||
e.currentTarget.style.background = 'none';
|
||||
}}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
|
||||
@@ -29,6 +29,7 @@ export function Inspector() {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tour="inspector-panel"
|
||||
className="dark-panel"
|
||||
style={{
|
||||
gridColumn: 3,
|
||||
|
||||
@@ -109,6 +109,7 @@ export function ArtboardNavigator() {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tour="navigator-panel"
|
||||
className="dark-panel"
|
||||
style={{
|
||||
gridColumn: 1,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import { UserButton } from '@clerk/nextjs';
|
||||
import { useTheme } from '@/store/theme';
|
||||
import { useWalkthrough } from '@/store/walkthrough';
|
||||
|
||||
interface Crumb { label: string; href?: string }
|
||||
|
||||
@@ -16,6 +17,7 @@ interface AppHeaderProps {
|
||||
|
||||
export function AppHeader({ breadcrumbs = [], workspaceName, workspaceId }: AppHeaderProps) {
|
||||
const { mode, toggle } = useTheme();
|
||||
const startTour = useWalkthrough((s) => s.start);
|
||||
|
||||
// Back-compat: if old props are passed without breadcrumbs, synthesise them
|
||||
const crumbs: Crumb[] = breadcrumbs.length > 0
|
||||
@@ -73,6 +75,34 @@ export function AppHeader({ breadcrumbs = [], workspaceName, workspaceId }: AppH
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Tour trigger */}
|
||||
<button
|
||||
onClick={startTour}
|
||||
title="Start product tour"
|
||||
aria-label="Start product tour"
|
||||
style={{
|
||||
background: 'none', border: `1px solid ${mode === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.10)'}`,
|
||||
cursor: 'pointer',
|
||||
color: mode === 'dark' ? 'rgba(255,255,255,0.4)' : '#A1A1AA',
|
||||
width: 26, height: 26, borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '0.75rem', fontWeight: 600, marginRight: 8,
|
||||
transition: 'color 0.12s, background 0.12s, border-color 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.color = '#3385FF';
|
||||
e.currentTarget.style.borderColor = 'rgba(51,133,255,0.45)';
|
||||
e.currentTarget.style.background = 'rgba(51,133,255,0.08)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.4)' : '#A1A1AA';
|
||||
e.currentTarget.style.borderColor = mode === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.10)';
|
||||
e.currentTarget.style.background = 'none';
|
||||
}}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
|
||||
@@ -26,6 +26,7 @@ export function ProjectCard({ workspaceId, projectId, name, framework, appUrl, d
|
||||
return (
|
||||
<Link href={`/workspace/${workspaceId}/project/${projectId}`} style={{ textDecoration: 'none' }}>
|
||||
<div
|
||||
data-tour="project-card"
|
||||
style={{
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--card-border)',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useWalkthrough } from '@/store/walkthrough';
|
||||
|
||||
/**
|
||||
* Invisible client component that auto-starts the product tour for
|
||||
* first-time visitors. Renders null — mount it anywhere on the page.
|
||||
*
|
||||
* Logic:
|
||||
* • Skip if the user has already completed the tour (persisted in localStorage).
|
||||
* • Skip if the tour is already active.
|
||||
* • Use a sessionStorage flag so the auto-start fires at most once per
|
||||
* browser session (prevents re-firing on every client navigation).
|
||||
* • A 900 ms delay lets the page fully paint before the overlay appears.
|
||||
*/
|
||||
export function TourAutoStart() {
|
||||
const completed = useWalkthrough((s) => s.completed);
|
||||
const active = useWalkthrough((s) => s.active);
|
||||
const start = useWalkthrough((s) => s.start);
|
||||
|
||||
useEffect(() => {
|
||||
// Already done or currently running — nothing to do.
|
||||
if (completed || active) return;
|
||||
|
||||
const FLAG = 'originmain:tour-auto-started';
|
||||
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(FLAG)) return;
|
||||
|
||||
// Mark as fired for this session before the timeout so a fast re-render
|
||||
// doesn't double-fire.
|
||||
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(FLAG, '1');
|
||||
|
||||
const timer = setTimeout(start, 900);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // intentionally empty — run only on first mount
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useWalkthrough } from '@/store/walkthrough';
|
||||
import { TOUR_STEPS } from './tourSteps';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Rect { x: number; y: number; width: number; height: number }
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TOOLTIP_WIDTH = 340;
|
||||
const SPOTLIGHT_PAD = 8; // px of padding around the spotlight target
|
||||
const GAP = 14; // px gap between spotlight and tooltip card
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function TourOverlay() {
|
||||
const { active, stepIndex, next, prev, dismiss } = useWalkthrough();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [targetRect, setTargetRect] = useState<Rect | null>(null);
|
||||
const [pathMismatch, setPathMismatch] = useState(false);
|
||||
|
||||
// Only render portals after client hydration.
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
const step = TOUR_STEPS[stepIndex];
|
||||
|
||||
// ── Measure the spotlight target element ─────────────────────────────────
|
||||
const measureTarget = useCallback(() => {
|
||||
if (!step) { setTargetRect(null); return; }
|
||||
|
||||
const { targetSelector, requiredPathPart } = step;
|
||||
|
||||
// Check we're on the right page for this step.
|
||||
const onCorrectPage = !requiredPathPart ||
|
||||
window.location.pathname.includes(requiredPathPart);
|
||||
setPathMismatch(!onCorrectPage);
|
||||
|
||||
if (!targetSelector || !onCorrectPage) {
|
||||
setTargetRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const el = document.querySelector(targetSelector);
|
||||
if (!el) {
|
||||
setTargetRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = el.getBoundingClientRect();
|
||||
setTargetRect({ x: r.x, y: r.y, width: r.width, height: r.height });
|
||||
}, [step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) { setTargetRect(null); return; }
|
||||
measureTarget();
|
||||
|
||||
// Re-measure on resize and scroll so the spotlight stays aligned.
|
||||
window.addEventListener('resize', measureTarget);
|
||||
window.addEventListener('scroll', measureTarget, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', measureTarget);
|
||||
window.removeEventListener('scroll', measureTarget, true);
|
||||
};
|
||||
}, [active, measureTarget]);
|
||||
|
||||
// ── Keyboard navigation ───────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); dismiss(); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); next(TOUR_STEPS.length); }
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); prev(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [active, next, prev, dismiss]);
|
||||
|
||||
// ── Compute tooltip position ──────────────────────────────────────────────
|
||||
const tooltipStyle = computeTooltipPosition(targetRect, step?.placement ?? 'auto');
|
||||
|
||||
if (!mounted || !active || !step) return null;
|
||||
|
||||
const isFirstStep = stepIndex === 0;
|
||||
const isLastStep = stepIndex === TOUR_STEPS.length - 1;
|
||||
const progress = ((stepIndex + 1) / TOUR_STEPS.length) * 100;
|
||||
|
||||
// Spotlight rect with padding applied.
|
||||
const spotlight = targetRect ? {
|
||||
x: targetRect.x - SPOTLIGHT_PAD,
|
||||
y: targetRect.y - SPOTLIGHT_PAD,
|
||||
width: targetRect.width + SPOTLIGHT_PAD * 2,
|
||||
height: targetRect.height + SPOTLIGHT_PAD * 2,
|
||||
} : null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* ── Dim backdrop (click outside = dismiss) ─────────────────────── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
onClick={dismiss}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
zIndex: 9990,
|
||||
// When there's a spotlight, the backdrop darkness is created by the
|
||||
// spotlight element's box-shadow, so we only need a backdrop when
|
||||
// the tour card is centered (no spotlight).
|
||||
background: spotlight ? 'transparent' : 'rgba(0,0,0,0.55)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Spotlight ring (box-shadow creates dark outside the rect) ────── */}
|
||||
{spotlight && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: spotlight.x,
|
||||
top: spotlight.y,
|
||||
width: spotlight.width,
|
||||
height: spotlight.height,
|
||||
borderRadius: 8,
|
||||
// 9999px box-shadow fills the entire viewport outside this rect.
|
||||
boxShadow: '0 0 0 9999px rgba(0,0,0,0.55)',
|
||||
border: '2px solid rgba(51,133,255,0.7)',
|
||||
zIndex: 9991,
|
||||
pointerEvents: 'none',
|
||||
transition: 'all 0.12s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Tooltip card ──────────────────────────────────────────────────── */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={step.id}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
zIndex: 9999,
|
||||
width: TOOLTIP_WIDTH,
|
||||
...tooltipStyle,
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--card-border)',
|
||||
borderRadius: 16,
|
||||
padding: '22px 24px 20px',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.22), 0 2px 8px rgba(0,0,0,0.12)',
|
||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||
}}
|
||||
// Prevent click from propagating to the backdrop (which would dismiss)
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* ── Progress bar ─────────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
height: 2, borderRadius: 2,
|
||||
background: 'var(--card-border)',
|
||||
marginBottom: 18,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 2,
|
||||
background: '#3385FF',
|
||||
width: `${progress}%`,
|
||||
transition: 'width 0.25s ease',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* ── Step counter ─────────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '0.6875rem', fontWeight: 600, letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase', color: '#3385FF',
|
||||
}}>
|
||||
Step {stepIndex + 1} of {TOUR_STEPS.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
aria-label="Close tour"
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--card-muted)', padding: '2px 4px',
|
||||
display: 'flex', alignItems: 'center', borderRadius: 4,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Title ────────────────────────────────────────────────────── */}
|
||||
<h3 style={{
|
||||
fontSize: '1rem', fontWeight: 700, letterSpacing: '-0.025em',
|
||||
color: 'var(--card-text)', margin: '0 0 10px',
|
||||
}}>
|
||||
{step.title}
|
||||
</h3>
|
||||
|
||||
{/* ── Body ─────────────────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
fontSize: '0.8125rem', color: 'var(--card-muted)',
|
||||
lineHeight: 1.65, margin: '0 0 14px',
|
||||
}}>
|
||||
{step.body}
|
||||
</div>
|
||||
|
||||
{/* ── Path mismatch hint ───────────────────────────────────────── */}
|
||||
{pathMismatch && step.requiredPathPart && (
|
||||
<div style={{
|
||||
padding: '8px 10px', borderRadius: 7, marginBottom: 14,
|
||||
background: 'rgba(51,133,255,0.08)',
|
||||
border: '1px solid rgba(51,133,255,0.2)',
|
||||
fontSize: '0.75rem', color: 'var(--card-muted)', lineHeight: 1.5,
|
||||
}}>
|
||||
💡 Navigate to the{' '}
|
||||
<strong style={{ color: 'var(--card-text)' }}>
|
||||
{step.requiredPathPart.includes('/project/') ? 'canvas' : 'workspace dashboard'}
|
||||
</strong>{' '}
|
||||
to follow this step interactively.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CLI code block ───────────────────────────────────────────── */}
|
||||
{step.codeBlock && (
|
||||
<pre style={{
|
||||
background: 'var(--card-subtle)',
|
||||
border: '1px solid var(--card-border)',
|
||||
borderRadius: 8, padding: '10px 12px',
|
||||
fontSize: '0.6875rem', lineHeight: 1.7,
|
||||
fontFamily: "'JetBrains Mono', 'SF Mono', ui-monospace, monospace",
|
||||
color: 'var(--card-text)',
|
||||
margin: '0 0 14px',
|
||||
overflow: 'auto', whiteSpace: 'pre',
|
||||
}}>
|
||||
{step.codeBlock}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* ── Navigation ───────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 4 }}>
|
||||
{/* Back button */}
|
||||
{!isFirstStep && (
|
||||
<button
|
||||
onClick={prev}
|
||||
style={{
|
||||
padding: '8px 14px', borderRadius: 8,
|
||||
border: '1px solid var(--card-border)',
|
||||
background: 'var(--btn-idle-bg)',
|
||||
color: 'var(--btn-idle-fg)',
|
||||
fontSize: '0.8125rem', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Next / Finish button */}
|
||||
<button
|
||||
onClick={() => next(TOUR_STEPS.length)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '9px 16px', borderRadius: 8,
|
||||
border: 'none',
|
||||
background: isLastStep ? '#10B981' : '#3385FF',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '0.8125rem', fontWeight: 600,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
letterSpacing: '-0.01em',
|
||||
transition: 'background 0.12s',
|
||||
}}
|
||||
>
|
||||
{isFirstStep
|
||||
? "Let's go →"
|
||||
: isLastStep
|
||||
? 'Start building ✓'
|
||||
: 'Next →'}
|
||||
</button>
|
||||
|
||||
{/* Skip tour */}
|
||||
{!isLastStep && (
|
||||
<button
|
||||
onClick={dismiss}
|
||||
style={{
|
||||
padding: '8px 10px', background: 'none', border: 'none',
|
||||
color: 'var(--card-muted)', fontSize: '0.75rem',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Spotlight arrow (pointer from tooltip toward the target) ────── */}
|
||||
{spotlight && <SpotlightArrow spotlight={spotlight} tooltipStyle={tooltipStyle} />}
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Spotlight arrow ───────────────────────────────────────────────────────────
|
||||
// A small triangle that connects the tooltip card to the spotlit element.
|
||||
|
||||
function SpotlightArrow({
|
||||
spotlight,
|
||||
tooltipStyle,
|
||||
}: {
|
||||
spotlight: Rect;
|
||||
tooltipStyle: React.CSSProperties;
|
||||
}) {
|
||||
// Determine rough position of tooltip relative to spotlight centre to pick
|
||||
// the correct arrow direction.
|
||||
const slCX = spotlight.x + spotlight.width / 2;
|
||||
const slCY = spotlight.y + spotlight.height / 2;
|
||||
|
||||
// Resolve tooltip top-left from the style object (may use numbers or strings)
|
||||
const ttTop = typeof tooltipStyle.top === 'number' ? tooltipStyle.top : 0;
|
||||
const ttLeft = typeof tooltipStyle.left === 'number' ? tooltipStyle.left : 0;
|
||||
const ttCX = ttLeft + TOOLTIP_WIDTH / 2;
|
||||
|
||||
// Arrow placed on the edge of the spotlight nearest the tooltip.
|
||||
const above = ttTop < slCY; // tooltip is above spotlight
|
||||
|
||||
const arrowX = Math.min(
|
||||
Math.max(ttCX, spotlight.x + 12),
|
||||
spotlight.x + spotlight.width - 12,
|
||||
);
|
||||
|
||||
if (above) {
|
||||
// Arrow at bottom of tooltip (pointing down toward spotlight)
|
||||
return (
|
||||
<div aria-hidden="true" style={{
|
||||
position: 'fixed',
|
||||
left: arrowX - 7,
|
||||
top: Number(ttTop) + /* tooltip est. height */ 260,
|
||||
width: 14, height: 8,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 14, height: 14,
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--card-border)',
|
||||
transform: 'rotate(45deg) translateY(-7px)',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Arrow at top of tooltip (pointing up toward spotlight — tooltip is below)
|
||||
const arrowTop = typeof tooltipStyle.top === 'number' ? tooltipStyle.top - 8 : 0;
|
||||
return (
|
||||
<div aria-hidden="true" style={{
|
||||
position: 'fixed',
|
||||
left: arrowX - 7,
|
||||
top: arrowTop,
|
||||
width: 14, height: 8,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 14, height: 14,
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--card-border)',
|
||||
transform: 'rotate(45deg) translateY(1px)',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tooltip positioning ───────────────────────────────────────────────────────
|
||||
|
||||
function computeTooltipPosition(
|
||||
rect: Rect | null,
|
||||
placement: TourStep['placement'],
|
||||
): React.CSSProperties {
|
||||
const vw = typeof window !== 'undefined' ? window.innerWidth : 1280;
|
||||
const vh = typeof window !== 'undefined' ? window.innerHeight : 800;
|
||||
const pad = 16;
|
||||
|
||||
if (!rect) {
|
||||
// Centered card
|
||||
return {
|
||||
left: Math.max(pad, (vw - TOOLTIP_WIDTH) / 2),
|
||||
top: Math.max(pad, vh / 2 - 160),
|
||||
};
|
||||
}
|
||||
|
||||
const sl = {
|
||||
x: rect.x - SPOTLIGHT_PAD,
|
||||
y: rect.y - SPOTLIGHT_PAD,
|
||||
width: rect.width + SPOTLIGHT_PAD * 2,
|
||||
height: rect.height + SPOTLIGHT_PAD * 2,
|
||||
};
|
||||
|
||||
// Clamp a horizontal position to stay within viewport.
|
||||
const clampLeft = (l: number) =>
|
||||
Math.max(pad, Math.min(vw - TOOLTIP_WIDTH - pad, l));
|
||||
|
||||
const centredLeft = clampLeft(sl.x + sl.width / 2 - TOOLTIP_WIDTH / 2);
|
||||
|
||||
const spaceBelow = vh - (sl.y + sl.height);
|
||||
const spaceAbove = sl.y;
|
||||
const spaceRight = vw - (sl.x + sl.width);
|
||||
const spaceLeft = sl.x;
|
||||
|
||||
const effectivePlacement = placement === 'auto' || !placement
|
||||
? spaceBelow >= 240 ? 'bottom'
|
||||
: spaceAbove >= 240 ? 'top'
|
||||
: spaceRight >= TOOLTIP_WIDTH + GAP ? 'right'
|
||||
: spaceLeft >= TOOLTIP_WIDTH + GAP ? 'left'
|
||||
: 'bottom'
|
||||
: placement;
|
||||
|
||||
switch (effectivePlacement) {
|
||||
case 'bottom':
|
||||
return { left: centredLeft, top: sl.y + sl.height + GAP };
|
||||
case 'top':
|
||||
return { left: centredLeft, top: Math.max(pad, sl.y - GAP - 280) };
|
||||
case 'right':
|
||||
return { left: sl.x + sl.width + GAP, top: Math.max(pad, sl.y) };
|
||||
case 'left':
|
||||
return { left: Math.max(pad, sl.x - TOOLTIP_WIDTH - GAP), top: Math.max(pad, sl.y) };
|
||||
default:
|
||||
return { left: centredLeft, top: sl.y + sl.height + GAP };
|
||||
}
|
||||
}
|
||||
|
||||
// Imported only for the type — avoids a circular import.
|
||||
import type { TourStep } from './tourSteps';
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface TourStep {
|
||||
/** Unique key — also used as aria-label. */
|
||||
id: string;
|
||||
title: string;
|
||||
body: ReactNode;
|
||||
/** Optional shell command shown in a monospace code block. */
|
||||
codeBlock?: string;
|
||||
/** CSS selector for the element to spotlight. Falls back to centered modal. */
|
||||
targetSelector?: string;
|
||||
/** Which side of the spotlight to place the tooltip.
|
||||
* 'auto' (default): below if space available, otherwise above. */
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right' | 'auto';
|
||||
/** URL substring — if present and current URL does NOT include it,
|
||||
* the spotlight is skipped and the card shows a navigation hint. */
|
||||
requiredPathPart?: string;
|
||||
}
|
||||
|
||||
// ── Tour steps ─────────────────────────────────────────────────────────────────
|
||||
// Steps walk the user from the workspace dashboard all the way through live
|
||||
// component inspection on the canvas. Descriptions are concise (< 3 sentences).
|
||||
|
||||
export const TOUR_STEPS: TourStep[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
title: 'Welcome to Originmain',
|
||||
body: (
|
||||
<>
|
||||
Originmain gives you a live visual canvas for your running React apps —
|
||||
inspect components, generate intent diffs, and ship changes with AI. Let's
|
||||
get you set up in under 2 minutes.
|
||||
</>
|
||||
),
|
||||
// No target — renders as a centered welcome card.
|
||||
},
|
||||
|
||||
{
|
||||
id: 'workspace-projects',
|
||||
title: 'Your workspace',
|
||||
body: (
|
||||
<>
|
||||
This is your workspace. It holds all your projects, team members, and
|
||||
design language files. Each project connects to a single running
|
||||
application — local, staging, or preview.
|
||||
</>
|
||||
),
|
||||
targetSelector: '[data-tour="projects-section"]',
|
||||
placement: 'bottom',
|
||||
requiredPathPart: '/workspace/',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'new-project',
|
||||
title: 'Create a project',
|
||||
body: (
|
||||
<>
|
||||
Click <strong>New project</strong> to create your first project.
|
||||
You'll give it a name, an optional URL, and a framework — you can
|
||||
always change these later in project settings.
|
||||
</>
|
||||
),
|
||||
targetSelector: '[data-tour="new-project-btn"]',
|
||||
placement: 'bottom',
|
||||
requiredPathPart: '/workspace/',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'open-canvas',
|
||||
title: 'Open the canvas',
|
||||
body: (
|
||||
<>
|
||||
Click any project card to open its visual canvas editor. The canvas is
|
||||
where live rendering, component inspection, diff generation, and AI
|
||||
queries all live.
|
||||
</>
|
||||
),
|
||||
targetSelector: '[data-tour="project-card"]',
|
||||
placement: 'bottom',
|
||||
requiredPathPart: '/workspace/',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'canvas-overview',
|
||||
title: 'The canvas editor',
|
||||
body: (
|
||||
<>
|
||||
Left panel: artboard navigator and file tree. Center: infinite canvas
|
||||
with your artboards. Right: the inspector — props, diffs, and the live
|
||||
component graph. Press <kbd style={{ fontFamily: 'inherit' }}>V</kbd>,{' '}
|
||||
<kbd style={{ fontFamily: 'inherit' }}>H</kbd>,{' '}
|
||||
<kbd style={{ fontFamily: 'inherit' }}>A</kbd> to switch tools.
|
||||
</>
|
||||
),
|
||||
// No target — full-canvas overview shown as a centered card.
|
||||
requiredPathPart: '/project/',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'connect-app',
|
||||
title: 'Connect your app',
|
||||
body: (
|
||||
<>
|
||||
Run the CLI proxy in a second terminal. It strips iframing headers and
|
||||
injects the Originmain fiber hook into your dev server's HTML — zero
|
||||
changes to your app code needed.
|
||||
</>
|
||||
),
|
||||
codeBlock: `# Terminal 2 — keep your dev server running in Terminal 1
|
||||
npx @originmain/cli dev --target http://localhost:3000
|
||||
|
||||
# ✓ Proxy listening on http://localhost:4170
|
||||
# Paste http://localhost:4170 into the artboard URL input`,
|
||||
targetSelector: '[data-tour="artboard-empty"]',
|
||||
placement: 'right',
|
||||
requiredPathPart: '/project/',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'enter-url',
|
||||
title: 'Enter the proxy URL',
|
||||
body: (
|
||||
<>
|
||||
Click <strong>Enter proxy URL</strong>, paste{' '}
|
||||
<code
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: '0.8em',
|
||||
background: 'var(--card-subtle)',
|
||||
padding: '1px 4px',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
http://localhost:4170
|
||||
</code>
|
||||
, and press Connect. The artboard will render your live app and begin
|
||||
streaming the React fiber tree.
|
||||
</>
|
||||
),
|
||||
targetSelector: '[data-tour="artboard-url-btn"]',
|
||||
placement: 'top',
|
||||
requiredPathPart: '/project/',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'navigator-live',
|
||||
title: 'Live connection established',
|
||||
body: (
|
||||
<>
|
||||
A pulsing green dot in the navigator means Originmain has an active
|
||||
fiber connection. Every React commit — including HMR hot updates —
|
||||
streams the full component tree to the canvas in real time.
|
||||
</>
|
||||
),
|
||||
targetSelector: '[data-tour="navigator-panel"]',
|
||||
placement: 'right',
|
||||
requiredPathPart: '/project/',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'inspector',
|
||||
title: 'Inspect any component',
|
||||
body: (
|
||||
<>
|
||||
Click any element in the canvas to select it. The inspector shows its
|
||||
live props, lets you export an intent diff, and gives you a full
|
||||
component graph. The AI query bar runs cross-artboard analysis.
|
||||
</>
|
||||
),
|
||||
targetSelector: '[data-tour="inspector-panel"]',
|
||||
placement: 'left',
|
||||
requiredPathPart: '/project/',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'done',
|
||||
title: "You're all set 🎉",
|
||||
body: (
|
||||
<>
|
||||
You know how to connect an app, inspect components, and navigate the
|
||||
canvas. Explore the diff tab to generate intent diffs, or add more
|
||||
artboards with{' '}
|
||||
<kbd style={{ fontFamily: 'inherit' }}>A</kbd> and connect different
|
||||
routes.
|
||||
</>
|
||||
),
|
||||
// Centered finish card — no spotlight.
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface WalkthroughStore {
|
||||
/** Whether the tour overlay is currently visible. */
|
||||
active: boolean;
|
||||
/** Zero-based index of the current step. */
|
||||
stepIndex: number;
|
||||
/** True once the user reaches the final step and closes the tour. */
|
||||
completed: boolean;
|
||||
start: () => void;
|
||||
next: (totalSteps: number) => void;
|
||||
prev: () => void;
|
||||
dismiss: () => void;
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
export const useWalkthrough = create<WalkthroughStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
active: false,
|
||||
stepIndex: 0,
|
||||
completed: false,
|
||||
|
||||
start: () => set({ active: true, stepIndex: 0 }),
|
||||
|
||||
next: (totalSteps: number) =>
|
||||
set((s) => {
|
||||
const next = s.stepIndex + 1;
|
||||
if (next >= totalSteps) {
|
||||
// Reached the end — mark complete and close.
|
||||
return { active: false, completed: true, stepIndex: 0 };
|
||||
}
|
||||
return { stepIndex: next };
|
||||
}),
|
||||
|
||||
prev: () =>
|
||||
set((s) => ({ stepIndex: Math.max(0, s.stepIndex - 1) })),
|
||||
|
||||
dismiss: () => set({ active: false }),
|
||||
|
||||
restart: () => set({ active: true, stepIndex: 0, completed: false }),
|
||||
}),
|
||||
{
|
||||
name: 'originmain:tour',
|
||||
},
|
||||
),
|
||||
);
|
||||
+225
-56
@@ -1,28 +1,37 @@
|
||||
// ── Originmain Fiber Hook ────────────────────────────────────────────────────
|
||||
// This module installs a React DevTools–compatible 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 DevTools–compatible 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 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,69 +79,64 @@ 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.
|
||||
post({ type: 'READY' });
|
||||
}
|
||||
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
|
||||
|
||||
// ── Fiber Serialization ──────────────────────────────────────────────────────
|
||||
|
||||
interface FiberLike {
|
||||
type: unknown;
|
||||
index: number;
|
||||
child: FiberLike | null;
|
||||
sibling: FiberLike | null;
|
||||
stateNode: unknown;
|
||||
memoizedProps: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface SerializedNode {
|
||||
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 {
|
||||
function serializeFiber(
|
||||
fiber: FiberLike | null,
|
||||
parentId: string,
|
||||
): SerializedNode | null {
|
||||
if (!fiber) return null;
|
||||
|
||||
const name = getDisplayName(fiber);
|
||||
if (!name) return serializeFiber(fiber.child);
|
||||
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: String(fiber.index || Math.random()),
|
||||
id: nodeId,
|
||||
name,
|
||||
props: serializeProps(fiber.memoizedProps),
|
||||
children: [],
|
||||
};
|
||||
if (rect) {
|
||||
node.domRect = rect;
|
||||
}
|
||||
if (rect) node.domRect = rect;
|
||||
|
||||
let child = fiber.child;
|
||||
while (child) {
|
||||
const serialized = serializeFiber(child);
|
||||
const serialized = serializeFiber(child, nodeId);
|
||||
if (serialized) node.children.push(serialized);
|
||||
child = child.sibling;
|
||||
}
|
||||
@@ -150,14 +160,14 @@ function getDisplayName(fiber: FiberLike): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDomRect(fiber: FiberLike): { x: number; y: number; width: number; height: number } | 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 { /* no DOM node */ }
|
||||
} catch { /* stateNode has no layout */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -176,3 +186,162 @@ function serializeProps(
|
||||
}
|
||||
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' });
|
||||
}
|
||||
|
||||
// ── Internal types ────────────────────────────────────────────────────────────
|
||||
|
||||
interface FiberLike {
|
||||
type: unknown;
|
||||
index: number;
|
||||
child: FiberLike | null;
|
||||
sibling: FiberLike | null;
|
||||
stateNode: unknown;
|
||||
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>;
|
||||
children: SerializedNode[];
|
||||
domRect?: DomRect;
|
||||
}
|
||||
|
||||
@@ -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,17 +102,22 @@ 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:';
|
||||
@@ -130,57 +126,84 @@ export function buildProxyFiberHookScript(): string {
|
||||
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;
|
||||
|
||||
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: String(fiber.index || Math.random()),
|
||||
name: name,
|
||||
props: serializeProps(fiber.memoizedProps),
|
||||
children: [],
|
||||
};
|
||||
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