made some updates

This commit is contained in:
SinachPat
2026-05-01 07:30:29 +01:00
parent b9bc234d1d
commit 84ddd2e248
7 changed files with 267 additions and 106 deletions
@@ -68,17 +68,24 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
setComponentStyles(null);
return;
}
if (!localFiberRoot) return;
// If the fiber tree hasn't arrived yet, still commit the selection so
// REQUEST_ELEMENT_STYLES fires — just pass null for the fiber data.
if (!localFiberRoot) {
selectComponent(nodeId, null);
return;
}
const node = findFiberNode(localFiberRoot, nodeId);
selectComponent(nodeId, node ?? null);
}, [localFiberRoot, selectComponent, setComponentStyles]);
const handleComponentStylesUpdate = useCallback((_nodeId: string, styles: Record<string, string>) => {
// Only update styles if this artboard is the one currently selected
if (selectedArtboardId === id) {
const handleComponentStylesUpdate = useCallback((nodeId: string, styles: Record<string, string>) => {
// Guard against stale responses arriving after the user has already clicked
// a different component — only apply if artboard and node both still match.
const { selectedComponentId: currentId, selectedArtboardId: currentArtboard } = useCanvas.getState();
if (currentArtboard === id && currentId === nodeId) {
setComponentStyles(styles);
}
}, [id, selectedArtboardId, setComponentStyles]);
}, [id, setComponentStyles]);
// ── Drag to reposition ─────────────────────────────────────────────────────
const isDragging = useRef(false);
@@ -359,6 +366,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
width={width}
height={height}
onSelectionChange={(sel) => {
if (sel) selectArtboard(id);
handleComponentSelected(sel?.nodeId ?? '');
}}
/>
+147 -71
View File
@@ -288,81 +288,157 @@ export function Canvas() {
{/* Empty canvas onboarding — shown only when the project has no artboards yet */}
{artboards.length === 0 && (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
pointerEvents: 'none', zIndex: 3,
}}>
<UrlOnboardingOverlay
workspaceId={workspaceId}
projectId={projectId}
queryClient={queryClient}
/>
)}
</div>
);
}
/* ── URL onboarding overlay ───────────────────────────────── */
// Shown when the canvas has no artboards. Lets the user paste their CLI proxy
// URL to auto-create the first artboard; route discovery will then fire and
// populate the remaining pages automatically.
function UrlOnboardingOverlay({
workspaceId,
projectId,
queryClient,
}: {
workspaceId: string | null;
projectId: string | null;
queryClient: ReturnType<typeof useQueryClient>;
}) {
const [url, setUrl] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
const handleConnect = useCallback(async () => {
if (!workspaceId) return;
const trimmed = url.trim();
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('bad protocol');
} catch {
setErrorMsg('Enter a valid http:// or https:// URL');
return;
}
setStatus('loading');
setErrorMsg('');
try {
await createArtboardMutation({
workspace_id: workspaceId,
project_id: projectId ?? null,
name: 'Home',
origin_id: null,
parent_artboard_id: null,
metadata_jsonb: { x: 100, y: 100, width: 1280, height: 800, renderUrl: trimmed, route: '/' },
});
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
} catch (e) {
console.error('[Canvas] Failed to create artboard', e);
setStatus('error');
setErrorMsg('Failed to create artboard — try again');
}
}, [url, workspaceId, projectId, queryClient]);
return (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
pointerEvents: 'none', zIndex: 3,
}}>
<div
style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20,
maxWidth: 360, pointerEvents: 'auto',
}}
onMouseDown={e => e.stopPropagation()}
>
{/* Icon */}
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<rect x="3" y="3" width="15" height="15" rx="3" stroke="rgba(255,255,255,0.5)" strokeWidth="1.3" strokeDasharray="3.5 2"/>
<rect x="22" y="3" width="15" height="15" rx="3" stroke="rgba(255,255,255,0.5)" strokeWidth="1.3" strokeDasharray="3.5 2"/>
<rect x="3" y="22" width="15" height="15" rx="3" stroke="rgba(255,255,255,0.5)" strokeWidth="1.3" strokeDasharray="3.5 2"/>
<rect x="22" y="22" width="15" height="15" rx="3" stroke="rgba(255,255,255,0.5)" strokeWidth="1.3" strokeDasharray="3.5 2"/>
</svg>
<div style={{ textAlign: 'center' }}>
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20,
opacity: 0.55, maxWidth: 320,
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.65rem', color: 'rgba(255,255,255,0.7)',
letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 4,
}}>
{/* Icon */}
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<rect x="3" y="3" width="15" height="15" rx="3" stroke="rgba(255,255,255,0.5)" strokeWidth="1.3" strokeDasharray="3.5 2"/>
<rect x="22" y="3" width="15" height="15" rx="3" stroke="rgba(255,255,255,0.5)" strokeWidth="1.3" strokeDasharray="3.5 2"/>
<rect x="3" y="22" width="15" height="15" rx="3" stroke="rgba(255,255,255,0.5)" strokeWidth="1.3" strokeDasharray="3.5 2"/>
<rect x="22" y="22" width="15" height="15" rx="3" stroke="rgba(255,255,255,0.5)" strokeWidth="1.3" strokeDasharray="3.5 2"/>
</svg>
<div style={{ textAlign: 'center' }}>
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.65rem', color: 'rgba(255,255,255,0.7)',
letterSpacing: '0.1em', textTransform: 'uppercase',
marginBottom: 4,
}}>
Get started
</div>
<div style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.7rem', color: 'rgba(255,255,255,0.35)', lineHeight: 1.5 }}>
Live render your running app into design frames
</div>
</div>
{/* Steps */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, width: '100%' }}>
{[
{ n: '1', text: 'Press A and click the canvas to place a screen' },
{ n: '2', text: 'Start the CLI proxy pointing at your dev server', code: 'npx @originmain/cli dev --target http://localhost:3000' },
{ n: '3', text: "Paste the proxy URL into the screen's Connect field" },
].map(({ n, text, code }) => (
<div key={n} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<div style={{
width: 18, height: 18, borderRadius: '50%', flexShrink: 0,
border: '1px solid rgba(51,133,255,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem', color: 'rgba(51,133,255,0.9)',
fontWeight: 600,
}}>
{n}
</div>
<div>
<div style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.55)', lineHeight: 1.45 }}>
{text}
</div>
{code && (
<div style={{
marginTop: 5,
padding: '4px 8px',
background: 'rgba(51,133,255,0.1)',
border: '1px solid rgba(51,133,255,0.2)',
borderRadius: 5,
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: 'rgba(51,133,255,0.85)',
letterSpacing: '-0.01em',
}}>
{code}
</div>
)}
</div>
</div>
))}
</div>
Connect your app
</div>
<div style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.7rem', color: 'rgba(255,255,255,0.35)', lineHeight: 1.5 }}>
Paste the CLI proxy URL all your app&apos;s pages will be auto-rendered as artboards
</div>
</div>
)}
{/* CLI hint */}
<div style={{
width: '100%', padding: '6px 10px',
background: 'rgba(51,133,255,0.08)', border: '1px solid rgba(51,133,255,0.18)',
borderRadius: 6, fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem', color: 'rgba(51,133,255,0.75)', letterSpacing: '-0.01em',
}}>
npx @originmain/cli dev --target http://localhost:3000
</div>
{/* URL input */}
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 6 }}>
<input
type="url"
value={url}
onChange={e => { setUrl(e.target.value); setErrorMsg(''); }}
onKeyDown={e => { if (e.key === 'Enter') void handleConnect(); e.stopPropagation(); }}
placeholder="http://localhost:4170"
style={{
flex: 1, background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.12)', borderRadius: 6,
padding: '8px 10px', fontSize: '0.75rem',
color: 'rgba(255,255,255,0.85)',
fontFamily: "'JetBrains Mono', monospace",
outline: 'none', letterSpacing: '-0.01em',
}}
onFocus={e => (e.currentTarget.style.borderColor = '#3385FF')}
onBlur={e => (e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)')}
/>
<button
onClick={() => void handleConnect()}
disabled={status === 'loading' || !url.trim()}
style={{
padding: '8px 14px', borderRadius: 6,
background: !url.trim() ? 'rgba(51,133,255,0.3)' : '#3385FF',
border: 'none', color: '#fff', fontSize: '0.75rem', fontWeight: 600,
cursor: status === 'loading' || !url.trim() ? 'not-allowed' : 'pointer',
fontFamily: "'Inter', sans-serif",
opacity: status === 'loading' ? 0.7 : 1, whiteSpace: 'nowrap',
}}
>
{status === 'loading' ? 'Connecting…' : 'Connect →'}
</button>
</div>
{errorMsg && (
<p style={{ margin: 0, fontSize: '0.625rem', color: '#FF8080', fontFamily: "'Inter', sans-serif" }}>
{errorMsg}
</p>
)}
</div>
<div style={{
fontFamily: "'Inter', sans-serif", fontSize: '0.625rem',
color: 'rgba(255,255,255,0.2)', lineHeight: 1.5, textAlign: 'center',
}}>
Or press{' '}
<kbd style={{ fontFamily: "'JetBrains Mono', monospace", padding: '1px 4px', background: 'rgba(255,255,255,0.08)', borderRadius: 3 }}>A</kbd>
{' '}and click the canvas to place a screen manually
</div>
</div>
</div>
);
}
@@ -53,12 +53,17 @@ export function LiveArtboard({
const iframeRef = useRef<HTMLIFrameElement>(null);
// Track whether the iframe has sent READY so we don't send messages too early.
const isReadyRef = useRef(false);
// After READY fires the nodeMap inside the iframe is still empty — we can't
// request styles until after the first FIBER_TREE_UPDATE (which populates it).
// This ref holds the nodeId to re-request styles for after that first commit.
const pendingStylesFetchRef = useRef<string | null>(null);
// Reset ready state whenever src changes. Without this, isReadyRef stays
// true from the previous page, causing design-token / selection effects to
// fire against a half-loaded iframe between navigation and the new READY.
useEffect(() => {
isReadyRef.current = false;
pendingStylesFetchRef.current = null;
}, [src]);
// ── Send a typed message to the iframe ───────────────────────────────────
@@ -87,19 +92,26 @@ export function LiveArtboard({
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.
// Restore the highlight ring for any active selection.
// We cannot send REQUEST_ELEMENT_STYLES here — nodeMap is empty until
// the first React commit fires FIBER_TREE_UPDATE. Defer it via ref.
if (selectedComponentId) {
sendMessage('SELECT_COMPONENT', { nodeId: selectedComponentId });
pendingStylesFetchRef.current = selectedComponentId;
}
onReady?.();
break;
case 'FIBER_TREE_UPDATE':
onFiberTreeUpdate?.(msg.root);
// If READY deferred a style fetch (nodeMap was empty at that point),
// the first commit has now populated nodeMap — request styles now.
if (pendingStylesFetchRef.current) {
sendMessage('REQUEST_ELEMENT_STYLES', { nodeId: pendingStylesFetchRef.current });
pendingStylesFetchRef.current = null;
}
break;
case 'COMPONENT_SELECTED':
onComponentSelected?.(msg.nodeId);
// Request computed styles so the Design tab can populate immediately.
sendMessage('REQUEST_ELEMENT_STYLES', { nodeId: msg.nodeId });
break;
case 'COMPONENT_DESELECTED':
// Renderer clicked empty space — clear the host-side selection.
@@ -124,24 +136,23 @@ export function LiveArtboard({
sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
}, [designTokens, sendMessage]);
// ── Style edit mailbox ─────────────────────────────────────────────────────
// Watches the Zustand mailbox for PATCH_ELEMENT_STYLE events addressed to
// this artboard and forwards them to the iframe immediately.
const styleEditEvent = useCanvas((s) => s.styleEditEvent);
const clearStyleEdit = useCanvas((s) => s.clearStyleEdit);
// ── Style edit queue ───────────────────────────────────────────────────────
// Drains all PATCH_ELEMENT_STYLE events addressed to this artboard and
// forwards them to the iframe. Uses a queue (not a single slot) so that
// simultaneous width + height patches from resize are both delivered.
const styleEditQueue = useCanvas((s) => s.styleEditQueue);
const clearStyleEdits = useCanvas((s) => s.clearStyleEdits);
const removeElementEvent = useCanvas((s) => s.removeElementEvent);
const clearRemoveElement = useCanvas((s) => s.clearRemoveElement);
useEffect(() => {
if (styleEditEvent?.artboardId === id && isReadyRef.current) {
sendMessage('PATCH_ELEMENT_STYLE', {
nodeId: styleEditEvent.nodeId,
property: styleEditEvent.property,
value: styleEditEvent.value,
});
clearStyleEdit();
const mine = styleEditQueue.filter((e) => e.artboardId === id);
if (mine.length === 0 || !isReadyRef.current) return;
for (const e of mine) {
sendMessage('PATCH_ELEMENT_STYLE', { nodeId: e.nodeId, property: e.property, value: e.value });
}
}, [id, styleEditEvent, sendMessage, clearStyleEdit]);
clearStyleEdits(id);
}, [id, styleEditQueue, sendMessage, clearStyleEdits]);
useEffect(() => {
if (removeElementEvent?.artboardId === id && isReadyRef.current) {
+11 -8
View File
@@ -36,12 +36,14 @@ interface CanvasStore {
selectedComponentStyles: Record<string, string> | null;
setComponentStyles: (styles: Record<string, string> | null) => void;
// ── Style edit mailbox ──────────────────────────────────────────────────────
// The Design tab drops a patch here; the owning LiveArtboard picks it up,
// forwards it to the iframe, then clears it.
styleEditEvent: { artboardId: string; nodeId: string; property: string; value: string } | null;
// ── Style edit queue ────────────────────────────────────────────────────────
// The Design tab and resize handles push patches here; each owning
// LiveArtboard drains entries addressed to it, then removes them.
// A queue (not a single slot) is required because resize sends width + height
// in the same synchronous event — a single slot would silently drop the first.
styleEditQueue: Array<{ artboardId: string; nodeId: string; property: string; value: string }>;
patchStyleEdit: (artboardId: string, nodeId: string, property: string, value: string) => void;
clearStyleEdit: () => void;
clearStyleEdits: (artboardId: string) => void;
// ── Element removal mailbox ─────────────────────────────────────────────────
removeElementEvent: { artboardId: string; nodeId: string } | null;
@@ -81,10 +83,11 @@ export const useCanvas = create<CanvasStore>((set) => ({
selectedComponentStyles: null,
setComponentStyles: (styles) => set({ selectedComponentStyles: styles }),
styleEditEvent: null,
styleEditQueue: [],
patchStyleEdit: (artboardId, nodeId, property, value) =>
set({ styleEditEvent: { artboardId, nodeId, property, value } }),
clearStyleEdit: () => set({ styleEditEvent: null }),
set((s) => ({ styleEditQueue: [...s.styleEditQueue, { artboardId, nodeId, property, value }] })),
clearStyleEdits: (artboardId) =>
set((s) => ({ styleEditQueue: s.styleEditQueue.filter((e) => e.artboardId !== artboardId) })),
removeElementEvent: null,
dispatchRemoveElement: (artboardId, nodeId) =>
File diff suppressed because one or more lines are too long