Files
wursor/packages/app/src/components/canvas/IsolationFrame.tsx
T
2026-05-05 22:06:31 +01:00

230 lines
7.3 KiB
TypeScript

'use client';
/**
* IsolationFrame — Phase 3 full implementation
*
* Renders an isolated view of a single React component inside an iframe.
* The CLI proxy serves the isolation page at:
* `/__om_isolation__?component=<name>&file=<path>`
*
* When the indexer is not ready, shows an informative placeholder. When it is
* ready, renders the isolation iframe. The host sends UPDATE_ISOLATION_PROPS
* messages so the designer can tweak props live from the Inspector.
*
* spec: SOURCE-AWARE-CANVAS.md Phase 3 §3.5 "Component Isolation"
*/
import { useRef, useEffect, useCallback } from 'react';
import { useCanvas } from '@/store/canvas';
import { useCanvasTheme } from '@/store/canvasTheme';
import { createHostEnvelope } from '@originmain/renderer';
interface IsolationFrameProps {
/** Artboard ID (used for message routing). */
artboardId: string;
/** Display name of the component to isolate — passed as ?component= param. */
componentName: string;
/** Workspace-relative source file path — passed as ?file= param. */
componentFile: string;
/** Base URL of the CLI proxy (e.g. "http://localhost:4170"). */
proxyUrl: string;
/** Current prop overrides to forward into the isolation page. */
isolationProps?: Record<string, unknown>;
width: number;
height: number;
}
/** Builds the isolation page URL from the proxy base and component params. */
function buildIsolationUrl(
proxyUrl: string,
componentName: string,
componentFile: string,
): string {
const base = proxyUrl.replace(/\/$/, '');
const params = new URLSearchParams({
component: componentName,
file: componentFile,
});
return `${base}/__om_isolation__?${params.toString()}`;
}
export function IsolationFrame({
artboardId,
componentName,
componentFile,
proxyUrl,
isolationProps,
width,
height,
}: IsolationFrameProps) {
const T = useCanvasTheme();
const { indexerStatus } = useCanvas();
const iframeRef = useRef<HTMLIFrameElement>(null);
// ── Forward prop overrides to the isolation iframe ─────────────────────────
// Sends UPDATE_ISOLATION_PROPS whenever isolationProps changes so the
// component re-renders with the new values without a full page reload.
const sendIsolationProps = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe?.contentWindow) return;
try {
const msg = createHostEnvelope(artboardId, {
type: 'UPDATE_ISOLATION_PROPS',
props: isolationProps ?? {},
});
iframe.contentWindow.postMessage(msg, '*');
} catch { /* iframe may not be ready yet — will retry on next onLoad */ }
}, [artboardId, isolationProps]);
useEffect(() => {
sendIsolationProps();
}, [sendIsolationProps]);
// ── Indexer not running — show placeholder ─────────────────────────────────
if (indexerStatus !== 'ready') {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
padding: '32px 20px',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 8,
textAlign: 'center',
width, height,
boxSizing: 'border-box',
}}
>
{/* Isolation icon */}
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" style={{ opacity: 0.3 }}>
<rect x="3" y="3" width="22" height="22" rx="4" stroke="white" strokeWidth="1.3" strokeDasharray="4 2.5"/>
<rect x="9" y="9" width="10" height="10" rx="2" stroke="white" strokeWidth="1.3"/>
</svg>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
<span style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5875rem',
color: T.fgMuted,
letterSpacing: '0.02em',
}}>
Isolation mode requires CLI indexer
</span>
<span style={{
fontFamily: "'Inter', sans-serif",
fontSize: '0.5625rem',
color: T.dim,
lineHeight: 1.55,
}}>
Run{' '}
<code style={{
fontFamily: "'JetBrains Mono', monospace",
color: '#FFBA7B',
fontSize: '0.5rem',
background: 'rgba(255,186,123,0.08)',
borderRadius: 3,
padding: '1px 4px',
}}>
npx @originmain/cli dev
</code>
{' '}to enable component isolation.
</span>
</div>
{/* Status badge */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 5,
padding: '4px 10px',
background: 'rgba(255,255,255,0.04)',
border: `1px solid ${T.sep}`,
borderRadius: 5,
}}>
<div style={{
width: 5, height: 5, borderRadius: '50%',
background: T.dim, flexShrink: 0,
}} />
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
color: T.dim,
letterSpacing: '0.05em',
textTransform: 'uppercase',
}}>
Indexer offline
</span>
</div>
</div>
);
}
// ── Indexer ready — render isolation iframe ────────────────────────────────
const src = buildIsolationUrl(proxyUrl, componentName, componentFile);
return (
<div style={{ position: 'relative', width, height, overflow: 'hidden' }}>
<iframe
ref={iframeRef}
src={src}
width={width}
height={height}
style={{
display: 'block',
border: 'none',
background: 'white',
}}
title={`${componentName} — isolation`}
// The isolation page is served by the CLI proxy (localhost).
// allow="*" is safe here because it's a locally-served page.
allow="*"
onLoad={sendIsolationProps}
/>
{/* Isolation indicator badge */}
<div style={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '3px 8px',
background: 'rgba(51,133,255,0.12)',
border: '1px solid rgba(51,133,255,0.3)',
borderRadius: 4,
backdropFilter: 'blur(8px)',
pointerEvents: 'none',
}}>
<div style={{
width: 5, height: 5, borderRadius: '50%',
background: '#3385FF', flexShrink: 0,
animation: 'om-pulse 2s infinite',
}} />
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
color: '#3385FF',
letterSpacing: '0.05em',
textTransform: 'uppercase',
}}>
isolation
</span>
</div>
{/* Subtle top border to distinguish from route artboards */}
<div style={{
position: 'absolute',
top: 0, left: 0, right: 0,
height: 2,
background: 'linear-gradient(90deg, rgba(51,133,255,0.6) 0%, rgba(51,133,255,0) 100%)',
pointerEvents: 'none',
}} />
</div>
);
}