import React, { useEffect, useRef, useState, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import PixiCanvas, { PixiCanvasHandle } from '../canvas/PixiCanvas'; import { SelectionManager } from '../canvas/SelectionManager'; import { type SceneItem } from '../canvas/SceneManager'; import { ToolType, activateTool } from '../canvas/tools'; import type { ToolContext } from '../canvas/tools'; import { SyncHandle } from '../canvas/sync'; import { UndoManager } from '../canvas/history'; import { shortcuts as shortcutDefs } from '../canvas/shortcut-definitions'; import { writeCanvasToClipboard as writeClipboard } from '../canvas/clipboard'; import { exportAsImage } from '../canvas/export'; import { buildContextMenuItems } from '../canvas/context-menu-items'; import { groupItems, ungroupItems } from '../canvas/grouping'; import { getSocket } from '../socket'; import { useAuth } from '../auth'; import Toolbar from '../components/Toolbar'; import StatusBar, { SaveStatus } from '../components/StatusBar'; import UserCursors from '../components/UserCursors'; import ContextMenu from '../components/ContextMenu'; import LayerPanel from '../components/LayerPanel'; import SelectionToolbar from '../components/SelectionToolbar'; import TextFormatToolbar from '../components/TextFormatToolbar'; import VideoControls from '../components/VideoControls'; import ShortcutsHelp from '../components/ShortcutsHelp'; import MattermostImport from '../components/MattermostImport'; import Minimap from '../components/Minimap'; import UploadPanel from '../components/UploadPanel'; import ExportDialog from '../components/ExportDialog'; import FeedbackPanel from '../components/feedback/FeedbackPanel'; import InlineCommentComposer from '../components/feedback/InlineCommentComposer'; import { useAnchoredOverlayPosition } from '../hooks/useAnchoredOverlayPosition'; import { UploadManager } from '../stores/uploadManager'; import { InboxZone } from '../canvas/InboxZone'; import { getItemWorldBounds } from '../canvas/SceneManager'; import type { TextObject } from '../canvas/scene-format'; import { VideoSprite } from '../canvas/sprites/VideoSprite'; import * as ops from '../canvas/operations'; // Hooks import { useBoardLoader } from '../hooks/useBoardLoader'; import { useSaveManager } from '../hooks/useSaveManager'; import { useCanvasSetup } from '../hooks/useCanvasSetup'; import { useShortcutHandler } from '../hooks/useShortcutHandler'; import { useLayerPanel } from '../hooks/useLayerPanel'; import { useFollowMode } from '../hooks/useFollowMode'; interface EditorProps { isPublicView?: boolean; } interface OnlineUser { userId: string; displayName: string; color: string; } export interface DraftPin { objectId: string; pinX: number; // 0-1 relative to object bounds pinY: number; // 0-1 relative to object bounds worldX: number; // world-space coords at placement time worldY: number; // world-space coords at placement time } export default function Editor({ isPublicView }: EditorProps) { const { boardId } = useParams<{ boardId?: string }>(); const navigate = useNavigate(); const { user } = useAuth(); // Refs const canvasRef = useRef(null); const selectionRef = useRef(null); const undoRef = useRef(null); const syncRef = useRef(null); const toolCleanupRef = useRef<(() => void) | null>(null); const inboxZoneRef = useRef(null); const clipboardRef = useRef([]); const canvasContainerRef = useRef(null); const [uploadManager] = useState(() => new UploadManager()); // Reset upload manager when switching boards useEffect(() => { uploadManager.clear(); }, [boardId, uploadManager]); // UI state const [activeTool, setActiveTool] = useState(ToolType.SELECT); const [color, setColor] = useState('#ffffff'); const [strokeWidth, setStrokeWidth] = useState(4); const [fontSize, setFontSize] = useState(24); const [zoom, setZoom] = useState(1); const [objectCount, setObjectCount] = useState(0); const [saveStatus, setSaveStatus] = useState('saved'); const [onlineUsers, setOnlineUsers] = useState([]); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); const [canvasTransform, setCanvasTransform] = useState([1, 0, 0, 1, 0, 0]); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [toasts, setToasts] = useState<{ id: string; text: string }[]>([]); const [showLayers, setShowLayers] = useState(false); const [showGrid, setShowGrid] = useState(true); const [showHelp, setShowHelp] = useState(false); const [showMmImport, setShowMmImport] = useState(false); const [showExport, setShowExport] = useState(false); const [reviewMode, setReviewMode] = useState(false); const reviewModeRef = useRef(false); const [focusedThreadId, setFocusedThreadId] = useState(null); // Pulse signal: threadId to open, or null to collapse detail view const [expandRequest, setExpandRequest] = useState<{ threadId: string | null; seq: number } | null>(null); const [draftPin, setDraftPin] = useState(null); const [openThreadDetailId, setOpenThreadDetailId] = useState(null); const expandSeqRef = useRef(0); const [draftCommentText, setDraftCommentText] = useState(''); const [creatingPointThread, setCreatingPointThread] = useState(false); const [sceneVersion, setSceneVersion] = useState(0); const [focusMode, setFocusMode] = useState(false); const [layerList, setLayerList] = useState([]); const [selectedLayerIds, setSelectedLayerIds] = useState([]); const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null); const [textToolbar, setTextToolbar] = useState<{ x: number; y: number; fontSize: number; fontFamily: string; fill: string; items: SceneItem[] } | null>(null); const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null); const [showMinimap, setShowMinimap] = useState(true); const [minimapData, setMinimapData] = useState<{ items: any[]; viewportBounds: any; contentBounds: any }>({ items: [], viewportBounds: { x: 0, y: 0, w: 1, h: 1 }, contentBounds: { x: 0, y: 0, w: 1, h: 1 }, }); // Derived const { boardData, loading, error } = useBoardLoader(boardId); const resolvedBoardId = boardId || boardData?.board?.id; const userRole = boardData?.role || 'viewer'; const readOnly = !isPublicView && userRole === 'viewer'; // Set read-only status when board data loads useEffect(() => { if (boardData && readOnly) { setSaveStatus('readonly'); } }, [boardData, readOnly]); // Toast helper const showToast = useCallback((text: string) => { const id = crypto.randomUUID(); setToasts((prev) => [...prev.slice(-4), { id, text }]); setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000); }, []); // Save manager const { scheduleSave } = useSaveManager({ resolvedBoardId, isPublicView, readOnly, canvasRef, setSaveStatus }); // Canvas change handler. // Pass changedIds for incremental sync (fast, lightweight). // Omit for structural changes — falls through to debounced full scene sync. const onCanvasChange = useCallback((changedIds?: string[]) => { scheduleSave(); setSceneVersion(v => v + 1); if (changedIds && changedIds.length > 0) { // Incremental: broadcast only changed elements syncRef.current?.broadcastElements(changedIds); // Keep spatial index in sync for all local mutations (align/flip/arrange/etc.) const scene = canvasRef.current?.getScene(); if (scene) { for (const id of changedIds) { const item = scene.items.get(id); if (item) scene.updateSpatialEntry(item); } } } // Full scene sync happens via debounced SceneManager.onChange chain in sync.ts if (undoRef.current && !undoRef.current.isLocked()) { undoRef.current.saveState(); setCanUndo(undoRef.current.canUndo()); setCanRedo(undoRef.current.canRedo()); } const z = canvasRef.current?.getZoom() ?? 1; setZoom(z); const scene = canvasRef.current?.getScene(); if (scene) setObjectCount(scene.getAllItems().length); const vp = canvasRef.current?.getViewport(); if (vp) setCanvasTransform([vp.scale.x, 0, 0, vp.scale.y, vp.x, vp.y]); }, [scheduleSave]); // Follow mode const getViewport = useCallback(() => canvasRef.current?.getViewport() ?? null, []); const { followingUserId, followingDisplayName, startFollowing, stopFollowing } = useFollowMode({ socket: getSocket(), boardId: resolvedBoardId, getViewport, }); // Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox, annotations) const { annotationStore, pinOverlay, textEditor, cropOverlayRef } = useCanvasSetup({ boardData, resolvedBoardId, user, isPublicView, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef, uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, }); // Build canvas objects map for FeedbackPanel (memoized on object count changes) const canvasObjectMap = React.useMemo(() => { const scene = canvasRef.current?.getScene(); const map = new Map(); if (scene) { for (const item of scene.items.values()) { map.set(item.id, { id: item.id, name: item.data.name, type: item.data.type }); } } return map; // eslint-disable-next-line react-hooks/exhaustive-deps }, [objectCount]); // Inline composer position — tracks viewport + scene changes for anchored placement const composerAnchor = draftPin ? { objectId: draftPin.objectId, pinX: draftPin.pinX, pinY: draftPin.pinY } : null; const composerPos = useAnchoredOverlayPosition( canvasRef.current?.getViewport() ?? null, canvasRef.current?.getScene() ?? null, composerAnchor, sceneVersion, ); // Keep reviewModeRef in sync for tool handlers to read useEffect(() => { reviewModeRef.current = reviewMode; }, [reviewMode]); // Pins visible only in review mode useEffect(() => { if (!pinOverlay) return; pinOverlay.visible = reviewMode; if (reviewMode) pinOverlay.refresh(); }, [pinOverlay, reviewMode]); // Sync draft pin to PinOverlay ghost pin useEffect(() => { if (!pinOverlay) return; pinOverlay.setGhostPin(draftPin ? { objectId: draftPin.objectId, pinX: draftPin.pinX, pinY: draftPin.pinY } : null); }, [draftPin, pinOverlay]); // Sync focused thread to PinOverlay focus visuals useEffect(() => { if (!pinOverlay) return; pinOverlay.setFocusedThread(focusedThreadId); }, [focusedThreadId, pinOverlay]); // Review-mode pointer handling: pin click + draft pin placement useEffect(() => { const vp = canvasRef.current?.getViewport(); if (!vp || !pinOverlay) return; let downX = 0; let downY = 0; let dragOccurred = false; const onPointerDown = (e: any) => { const screen = e.global || e.data?.global; if (!screen) return; downX = screen.x; downY = screen.y; dragOccurred = false; }; const onPointerMove = (e: any) => { if (dragOccurred) return; const screen = e.global || e.data?.global; if (!screen) return; const dx = screen.x - downX; const dy = screen.y - downY; if (dx * dx + dy * dy > 25) { // 5px threshold dragOccurred = true; } }; const onPointerUp = (e: any) => { if (dragOccurred) return; if (!reviewMode) return; const screen = e.global || e.data?.global; if (!screen) return; const worldPos = vp.toWorld(screen.x, screen.y); // Priority 1: existing pin const threadId = pinOverlay.getThreadIdAtPoint(worldPos.x, worldPos.y); if (threadId) { setFocusedThreadId(threadId); setDraftPin(null); expandSeqRef.current += 1; setExpandRequest({ threadId, seq: expandSeqRef.current }); return; } // Priority 2: scene object (place draft pin) — only for editor/owner const scene = canvasRef.current?.getScene(); if (scene && userRole !== 'viewer') { // Use 10px screen-space radius converted to world space for forgiving click detection const clickRadius = 10 / vp.scale.x; const hitItems = scene.queryRegion(worldPos.x - clickRadius, worldPos.y - clickRadius, clickRadius * 2, clickRadius * 2); // Take the topmost (highest z) item if (hitItems.length > 0) { const item = hitItems.sort((a: any, b: any) => (b.data.z || 0) - (a.data.z || 0))[0]; const bounds = getItemWorldBounds(item); const pinX = Math.max(0, Math.min(1, (worldPos.x - bounds.x) / bounds.w)); const pinY = Math.max(0, Math.min(1, (worldPos.y - bounds.y) / bounds.h)); setDraftPin({ objectId: item.id, pinX, pinY, worldX: worldPos.x, worldY: worldPos.y, }); setFocusedThreadId(null); return; } } // Priority 3: empty canvas — clear draft and focus setDraftPin(null); setFocusedThreadId(null); }; vp.on('pointerdown', onPointerDown); vp.on('pointermove', onPointerMove); vp.on('pointerup', onPointerUp); return () => { vp.off('pointerdown', onPointerDown); vp.off('pointermove', onPointerMove); vp.off('pointerup', onPointerUp); }; }, [reviewMode, pinOverlay, userRole]); // Review-mode Escape priority: draft → thread detail → review mode useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; if (draftPin) { e.preventDefault(); setDraftPin(null); setDraftCommentText(''); return; } if (openThreadDetailId) { e.preventDefault(); setFocusedThreadId(null); // Send collapse signal to FeedbackPanel expandSeqRef.current += 1; setExpandRequest({ threadId: null, seq: expandSeqRef.current }); return; } if (reviewMode) { // Don't exit review mode if user is typing in an unrelated input if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; e.preventDefault(); setReviewMode(false); return; } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [draftPin, openThreadDetailId, reviewMode]); // Create point-pinned thread via REST const handleCreatePointThread = useCallback(async (draft: DraftPin, content: string) => { if (!resolvedBoardId || creatingPointThread) return; const token = localStorage.getItem('refboard_token'); if (!token) return; setCreatingPointThread(true); try { const res = await fetch(`/api/boards/${resolvedBoardId}/threads`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ object_id: draft.objectId, anchor_type: 'point', pin_x: draft.pinX, pin_y: draft.pinY, content, }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); showToast(body.error || 'Failed to create comment'); return; } const data = await res.json(); // Clear draft, pre-focus the new thread setDraftPin(null); setDraftCommentText(''); setFocusedThreadId(data.thread.id); expandSeqRef.current += 1; setExpandRequest({ threadId: data.thread.id, seq: expandSeqRef.current }); } catch (err: any) { showToast('Failed to create comment: ' + (err.message || 'network error')); } finally { setCreatingPointThread(false); } }, [resolvedBoardId, showToast, creatingPointThread]); // Clear draft and focus when exiting review mode useEffect(() => { if (!reviewMode) { setDraftPin(null); setDraftCommentText(''); setFocusedThreadId(null); setExpandRequest(null); } }, [reviewMode]); // Tool activation useEffect(() => { const viewport = canvasRef.current?.getViewport(); const scene = canvasRef.current?.getScene(); const selection = selectionRef.current; if (!viewport || !scene || !selection) return; const canvasElements = document.querySelectorAll('canvas'); let domContainer: HTMLElement | null = null; for (const c of canvasElements) { if (c.parentElement && c.width > 100) { domContainer = c.parentElement; break; } } if (!domContainer) return; toolCleanupRef.current?.(); const ctx: ToolContext = { viewport, scene, selection, container: domContainer, onChange: onCanvasChange, broadcastElements: (ids) => syncRef.current?.broadcastElements(ids), textEditor: textEditor ?? undefined, switchToSelect: () => setActiveTool(ToolType.SELECT), }; // reviewMode is read at click time via getter so it stays current Object.defineProperty(ctx, 'reviewMode', { get: () => reviewModeRef.current }); toolCleanupRef.current = activateTool(ctx, activeTool, { color, strokeWidth, fontSize }); }, [activeTool, color, strokeWidth, fontSize, onCanvasChange, textEditor]); // Layer panel const { refreshLayers: refreshLayerData, layerHandlers } = useLayerPanel({ canvasRef, selectionRef, onCanvasChange }); const refreshLayers = useCallback(() => { const layers = refreshLayerData(); setLayerList(layers); const selection = selectionRef.current; if (selection) setSelectedLayerIds(Array.from(selection.selectedIds)); }, [refreshLayerData]); useEffect(() => { if (showLayers) refreshLayers(); }, [showLayers, refreshLayers, selectedLayerIds]); // Grouping const handleGroup = useCallback(() => { const scene = canvasRef.current?.getScene(); const selection = selectionRef.current; if (!scene || !selection) return; groupItems(scene, selection, () => { onCanvasChange(); refreshLayers(); syncRef.current?.broadcastSceneNow(); }); }, [onCanvasChange, refreshLayers]); const handleUngroup = useCallback(() => { const scene = canvasRef.current?.getScene(); const selection = selectionRef.current; const viewport = canvasRef.current?.getViewport(); if (!scene || !selection || !viewport) return; ungroupItems(scene, selection, viewport, () => { onCanvasChange(); refreshLayers(); syncRef.current?.broadcastSceneNow(); }); }, [onCanvasChange, refreshLayers]); // Clipboard write wrapper const writeCanvasToClipboard = useCallback(async (items?: SceneItem[]) => { try { const app = canvasRef.current?.getApp() ?? null; const viewport = canvasRef.current?.getViewport() ?? null; await writeClipboard(app, viewport, items); } catch (err: any) { showToast('Copy failed: ' + (err.message || 'clipboard not available')); } }, [showToast]); // Keyboard shortcuts useShortcutHandler({ canvasRef, selectionRef, undoRef, clipboardRef, resolvedBoardId, onCanvasChange, showToast, refreshLayers, handleGroup, handleUngroup, setActiveTool, setCanUndo, setCanRedo, setZoom, setShowGrid, setShowHelp, setFocusMode, setReviewMode, startCrop: () => { const selection = selectionRef.current; if (!selection || !cropOverlayRef.current) return; const items = selection.getSelectedItems(); if (items.length !== 1 || items[0].type !== 'image') { showToast('Select a single image to crop'); return; } selection.setEnabled(false); cropOverlayRef.current.start(items[0]); }, }); // Context menu const handleContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }, []); const contextMenuItems = useCallback(() => { return buildContextMenuItems({ scene: canvasRef.current?.getScene() ?? null, selection: selectionRef.current, viewport: canvasRef.current?.getViewport() ?? null, clipboardRef, writeCanvasToClipboard, onChange: () => { onCanvasChange(); refreshLayers(); }, refreshLayers, handleGroup, handleUngroup, fitAll: () => canvasRef.current?.fitAll(), startCrop: () => { const selection = selectionRef.current; if (!selection || !cropOverlayRef.current) return; const items = selection.getSelectedItems(); if (items.length !== 1 || items[0].type !== 'image') return; selection.setEnabled(false); cropOverlayRef.current.start(items[0]); }, }); }, [writeCanvasToClipboard, onCanvasChange, handleGroup, handleUngroup, refreshLayers]); // Save on page unload (only for users with edit access) useEffect(() => { if (readOnly || isPublicView) return; function onBeforeUnload() { const scene = canvasRef.current?.getScene(); if (!scene || !resolvedBoardId) return; const token = localStorage.getItem('refboard_token'); if (!token) return; const state = JSON.stringify(scene.serialize()); // Use fetch with keepalive to include auth header (sendBeacon can't set headers) fetch(`/api/boards/${resolvedBoardId}/save`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ canvas_state: state }), keepalive: true, }).catch(() => {}); } window.addEventListener('beforeunload', onBeforeUnload); return () => window.removeEventListener('beforeunload', onBeforeUnload); }, [resolvedBoardId, readOnly, isPublicView]); // Update UI overlays (selection toolbar, video controls, minimap) on demand const updateOverlays = useCallback(() => { const selection = selectionRef.current; const vp = canvasRef.current?.getViewport(); if (!selection || !vp) { setSelToolbar(null); setVideoCtrl(null); return; } const items = selection.getSelectedItems(); setZoom(canvasRef.current?.getZoom() ?? 1); setCanvasTransform([vp.scale.x, 0, 0, vp.scale.y, vp.x, vp.y]); // Video controls: show when exactly 1 video is selected if (items.length === 1 && items[0].type === 'video' && items[0].displayObject instanceof VideoSprite) { const vs = items[0].displayObject as VideoSprite; const b = getItemWorldBounds(items[0]); const screenTL = vp.toScreen(b.x, b.y); const screenBR = vp.toScreen(b.x + b.w, b.y + b.h); setVideoCtrl({ videoSprite: vs, screenRect: { x: screenTL.x, y: screenTL.y, w: screenBR.x - screenTL.x, h: screenBR.y - screenTL.y, }, }); } else { setVideoCtrl(null); } // Text format toolbar: show when all selected items are text (1 or more) const textItems = items.filter((it) => it.type === 'text'); if (textItems.length > 0 && textItems.length === items.length) { // Use first item's properties as representative const td = textItems[0].data as TextObject; let minX2 = Infinity, minY2 = Infinity, maxX2 = -Infinity, maxY2 = -Infinity; for (const item of textItems) { const b = getItemWorldBounds(item); if (b.x < minX2) minX2 = b.x; if (b.y < minY2) minY2 = b.y; if (b.x + b.w > maxX2) maxX2 = b.x + b.w; if (b.y + b.h > maxY2) maxY2 = b.y + b.h; } const screenTL2 = vp.toScreen(minX2, minY2); const screenTR2 = vp.toScreen(maxX2, minY2); const screenBL2 = vp.toScreen(minX2, maxY2); const screenBR2 = vp.toScreen(maxX2, maxY2); // Single text: show above; multiple: show below (selection toolbar is above) const useBottom = textItems.length >= 2; setTextToolbar({ x: useBottom ? (screenBL2.x + screenBR2.x) / 2 : (screenTL2.x + screenTR2.x) / 2, y: useBottom ? (screenBL2.y + screenBR2.y) / 2 + 8 : screenTL2.y, fontSize: td.fontSize, fontFamily: td.fontFamily, fill: td.fill, items: textItems, }); } else { setTextToolbar(null); } if (items.length < 2) { setSelToolbar(null); } else { // Compute world bounding box of selection let minX = Infinity, minY = Infinity, maxX = -Infinity; for (const item of items) { const b = getItemWorldBounds(item); if (b.x < minX) minX = b.x; if (b.y < minY) minY = b.y; if (b.x + b.w > maxX) maxX = b.x + b.w; } const screenTL = vp.toScreen(minX, minY); const screenTR = vp.toScreen(maxX, minY); setSelToolbar({ x: (screenTL.x + screenTR.x) / 2, y: screenTL.y, count: items.length }); } // Minimap data const scene = canvasRef.current?.getScene(); if (scene) { const allItems = scene.getTopLevelItems(); const mapItems = allItems.map((it) => { const b = getItemWorldBounds(it); return { x: b.x, y: b.y, w: b.w, h: b.h, type: it.type, id: it.id }; }); const topLeft = vp.toWorld(0, 0); const botRight = vp.toWorld(vp.screenWidth, vp.screenHeight); const vpBounds = { x: topLeft.x, y: topLeft.y, w: botRight.x - topLeft.x, h: botRight.y - topLeft.y }; let cMinX = Infinity, cMinY = Infinity, cMaxX = -Infinity, cMaxY = -Infinity; for (const it of mapItems) { if (it.x < cMinX) cMinX = it.x; if (it.y < cMinY) cMinY = it.y; if (it.x + it.w > cMaxX) cMaxX = it.x + it.w; if (it.y + it.h > cMaxY) cMaxY = it.y + it.h; } if (mapItems.length === 0) { cMinX = 0; cMinY = 0; cMaxX = 1; cMaxY = 1; } setMinimapData({ items: mapItems, viewportBounds: vpBounds, contentBounds: { x: cMinX, y: cMinY, w: cMaxX - cMinX, h: cMaxY - cMinY }, }); } // Pin positions follow media automatically (children of displayObjects). // Only refresh on zoom to update counter-scale. pinOverlay?.refresh(); }, [pinOverlay]); // Listen to viewport moved event for overlay updates (throttled) // Depends on objectCount so it re-runs after canvas init (viewport becomes available) useEffect(() => { const vp = canvasRef.current?.getViewport(); if (!vp) return; let rafId: number | null = null; const onMoved = () => { if (rafId) return; rafId = requestAnimationFrame(() => { rafId = null; updateOverlays(); }); }; vp.on('moved', onMoved); // Also listen to wheel events directly for immediate zoom feedback const onWheel = () => { if (rafId) return; rafId = requestAnimationFrame(() => { rafId = null; updateOverlays(); }); }; vp.on('wheel-scroll', onWheel); return () => { vp.off('moved', onMoved); vp.off('wheel-scroll', onWheel); if (rafId) cancelAnimationFrame(rafId); }; }, [updateOverlays, objectCount]); // Also update overlays when selection or scene changes useEffect(() => { updateOverlays(); }, [selectedLayerIds, updateOverlays]); // (PresenceOverlay removed — selection broadcast no longer needed) // -- Render -- if (loading) { return (
Loading board...
); } if (error) { return (
{error}
); } const board = boardData?.board; const collection = boardData?.collection; const canvasState = board?.canvas_state; return (
{/* Breadcrumb header */}
{user && ( <> navigate('/')} onMouseEnter={(e) => e.currentTarget.style.color = '#aaa'} onMouseLeave={(e) => e.currentTarget.style.color = '#666'} > {user.display_name || user.username || user.email} / )} {collection && ( <> navigate(`/collection/${collection.id}`)} onMouseEnter={(e) => e.currentTarget.style.color = '#ccc'} onMouseLeave={(e) => e.currentTarget.style.color = '#888'} > {collection.name} / )} {board?.name || 'Untitled'}
{/* Toolbar */} {!focusMode && canvasRef.current?.fitAll()} canUndo={canUndo} canRedo={canRedo} onUndo={() => { undoRef.current?.undo(); setCanUndo(undoRef.current?.canUndo() ?? false); setCanRedo(undoRef.current?.canRedo() ?? false); }} onRedo={() => { undoRef.current?.redo(); setCanUndo(undoRef.current?.canUndo() ?? false); setCanRedo(undoRef.current?.canRedo() ?? false); }} onZoomIn={() => { const vp = canvasRef.current?.getViewport(); if (!vp) return; const z = Math.min(vp.scale.x * 1.2, 5); canvasRef.current?.setZoom(z); setZoom(z); }} onZoomOut={() => { const vp = canvasRef.current?.getViewport(); if (!vp) return; const z = Math.max(vp.scale.x / 1.2, 0.1); canvasRef.current?.setZoom(z); setZoom(z); }} onlineUsers={onlineUsers} onUserClick={startFollowing} followingUserId={followingUserId} onToggleLayers={() => setShowLayers((v) => !v)} showLayers={showLayers} onToggleHelp={() => setShowHelp((v) => !v)} onMmImport={() => setShowMmImport(true)} onExport={() => setShowExport(true)} onToggleReview={() => setReviewMode((v) => !v)} reviewMode={reviewMode} boardName={board?.name} />} {/* Canvas */}
e.preventDefault()} onDrop={(e) => e.preventDefault()}> {/* Dot grid — behind transparent canvas */} {showGrid && (() => { const scale = canvasTransform[0] || 1; const tx = canvasTransform[4] || 0; const ty = canvasTransform[5] || 0; let spacing = 20; while (spacing * scale < 12) spacing *= 2; while (spacing * scale > 50) spacing /= 2; const screenSpacing = spacing * scale; const dotR = Math.max(0.5, Math.min(1.5, scale * 0.8)); const ox = tx % screenSpacing; const oy = ty % screenSpacing; const alpha = Math.max(0.12, Math.min(0.35, scale * 0.18)); return ( ); })()} {/* Empty canvas guide */} {objectCount === 0 && (
+
Drop images here
or paste from clipboard (Ctrl+V) · Draw (P) · Add text (T)
)} {/* Selection toolbar (floating, above selection) */} {selToolbar && selToolbar.count >= 2 && activeTool === ToolType.SELECT && !contextMenu && ( { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignLeft(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onAlignCenterH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterH(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onAlignRight={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignRight(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onAlignTop={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignTop(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onAlignCenterV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterV(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onAlignBottom={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignBottom(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onDistributeH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeHorizontal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onDistributeV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeVertical(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onPack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeOptimal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onGrid={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeGrid(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onRow={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeRow(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onColumn={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeColumn(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onStack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.stackObjects(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onFlipH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipHorizontal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onFlipV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipVertical(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onGroup={handleGroup} onNormSize={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.normalizeSize(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} /> )} {/* Text format toolbar (floating, above selected text items) */} {textToolbar && activeTool === ToolType.SELECT && !contextMenu && ( = 2 ? 'below' : 'above'} onFontSizeChange={(size) => { const scene = canvasRef.current?.getScene(); for (const item of textToolbar.items) { (item.data as TextObject).fontSize = size; const s = (item.displayObject as any)?.style; if (s) s.fontSize = size; // Re-measure text bounds const bounds = item.displayObject.getLocalBounds(); item.data.w = bounds.width; item.data.h = bounds.height; if (scene) scene.updateSpatialEntry(item); } selectionRef.current?.transformBox.update(textToolbar.items); onCanvasChange(textToolbar.items.map(i => i.id)); updateOverlays(); }} onFontFamilyChange={(family) => { const scene = canvasRef.current?.getScene(); for (const item of textToolbar.items) { (item.data as TextObject).fontFamily = family; const s = (item.displayObject as any)?.style; if (s) s.fontFamily = family; const bounds = item.displayObject.getLocalBounds(); item.data.w = bounds.width; item.data.h = bounds.height; if (scene) scene.updateSpatialEntry(item); } selectionRef.current?.transformBox.update(textToolbar.items); onCanvasChange(textToolbar.items.map(i => i.id)); updateOverlays(); }} onFillChange={(color) => { for (const item of textToolbar.items) { (item.data as TextObject).fill = color; const s = (item.displayObject as any)?.style; if (s) s.fill = color; } onCanvasChange(textToolbar.items.map(i => i.id)); updateOverlays(); }} /> )} {/* Video controls (floating, below selected video) */} {videoCtrl && activeTool === ToolType.SELECT && !contextMenu && ( )} {/* Minimap */} {showMinimap && !focusMode && objectCount > 0 && ( { const vp = canvasRef.current?.getViewport(); if (vp) vp.animate({ time: 200, position: { x: wx, y: wy }, ease: 'easeOutQuad' }); }} /> )} {/* Follow mode banner */} {followingDisplayName && (
Following {followingDisplayName} — click to stop
)} {/* Inline comment composer — anchored to draft pin */} {reviewMode && draftPin && ( { if (!draftPin || !draftCommentText.trim()) return; await handleCreatePointThread(draftPin, draftCommentText.trim()); }} onCancel={() => { setDraftPin(null); setDraftCommentText(''); }} submitting={creatingPointThread} /> )} {/* Context menu */} {contextMenu && ( setContextMenu(null)} /> )} {/* Layer panel */} {showLayers && ( { layerHandlers.onToggleVisible(id); refreshLayers(); }} onToggleLock={(id) => { layerHandlers.onToggleLock(id); refreshLayers(); }} onReorder={(from, to) => { layerHandlers.onReorder(from, to); refreshLayers(); }} onDelete={(id) => { layerHandlers.onDelete(id); refreshLayers(); }} onRename={(id, name) => { layerHandlers.onRename(id, name); refreshLayers(); }} onGroup={handleGroup} onUngroup={handleUngroup} hasSelection={(selectionRef.current?.selectedIds.size ?? 0) > 1} hasGroupSelection={(() => { const sel = selectionRef.current; if (!sel || sel.selectedIds.size !== 1) return false; const scene = canvasRef.current?.getScene(); if (!scene) return false; const id = Array.from(sel.selectedIds)[0]; const item = scene.getById(id); return item?.data.type === 'group'; })()} /> )} {/* Feedback / Review panel */} {reviewMode && annotationStore && user && resolvedBoardId && ( showToast(msg)} expandRequest={expandRequest} focusedThreadId={focusedThreadId} onThreadDetailChange={setOpenThreadDetailId} onJumpToObject={(objectId, thread) => { const scene = canvasRef.current?.getScene(); const vp = canvasRef.current?.getViewport(); if (!scene || !vp) return; const item = scene.items.get(objectId); if (!item) { showToast('This item has been deleted'); return; } const b = getItemWorldBounds(item); // For point-pinned threads, center on pin location let centerX = b.x + b.w / 2; let centerY = b.y + b.h / 2; if (thread?.anchor_type === 'point' && thread.pin_x != null && thread.pin_y != null) { centerX = b.x + thread.pin_x * b.w; centerY = b.y + thread.pin_y * b.h; } // Pan only by default; zoom in if object is too small on screen const screenW = b.w * vp.scale.x; if (screenW < 50) { const targetScale = Math.min((vp.screenWidth * 0.6) / b.w, 5); vp.animate({ time: 300, position: { x: centerX, y: centerY }, scale: targetScale, ease: 'easeOutQuad' }); } else { vp.animate({ time: 300, position: { x: centerX, y: centerY }, ease: 'easeOutQuad' }); } selectionRef.current?.selectOnly(objectId); }} /> )} {/* Upload progress panel */} {/* Toasts */}
{toasts.map((t) => (
{t.text}
))}
{/* Status bar */} {!focusMode && } {/* Shortcuts help overlay */} {showHelp && ( setShowHelp(false)} /> )} {/* Mattermost import modal */} {showMmImport && resolvedBoardId && ( setShowMmImport(false)} onMediaArrived={(assets) => { if (inboxZoneRef.current) { inboxZoneRef.current.addMedia(assets); } }} /> )} {/* Export dialog */} {showExport && ( { setShowExport(false); try { const app = canvasRef.current?.getApp() ?? null; const viewport = canvasRef.current?.getViewport() ?? null; await exportAsImage(app, viewport, items, options); showToast('Exported successfully'); } catch (err: any) { showToast('Export failed: ' + (err.message || 'unknown error')); } }} onClose={() => setShowExport(false)} /> )}
); } function StatusIndicator({ status }: { status: SaveStatus }) { const cfg: Record = { saved: { color: '#4ade80', label: 'Saved' }, saving: { color: '#facc15', label: 'Saving...' }, unsaved: { color: '#f87171', label: 'Unsaved' }, readonly: { color: '#4dabf7', label: 'Read-only' }, }; const s = cfg[status] || cfg.saved; return (
{s.label}
); }