'use client'; import { useState, useCallback, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useCanvas } from '@/store/canvas'; import { useViewport } from '@/store/viewport'; import { LiveArtboard } from './LiveArtboard'; import { SelectionOverlay } from './SelectionOverlay'; import type { FiberNode } from '@originmain/renderer'; interface ArtboardProps { id: string; label: string; x: number; y: number; width: number; height: number; renderUrl?: string; } export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) { const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, setFiberRoot, selectComponent } = useCanvas(); const selected = selectedArtboardId === id; const queryClient = useQueryClient(); // ── Fiber tree (from LiveArtboard) ───────────────────────────────────────── const [localFiberRoot, setLocalFiberRoot] = useState(undefined); const handleFiberUpdate = useCallback((root: FiberNode) => { setFiberRoot(id, root); setArtboardLive(id, true); setLocalFiberRoot(root); }, [id, setFiberRoot, setArtboardLive]); const handleComponentSelected = useCallback((nodeId: string) => { if (!localFiberRoot) return; const node = findFiberNode(localFiberRoot, nodeId); selectComponent(nodeId, node ?? null); }, [localFiberRoot, selectComponent]); // ── Drag to reposition ───────────────────────────────────────────────────── const isDragging = useRef(false); const dragStart = useRef({ mouseX: 0, mouseY: 0, artX: 0, artY: 0 }); const [dragOffset, setDragOffset] = useState({ dx: 0, dy: 0 }); const onLabelMouseDown = useCallback((e: React.MouseEvent) => { // Only drag on left button; don't interfere with rename if (e.button !== 0) return; e.stopPropagation(); e.preventDefault(); isDragging.current = true; const { zoom, panX, panY } = useViewport.getState(); dragStart.current = { mouseX: (e.clientX - panX) / zoom, mouseY: (e.clientY - panY) / zoom, artX: x, artY: y, }; setDragOffset({ dx: 0, dy: 0 }); const onMove = (mv: MouseEvent) => { if (!isDragging.current) return; const { zoom: z, panX: px, panY: py } = useViewport.getState(); const curX = (mv.clientX - px) / z; const curY = (mv.clientY - py) / z; setDragOffset({ dx: curX - dragStart.current.mouseX, dy: curY - dragStart.current.mouseY, }); }; const onUp = () => { if (!isDragging.current) return; isDragging.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); const newX = Math.round(dragStart.current.artX + dragOffset.dx); const newY = Math.round(dragStart.current.artY + dragOffset.dy); // Persist position fetch(`/api/artboards/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ metadata_jsonb: { x: newX, y: newY, width, height, ...(renderUrl ? { renderUrl } : {}) }, }), }).then(() => { queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); setDragOffset({ dx: 0, dy: 0 }); }).catch(console.error); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }, [id, x, y, width, height, renderUrl, workspaceId, projectId, queryClient, dragOffset.dx, dragOffset.dy]); // ── Inline rename ────────────────────────────────────────────────────────── const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(label); const renameRef = useRef(null); const startRename = useCallback((e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); setRenameValue(label); setRenaming(true); setTimeout(() => renameRef.current?.select(), 0); }, [label]); const commitRename = useCallback(() => { setRenaming(false); const trimmed = renameValue.trim(); if (!trimmed || trimmed === label) return; fetch(`/api/artboards/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: trimmed }), }).then(() => { queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); }).catch(console.error); }, [id, renameValue, label, workspaceId, projectId, queryClient]); // ── Delete artboard ──────────────────────────────────────────────────────── const deleteArtboard = useCallback(() => { if (!window.confirm(`Delete "${label}"? This cannot be undone.`)) return; fetch(`/api/artboards/${id}`, { method: 'DELETE' }) .then(() => { selectArtboard(null); queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); }) .catch(console.error); }, [id, label, selectArtboard, workspaceId, projectId, queryClient]); const effectiveX = x + (isDragging.current ? dragOffset.dx : 0); const effectiveY = y + (isDragging.current ? dragOffset.dy : 0); return (
{ e.stopPropagation(); selectArtboard(id); }} > {/* Label / drag handle */}
{renaming ? ( setRenameValue(e.target.value)} onBlur={commitRename} onKeyDown={(e) => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setRenaming(false); e.stopPropagation(); }} onClick={(e) => e.stopPropagation()} style={{ fontSize: 11, fontFamily: "'JetBrains Mono', 'SF Mono', ui-monospace, monospace", fontWeight: 500, color: '#3385FF', background: 'rgba(51,133,255,0.12)', border: '1px solid rgba(51,133,255,0.4)', borderRadius: 3, padding: '1px 6px', outline: 'none', width: Math.max(80, label.length * 7), }} /> ) : ( <> {label} {/* Delete button — only visible when selected */} {selected && ( )} )}
{/* Frame */}
{/* Selection corner handles */} {selected && ( <> )} {/* Content */} {renderUrl ? ( <> ) : ( )}
); } // ── Empty artboard (no renderUrl) ───────────────────────────────────────────── function EmptyArtboardContent({ id, label, width, height, workspaceId, projectId, queryClient, }: { id: string; label: string; width: number; height: number; workspaceId: string | null; projectId: string | null; queryClient: ReturnType; }) { const [editing, setEditing] = useState(false); const [urlValue, setUrlValue] = useState(''); const [saving, setSaving] = useState(false); const save = async () => { const url = urlValue.trim(); if (!url) return; setSaving(true); await fetch(`/api/artboards/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ metadata_jsonb: { renderUrl: url, width, height, x: 0, y: 0 }, }), }).catch(console.error); queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); setSaving(false); setEditing(false); }; return (
{/* Artboard name */}
{label}
{editing ? (
setUrlValue(e.target.value)} placeholder="http://localhost:3000" onKeyDown={(e) => { if (e.key === 'Enter') void save(); if (e.key === 'Escape') setEditing(false); e.stopPropagation(); }} style={{ padding: '7px 10px', borderRadius: 6, border: '1.5px solid #0066FF', fontSize: 11, fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box' as const, }} />
) : ( <> {/* Icon */}

Connect a running app URL to enable live rendering

)}
); } // ── Helpers ─────────────────────────────────────────────────────────────────── function findFiberNode(root: FiberNode, nodeId: string): FiberNode | null { if (root.id === nodeId) return root; if (!root.children) return null; for (const child of root.children) { const found = findFiberNode(child, nodeId); if (found) return found; } return null; } function Handle({ pos }: { pos: React.CSSProperties }) { return (
); }