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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { originmainLightTheme, originmainDarkTheme } from '@originmain/ui';
|
import { originmainLightTheme, originmainDarkTheme } from '@originmain/ui';
|
||||||
import { useTheme } from '@/store/theme';
|
import { useTheme } from '@/store/theme';
|
||||||
|
import { TourOverlay } from '@/components/walkthrough/TourOverlay';
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
// TanStack Query v5: create QueryClient inside useState so it's stable across
|
// 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}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<FluentProvider theme={theme} style={{ height: '100%' }}>
|
<FluentProvider theme={theme} style={{ height: '100%' }}>
|
||||||
{children}
|
{children}
|
||||||
|
<TourOverlay />
|
||||||
</FluentProvider>
|
</FluentProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { serverClient } from '@/lib/supabase';
|
|||||||
import { AppHeader } from '@/components/shell/AppHeader';
|
import { AppHeader } from '@/components/shell/AppHeader';
|
||||||
import { ProjectCard } from '@/components/shell/ProjectCard';
|
import { ProjectCard } from '@/components/shell/ProjectCard';
|
||||||
import { DesignLanguageUpload } from '@/components/shell/DesignLanguageUpload';
|
import { DesignLanguageUpload } from '@/components/shell/DesignLanguageUpload';
|
||||||
|
import { TourAutoStart } from '@/components/walkthrough/TourAutoStart';
|
||||||
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
|
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
|
||||||
import type { Workspace, Project } 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 (
|
return (
|
||||||
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
|
<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={[
|
<AppHeader breadcrumbs={[
|
||||||
{ label: 'Workspaces', href: '/workspaces' },
|
{ label: 'Workspaces', href: '/workspaces' },
|
||||||
{ label: workspace.name },
|
{ 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' }}>
|
<main style={{ maxWidth: 960, margin: '0 auto', padding: '48px 24px' }}>
|
||||||
|
|
||||||
{/* Title row */}
|
{/* Title row — data-tour="projects-section" anchors the tour spotlight */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 32 }}>
|
<div data-tour="projects-section" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 32 }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, letterSpacing: '-0.03em', color: 'var(--page-text)', margin: 0 }}>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, letterSpacing: '-0.03em', color: 'var(--page-text)', margin: 0 }}>
|
||||||
Projects
|
Projects
|
||||||
@@ -92,7 +95,10 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
|
|||||||
</svg>
|
</svg>
|
||||||
Settings
|
Settings
|
||||||
</Link>
|
</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,
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
background: 'var(--btn-bg)', color: 'var(--btn-fg)',
|
background: 'var(--btn-bg)', color: 'var(--btn-fg)',
|
||||||
fontSize: '0.875rem', fontWeight: 600,
|
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) {
|
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 selected = selectedArtboardId === id;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -305,6 +309,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
|
|||||||
src={renderUrl}
|
src={renderUrl}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
|
selectedComponentId={selectedComponentId}
|
||||||
onFiberTreeUpdate={handleFiberUpdate}
|
onFiberTreeUpdate={handleFiberUpdate}
|
||||||
onComponentSelected={handleComponentSelected}
|
onComponentSelected={handleComponentSelected}
|
||||||
/>
|
/>
|
||||||
@@ -360,6 +365,7 @@ function EmptyArtboardContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-tour="artboard-empty"
|
||||||
style={{
|
style={{
|
||||||
width, height, display: 'flex', flexDirection: 'column',
|
width, height, display: 'flex', flexDirection: 'column',
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
@@ -432,6 +438,7 @@ function EmptyArtboardContent({
|
|||||||
npx @originmain/cli dev --target :3000
|
npx @originmain/cli dev --target :3000
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
data-tour="artboard-url-btn"
|
||||||
onClick={() => setEditing(true)}
|
onClick={() => setEditing(true)}
|
||||||
style={{
|
style={{
|
||||||
padding: '7px 14px', borderRadius: 6, border: '1px solid rgba(0,0,0,0.12)',
|
padding: '7px 14px', borderRadius: 6, border: '1px solid rgba(0,0,0,0.12)',
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ export interface LiveArtboardProps {
|
|||||||
src: string;
|
src: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
/** DLF design tokens to inject into the iframe as CSS custom properties. */
|
||||||
designTokens?: Record<string, string>;
|
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;
|
onReady?: () => void;
|
||||||
onFiberTreeUpdate?: (root: FiberNode) => void;
|
onFiberTreeUpdate?: (root: FiberNode) => void;
|
||||||
onComponentSelected?: (nodeId: string) => void;
|
onComponentSelected?: (nodeId: string) => void;
|
||||||
@@ -32,25 +37,31 @@ export function LiveArtboard({
|
|||||||
width = 1280,
|
width = 1280,
|
||||||
height = 720,
|
height = 720,
|
||||||
designTokens,
|
designTokens,
|
||||||
|
selectedComponentId,
|
||||||
onReady,
|
onReady,
|
||||||
onFiberTreeUpdate,
|
onFiberTreeUpdate,
|
||||||
onComponentSelected,
|
onComponentSelected,
|
||||||
style,
|
style,
|
||||||
}: LiveArtboardProps) {
|
}: LiveArtboardProps) {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
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(
|
const sendMessage = useCallback(
|
||||||
(type: Parameters<typeof createHostEnvelope>[1]['type'], payload?: Record<string, unknown>) => {
|
(type: Parameters<typeof createHostEnvelope>[1]['type'], payload?: Record<string, unknown>) => {
|
||||||
const iframe = iframeRef.current;
|
const iframe = iframeRef.current;
|
||||||
if (!iframe?.contentWindow) return;
|
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, '*');
|
iframe.contentWindow.postMessage(envelope, '*');
|
||||||
},
|
},
|
||||||
[id]
|
[id],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle messages from the renderer iframe
|
// ── Handle messages from the renderer iframe ──────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleMessage(event: MessageEvent) {
|
function handleMessage(event: MessageEvent) {
|
||||||
if (!isRendererEnvelope(event.data)) return;
|
if (!isRendererEnvelope(event.data)) return;
|
||||||
@@ -59,10 +70,13 @@ export function LiveArtboard({
|
|||||||
const msg: RendererMessage = event.data.message;
|
const msg: RendererMessage = event.data.message;
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'READY':
|
case 'READY':
|
||||||
// The fiber hook is already installed — either by the CLI proxy
|
isReadyRef.current = true;
|
||||||
// (injected into the HTML response) or by @originmain/live SDK
|
// Push current design tokens into the iframe immediately.
|
||||||
// (imported before React in the user's app). No injection needed.
|
|
||||||
if (designTokens) sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
|
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?.();
|
onReady?.();
|
||||||
break;
|
break;
|
||||||
case 'FIBER_TREE_UPDATE':
|
case 'FIBER_TREE_UPDATE':
|
||||||
@@ -76,26 +90,39 @@ export function LiveArtboard({
|
|||||||
|
|
||||||
window.addEventListener('message', handleMessage);
|
window.addEventListener('message', handleMessage);
|
||||||
return () => window.removeEventListener('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(() => {
|
useEffect(() => {
|
||||||
if (!designTokens) return;
|
if (!isReadyRef.current || !designTokens) return;
|
||||||
sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
|
sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
|
||||||
}, [designTokens, sendMessage]);
|
}, [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 (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
// The name attribute carries the artboard ID to the fiber hook.
|
// 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>"
|
// Format: "om:<artboardId>"
|
||||||
name={`om:${id}`}
|
name={`om:${id}`}
|
||||||
src={src}
|
src={src}
|
||||||
title={`artboard-${id}`}
|
title={`artboard-${id}`}
|
||||||
// Security: allow-scripts required to run React; allow-same-origin required
|
// Security: allow-scripts required for React; allow-same-origin required
|
||||||
// for postMessage with targeted origin validation. Do NOT combine these with
|
// for postMessage origin validation. Do NOT add allow-top-navigation or
|
||||||
// untrusted third-party content.
|
// allow-popups unless explicitly needed — principle of least privilege.
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||||
style={{
|
style={{
|
||||||
width,
|
width,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useCanvas, type Tool } from '@/store/canvas';
|
|||||||
import { useViewport } from '@/store/viewport';
|
import { useViewport } from '@/store/viewport';
|
||||||
import { useTheme } from '@/store/theme';
|
import { useTheme } from '@/store/theme';
|
||||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||||
|
import { useWalkthrough } from '@/store/walkthrough';
|
||||||
|
|
||||||
interface AppChromeProps {
|
interface AppChromeProps {
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
@@ -26,6 +27,7 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
|||||||
const setActiveTool = useCanvas((s) => s.setActiveTool);
|
const setActiveTool = useCanvas((s) => s.setActiveTool);
|
||||||
const { mode: themeMode, toggle: toggleTheme } = useTheme();
|
const { mode: themeMode, toggle: toggleTheme } = useTheme();
|
||||||
const CT = useCanvasTheme();
|
const CT = useCanvasTheme();
|
||||||
|
const startTour = useWalkthrough((s) => s.start);
|
||||||
|
|
||||||
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
|
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -168,6 +170,36 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
|||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
<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 */}
|
{/* Theme toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function Inspector() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-tour="inspector-panel"
|
||||||
className="dark-panel"
|
className="dark-panel"
|
||||||
style={{
|
style={{
|
||||||
gridColumn: 3,
|
gridColumn: 3,
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export function ArtboardNavigator() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-tour="navigator-panel"
|
||||||
className="dark-panel"
|
className="dark-panel"
|
||||||
style={{
|
style={{
|
||||||
gridColumn: 1,
|
gridColumn: 1,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { UserButton } from '@clerk/nextjs';
|
import { UserButton } from '@clerk/nextjs';
|
||||||
import { useTheme } from '@/store/theme';
|
import { useTheme } from '@/store/theme';
|
||||||
|
import { useWalkthrough } from '@/store/walkthrough';
|
||||||
|
|
||||||
interface Crumb { label: string; href?: string }
|
interface Crumb { label: string; href?: string }
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ interface AppHeaderProps {
|
|||||||
|
|
||||||
export function AppHeader({ breadcrumbs = [], workspaceName, workspaceId }: AppHeaderProps) {
|
export function AppHeader({ breadcrumbs = [], workspaceName, workspaceId }: AppHeaderProps) {
|
||||||
const { mode, toggle } = useTheme();
|
const { mode, toggle } = useTheme();
|
||||||
|
const startTour = useWalkthrough((s) => s.start);
|
||||||
|
|
||||||
// Back-compat: if old props are passed without breadcrumbs, synthesise them
|
// Back-compat: if old props are passed without breadcrumbs, synthesise them
|
||||||
const crumbs: Crumb[] = breadcrumbs.length > 0
|
const crumbs: Crumb[] = breadcrumbs.length > 0
|
||||||
@@ -73,6 +75,34 @@ export function AppHeader({ breadcrumbs = [], workspaceName, workspaceId }: AppH
|
|||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
<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 */}
|
{/* Theme toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function ProjectCard({ workspaceId, projectId, name, framework, appUrl, d
|
|||||||
return (
|
return (
|
||||||
<Link href={`/workspace/${workspaceId}/project/${projectId}`} style={{ textDecoration: 'none' }}>
|
<Link href={`/workspace/${workspaceId}/project/${projectId}`} style={{ textDecoration: 'none' }}>
|
||||||
<div
|
<div
|
||||||
|
data-tour="project-card"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--card-bg)',
|
background: 'var(--card-bg)',
|
||||||
border: '1px solid var(--card-border)',
|
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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
+231
-62
@@ -1,28 +1,37 @@
|
|||||||
// ── Originmain Fiber Hook ────────────────────────────────────────────────────
|
// ── Originmain Fiber Hook (Live SDK) ─────────────────────────────────────────
|
||||||
// This module installs a React DevTools–compatible global hook BEFORE React
|
// Installs a React DevTools–compatible global hook BEFORE React evaluates its
|
||||||
// evaluates its module body. React checks for __REACT_DEVTOOLS_GLOBAL_HOOK__
|
// module body. React checks for __REACT_DEVTOOLS_GLOBAL_HOOK__ exactly once at
|
||||||
// exactly once at import time; any later installation is too late.
|
// import time; any later installation is too late.
|
||||||
//
|
//
|
||||||
// The hook only activates when the app is iframed by Originmain (detected by
|
// Full bidirectional protocol:
|
||||||
// the `om:` prefix in `window.name`, which LiveArtboard.tsx sets on the
|
// Renderer → Host : READY, FIBER_TREE_UPDATE, COMPONENT_SELECTED, ERROR
|
||||||
// <iframe> element). Outside an Originmain iframe the module is a no-op.
|
// Host → Renderer : SET_DESIGN_TOKENS, NAVIGATE, SELECT_COMPONENT, DESELECT
|
||||||
//
|
//
|
||||||
// This file is intentionally self-contained — no imports from other
|
// Node IDs are stable path strings: "Component:idx/Child:idx/…"
|
||||||
// @originmain/* packages — because it ships as a public npm package.
|
// 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 RENDERER_SOURCE = 'originmain-renderer';
|
||||||
|
const HOST_SOURCE = 'originmain-host';
|
||||||
const NAME_PREFIX = 'om:';
|
const NAME_PREFIX = 'om:';
|
||||||
|
|
||||||
// ── Guard: only run inside an Originmain iframe ──────────────────────────────
|
// ── Guard ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function isOriginmainIframe(): boolean {
|
function isOriginmainIframe(): boolean {
|
||||||
try {
|
try {
|
||||||
return window.parent !== window
|
return (
|
||||||
&& typeof window.name === 'string'
|
window.parent !== window &&
|
||||||
&& window.name.startsWith(NAME_PREFIX);
|
typeof window.name === 'string' &&
|
||||||
|
window.name.startsWith(NAME_PREFIX)
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Accessing window.parent can throw in certain sandboxed contexts.
|
return false; // Accessing window.parent can throw in certain sandboxed contexts.
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,11 +39,12 @@ if (isOriginmainIframe()) {
|
|||||||
installFiberHook();
|
installFiberHook();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Core ─────────────────────────────────────────────────────────────────────
|
// ── Core ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function installFiberHook(): void {
|
function installFiberHook(): void {
|
||||||
const artboardId = window.name.slice(NAME_PREFIX.length);
|
const artboardId = window.name.slice(NAME_PREFIX.length);
|
||||||
|
|
||||||
|
// ── postMessage helper ────────────────────────────────────────────────────
|
||||||
function post(msg: Record<string, unknown>): void {
|
function post(msg: Record<string, unknown>): void {
|
||||||
try {
|
try {
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
@@ -46,8 +56,13 @@ function installFiberHook(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install or wrap the global hook. If React DevTools is already present,
|
// ── Runtime state ─────────────────────────────────────────────────────────
|
||||||
// we wrap its onCommitFiberRoot so both receive commits.
|
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 = {
|
type Hook = {
|
||||||
renderers: Map<unknown, unknown>;
|
renderers: Map<unknown, unknown>;
|
||||||
supportsFiber: boolean;
|
supportsFiber: boolean;
|
||||||
@@ -64,76 +79,71 @@ function installFiberHook(): void {
|
|||||||
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
|
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalOnCommit = hook.onCommitFiberRoot;
|
const _prevCommit = hook.onCommitFiberRoot;
|
||||||
|
|
||||||
hook.onCommitFiberRoot = function onCommitFiberRoot(...args: unknown[]) {
|
hook.onCommitFiberRoot = function onCommitFiberRoot(...args: unknown[]) {
|
||||||
// Delegate to the previous handler (React DevTools) first.
|
// Delegate to any pre-existing handler (React DevTools extension) first.
|
||||||
if (typeof originalOnCommit === 'function') {
|
if (typeof _prevCommit === 'function') {
|
||||||
try { originalOnCommit.apply(this, args); }
|
try { _prevCommit.apply(this, args); }
|
||||||
catch { /* don't break DevTools */ }
|
catch { /* don't break existing DevTools */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// args[1] is the FiberRoot — { current: Fiber }
|
// args[1] is the FiberRoot object — { current: Fiber }
|
||||||
const root = args[1] as { current: FiberLike } | undefined;
|
const root = args[1] as { current: FiberLike } | undefined;
|
||||||
if (!root?.current) return;
|
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 });
|
post({ type: 'FIBER_TREE_UPDATE', root: tree });
|
||||||
|
// Re-sync the highlight ring after each React commit (component may move).
|
||||||
|
if (selectedNodeId) updateHighlight();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
post({ type: 'ERROR', message: String(err) });
|
post({ type: 'ERROR', message: String(err) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Signal readiness.
|
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
|
||||||
post({ type: 'READY' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fiber Serialization ──────────────────────────────────────────────────────
|
function serializeFiber(
|
||||||
|
fiber: FiberLike | null,
|
||||||
interface FiberLike {
|
parentId: string,
|
||||||
type: unknown;
|
): SerializedNode | null {
|
||||||
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 {
|
|
||||||
if (!fiber) return null;
|
if (!fiber) return null;
|
||||||
|
|
||||||
const name = getDisplayName(fiber);
|
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 rect = getDomRect(fiber);
|
||||||
const node: SerializedNode = {
|
const node: SerializedNode = {
|
||||||
id: String(fiber.index || Math.random()),
|
id: nodeId,
|
||||||
name,
|
name,
|
||||||
props: serializeProps(fiber.memoizedProps),
|
props: serializeProps(fiber.memoizedProps),
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
if (rect) {
|
if (rect) node.domRect = rect;
|
||||||
node.domRect = rect;
|
|
||||||
}
|
|
||||||
|
|
||||||
let child = fiber.child;
|
let child = fiber.child;
|
||||||
while (child) {
|
while (child) {
|
||||||
const serialized = serializeFiber(child);
|
const serialized = serializeFiber(child, nodeId);
|
||||||
if (serialized) node.children.push(serialized);
|
if (serialized) node.children.push(serialized);
|
||||||
child = child.sibling;
|
child = child.sibling;
|
||||||
}
|
}
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDisplayName(fiber: FiberLike): string | null {
|
function getDisplayName(fiber: FiberLike): string | null {
|
||||||
const type = fiber.type;
|
const type = fiber.type;
|
||||||
if (!type) return null;
|
if (!type) return null;
|
||||||
if (typeof type === 'string') return type;
|
if (typeof type === 'string') return type;
|
||||||
@@ -148,22 +158,22 @@ function getDisplayName(fiber: FiberLike): string | null {
|
|||||||
?? null;
|
?? null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDomRect(fiber: FiberLike): { x: number; y: number; width: number; height: number } | null {
|
function getDomRect(fiber: FiberLike): DomRect | null {
|
||||||
try {
|
try {
|
||||||
const dom = fiber.stateNode;
|
const dom = fiber.stateNode;
|
||||||
if (dom && typeof dom === 'object' && 'getBoundingClientRect' in dom) {
|
if (dom && typeof dom === 'object' && 'getBoundingClientRect' in dom) {
|
||||||
const r = (dom as Element).getBoundingClientRect();
|
const r = (dom as Element).getBoundingClientRect();
|
||||||
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||||
}
|
}
|
||||||
} catch { /* no DOM node */ }
|
} catch { /* stateNode has no layout */ }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeProps(
|
function serializeProps(
|
||||||
props: Record<string, unknown> | null,
|
props: Record<string, unknown> | null,
|
||||||
): Record<string, string | number | boolean | null> {
|
): Record<string, string | number | boolean | null> {
|
||||||
if (!props || typeof props !== 'object') return {};
|
if (!props || typeof props !== 'object') return {};
|
||||||
const out: Record<string, string | number | boolean | null> = {};
|
const out: Record<string, string | number | boolean | null> = {};
|
||||||
for (const key in props) {
|
for (const key in props) {
|
||||||
@@ -175,4 +185,163 @@ function serializeProps(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
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';
|
import type { FiberNode, DOMRectLike } from './protocol.js';
|
||||||
|
|
||||||
// ── Fiber hook script ─────────────────────────────────────────────────────────
|
// ── Legacy fiber hook script ──────────────────────────────────────────────────
|
||||||
// This script is injected into the sandboxed iframe before the remote app
|
// @deprecated Use buildProxyFiberHookScript() instead. This version bakes
|
||||||
// initialises. It installs a React DevTools global hook so React reports every
|
// the artboard ID into the script at generation time; the proxy-compatible
|
||||||
// commit. On each commit, we walk the Fiber tree, serialize it to FiberNode[],
|
// version reads it from window.name at runtime.
|
||||||
// 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.
|
|
||||||
|
|
||||||
export function buildFiberHookScript(artboardId: string): string {
|
export function buildFiberHookScript(artboardId: string): string {
|
||||||
// Inline the constants and logic — the iframe has no access to this module.
|
|
||||||
return `(function(artboardId) {
|
return `(function(artboardId) {
|
||||||
var SOURCE = ${JSON.stringify(RENDERER_SOURCE)};
|
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__;
|
var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||||
if (!hook) {
|
if (!hook) {
|
||||||
hook = { renderers: new Map(), _isDisabled: false };
|
hook = { renderers: new Map(), _isDisabled: false };
|
||||||
@@ -102,7 +94,6 @@ export function buildFiberHookScript(artboardId: string): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signal that the renderer iframe is ready.
|
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
{ source: SOURCE, artboardId: artboardId, message: { type: 'READY' } },
|
{ source: SOURCE, artboardId: artboardId, message: { type: 'READY' } },
|
||||||
'*'
|
'*'
|
||||||
@@ -111,17 +102,22 @@ export function buildFiberHookScript(artboardId: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Proxy-compatible fiber hook script ────────────────────────────────────────
|
// ── Proxy-compatible fiber hook script ────────────────────────────────────────
|
||||||
// Unlike buildFiberHookScript (which bakes in an artboard ID), this version
|
// Reads the artboard ID from window.name at runtime (set by LiveArtboard:
|
||||||
// reads the artboard ID from `window.name` at runtime. The iframe element sets
|
// <iframe name="om:{id}">). Supports full bidirectional protocol:
|
||||||
// `name="om:<artboardId>"` and this script extracts the ID.
|
|
||||||
//
|
//
|
||||||
// This script is injected by the CLI proxy (`@originmain/cli`) into every HTML
|
// Renderer → Host : READY, FIBER_TREE_UPDATE, COMPONENT_SELECTED, ERROR
|
||||||
// response, and is also used by the `@originmain/live` SDK. It is fully
|
// Host → Renderer : SET_DESIGN_TOKENS, NAVIGATE, SELECT_COMPONENT, DESELECT
|
||||||
// self-contained — no imports, no dependencies.
|
//
|
||||||
|
// 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 {
|
export function buildProxyFiberHookScript(): string {
|
||||||
return `(function() {
|
return `(function() {
|
||||||
// Only activate inside an Originmain iframe
|
'use strict';
|
||||||
|
|
||||||
|
// ── Guard: only activate inside an Originmain iframe ─────────────────────
|
||||||
if (window.parent === window) return;
|
if (window.parent === window) return;
|
||||||
|
|
||||||
var NAME_PREFIX = 'om:';
|
var NAME_PREFIX = 'om:';
|
||||||
@@ -130,57 +126,84 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
if (typeof window.name === 'string' && window.name.indexOf(NAME_PREFIX) === 0) {
|
if (typeof window.name === 'string' && window.name.indexOf(NAME_PREFIX) === 0) {
|
||||||
artboardId = window.name.slice(NAME_PREFIX.length);
|
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;
|
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) {
|
function post(msg) {
|
||||||
try { window.parent.postMessage({ source: SOURCE, artboardId: artboardId, message: msg }, '*'); }
|
try {
|
||||||
catch (e) { /* parent unreachable */ }
|
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.
|
// ── React DevTools global hook ────────────────────────────────────────────
|
||||||
// If React DevTools extension is already present, wrap its handler.
|
// 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__;
|
var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||||
if (!hook) {
|
if (!hook) {
|
||||||
hook = { renderers: new Map(), supportsFiber: true, _isDisabled: false };
|
hook = { renderers: new Map(), supportsFiber: true, _isDisabled: false };
|
||||||
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
|
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
|
||||||
}
|
}
|
||||||
|
|
||||||
var originalOnCommit = hook.onCommitFiberRoot;
|
var _prevCommit = hook.onCommitFiberRoot;
|
||||||
|
|
||||||
hook.onCommitFiberRoot = function(rendererId, root, priorityLevel, didError) {
|
hook.onCommitFiberRoot = function(rendererId, root, priorityLevel, didError) {
|
||||||
if (typeof originalOnCommit === 'function') {
|
// Delegate to any pre-existing handler (e.g. React DevTools extension).
|
||||||
try { originalOnCommit.call(this, rendererId, root, priorityLevel, didError); }
|
if (typeof _prevCommit === 'function') {
|
||||||
catch (e) { /* don't break DevTools */ }
|
try { _prevCommit.call(this, rendererId, root, priorityLevel, didError); }
|
||||||
|
catch (e) { /* don't break existing DevTools */ }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
var fiberRoot = root.current;
|
var tree = serializeFiber(root.current, '');
|
||||||
var tree = serializeFiber(fiberRoot);
|
currentTree = tree;
|
||||||
|
rebuildNodeMap(tree);
|
||||||
post({ type: 'FIBER_TREE_UPDATE', root: 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) {
|
} catch (err) {
|
||||||
post({ type: 'ERROR', message: String(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;
|
if (!fiber) return null;
|
||||||
var name = getDisplayName(fiber);
|
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 rect = getDomRect(fiber);
|
||||||
var node = {
|
var node = { id: nodeId, name: name, props: serializeProps(fiber.memoizedProps), children: [] };
|
||||||
id: String(fiber.index || Math.random()),
|
|
||||||
name: name,
|
|
||||||
props: serializeProps(fiber.memoizedProps),
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
if (rect) node.domRect = rect;
|
if (rect) node.domRect = rect;
|
||||||
|
|
||||||
var child = fiber.child;
|
var child = fiber.child;
|
||||||
while (child) {
|
while (child) {
|
||||||
var serialized = serializeFiber(child);
|
var serialized = serializeFiber(child, nodeId);
|
||||||
if (serialized) node.children.push(serialized);
|
if (serialized) node.children.push(serialized);
|
||||||
child = child.sibling;
|
child = child.sibling;
|
||||||
}
|
}
|
||||||
@@ -192,7 +215,7 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
if (!type) return null;
|
if (!type) return null;
|
||||||
if (typeof type === 'string') return type;
|
if (typeof type === 'string') return type;
|
||||||
if (typeof type === 'function') return type.displayName || type.name || null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +226,7 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
var r = dom.getBoundingClientRect();
|
var r = dom.getBoundingClientRect();
|
||||||
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,14 +237,144 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
if (key === 'children') continue;
|
if (key === 'children') continue;
|
||||||
var val = props[key];
|
var val = props[key];
|
||||||
var t = typeof val;
|
var t = typeof val;
|
||||||
if (t === 'string' || t === 'number' || t === 'boolean' || val === null) {
|
if (t === 'string' || t === 'number' || t === 'boolean' || val === null) out[key] = val;
|
||||||
out[key] = val;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out;
|
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' });
|
post({ type: 'READY' });
|
||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user