import { useEffect, useRef } from 'react'; import type { PixiCanvasHandle } from '../canvas/PixiCanvas'; import { SelectionManager } from '../canvas/SelectionManager'; import { TextEditor } from '../canvas/TextEditor'; import { setupSync, SyncHandle } from '../canvas/sync'; import { setupDragDrop, setupPaste } from '../canvas/image-drop'; import { UndoManager } from '../canvas/history'; import { InboxZone } from '../canvas/InboxZone'; import { LaserPointer } from '../canvas/LaserPointer'; import { VideoSprite } from '../canvas/sprites/VideoSprite'; import { ImageSprite } from '../canvas/sprites/ImageSprite'; import { PdfPageSprite } from '../canvas/sprites/PdfPageSprite'; import type { PdfUploadedData } from '../canvas/image-drop'; import { UploadManager } from '../stores/uploadManager'; import { AnnotationStore } from '../stores/annotationStore'; import { PinOverlay } from '../canvas/PinOverlay'; import { CropOverlay } from '../canvas/CropOverlay'; import { MarkdownOverlay } from '../canvas/MarkdownOverlay'; import { TextSprite } from '../canvas/sprites/TextSprite'; import { TextSharpnessManager } from '../canvas/textSharpness'; import { getItemWorldBounds } from '../canvas/SceneManager'; import { applyImageDisplayTransform, getImageDisplayCropRect, imageViewPointToWorld } from '../canvas/imageTransforms'; // PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit import { connectSocket, disconnectSocket } from '../socket'; import api from '../api'; interface OnlineUser { userId: string; displayName: string; color: string; } const CURSOR_COLORS = [ '#ff6b6b', '#ffa94d', '#ffd43b', '#69db7c', '#38d9a9', '#4dabf7', '#7950f2', '#e64980', '#20c997', '#ff922b', ]; function userColor(userId: string): string { let hash = 0; for (let i = 0; i < userId.length; i++) { hash = ((hash << 5) - hash) + userId.charCodeAt(i); hash |= 0; } return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length]; } interface CanvasSetupDeps { boardData: any; resolvedBoardId: string | undefined; user: any; isPublicView?: boolean; canvasRef: React.RefObject; selectionRef: React.MutableRefObject; undoRef: React.MutableRefObject; syncRef: React.MutableRefObject; inboxZoneRef: React.MutableRefObject; canvasContainerRef: React.RefObject; uploadManager: UploadManager; onCanvasChange: (changedIds?: string[]) => void; showToast: (msg: string) => void; setOnlineUsers: React.Dispatch>; setSelectedLayerIds: React.Dispatch>; pasteOpts?: { onTextPaste?: (data: { text: string; html: string; hasImage: boolean }) => void; onShortTextPaste?: (text: string) => void; onPdfUploaded?: (data: PdfUploadedData) => void; }; onPdfUploaded?: (data: PdfUploadedData) => void; onCropModeChange?: (active: boolean) => void; } /** * Sets up the canvas infrastructure: selection, undo, sync, socket, drag/drop, paste, inbox zone. * Runs once when boardData is loaded and canvas is ready. */ export function useCanvasSetup(deps: CanvasSetupDeps) { const { boardData, resolvedBoardId, user, isPublicView, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef, uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, pasteOpts, onPdfUploaded, onCropModeChange, } = deps; const dropCleanupRef = useRef<(() => void) | null>(null); const pasteCleanupRef = useRef<(() => void) | null>(null); const laserCleanupRef = useRef<(() => void) | null>(null); const sharpnessCleanupRef = useRef<(() => void) | null>(null); const annotationStoreRef = useRef(null); if (!annotationStoreRef.current) annotationStoreRef.current = new AnnotationStore(); const pinOverlayRef = useRef(null); const textEditorRef = useRef(null); if (!textEditorRef.current) textEditorRef.current = new TextEditor(); const cropOverlayRef = useRef(null); const mdOverlayRef = useRef(null); useEffect(() => { if (!boardData || !resolvedBoardId) return; let attempts = 0; const maxAttempts = 100; // 5 seconds max (100 × 50ms) const poll = setInterval(() => { attempts++; const scene = canvasRef.current?.getScene(); const viewport = canvasRef.current?.getViewport(); if (!scene || !viewport) { if (attempts >= maxAttempts) { clearInterval(poll); console.error('[canvas-setup] Canvas not ready after 5s, giving up'); } return; } clearInterval(poll); // Create SelectionManager const selection = new SelectionManager(viewport, scene); selectionRef.current = selection; // Wire snap guides to transform box selection.transformBox.setSnapGuides(selection.snapGuides); // Wire selection change to update layer panel state selection.onSelectionChange = (ids: string[]) => { setSelectedLayerIds(ids); }; // Inline text editing on double-click const textEditor = textEditorRef.current!; // Find the DOM container for the canvas (parent of the element) 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; } } selection.onDoubleClickText = (item) => { if (!domContainer) return; textEditor.startEditing(item, viewport, domContainer, () => { // If user saved empty text, remove the item const text = (item.data as any).text?.trim(); if (!text) { scene.removeItem(item.id, true); } syncRef.current?.broadcastElements([item.id]); onCanvasChange(); }, (resizedItem) => { scene.updateSpatialEntry(resizedItem); selection.transformBox.update([resizedItem]); }); }; // Double-click image: zoom-to-fit (PureRef-style focus) selection.onDoubleClickImage = (item) => { const padding = 80; // screen pixels of padding around the image const screenW = viewport.screenWidth; const screenH = viewport.screenHeight; const bounds = getItemWorldBounds(item); const bw = bounds.w; const bh = bounds.h; const scaleX = (screenW - padding * 2) / bw; const scaleY = (screenH - padding * 2) / bh; const targetScale = Math.min(scaleX, scaleY, 3); // cap at 3x const cx = bounds.x + bw / 2; const cy = bounds.y + bh / 2; viewport.animate({ time: 300, position: { x: cx, y: cy }, scale: targetScale, ease: 'easeOutQuad', }); }; // Double-click markdown → enter edit mode selection.onDoubleClickMarkdown = (item) => { if (mdOverlayRef.current) { undoRef.current?.saveState(); mdOverlayRef.current.setEditing(item.id); selection.setEnabled(false); } }; // Refresh transform box when item dimensions change (e.g. video metadata loaded) scene.onItemDimensionsChanged = (itemId: string) => { if (selection.selectedIds.has(itemId)) { selection.transformBox.update(selection.getSelectedItems()); } }; // Create UndoManager undoRef.current = new UndoManager(scene); // Zoom-bucket text sharpness — re-rasterize visible text when zoom crosses bucket boundary // Listen on both 'zoomed' and 'moved' — pixi-viewport may only fire 'moved' for some zoom types const sharpness = new TextSharpnessManager(viewport, scene); const onZoomBucketCheck = () => { sharpness.check(); }; viewport.on('zoomed', onZoomBucketCheck); viewport.on('moved', onZoomBucketCheck); scene.onItemCreated = (item) => { sharpness.applyToItem(item); }; sharpnessCleanupRef.current = () => { viewport.off('zoomed', onZoomBucketCheck); viewport.off('moved', onZoomBucketCheck); scene.onItemCreated = null; sharpness.destroy(); }; // ── Markdown overlay — DOM divs positioned over canvas for each markdown card ── if (domContainer) { const mdOverlay = new MarkdownOverlay(viewport, scene, domContainer); mdOverlayRef.current = mdOverlay; mdOverlay.refreshAll(); } // Create InboxZone and add to viewport const inboxZone = new InboxZone(scene.textures, scene.springs); viewport.addChild(inboxZone); inboxZoneRef.current = inboxZone; if (user) { const socket = connectSocket(); syncRef.current = setupSync(scene, socket, resolvedBoardId, { onRemoteTransform: (item) => { if (selection.selectedIds.has(item.id)) { selection.transformBox.update(selection.getSelectedItems()); } }, }); // Wire live drag/resize transforms to sync broadcast + pin position update // updatePositions() is the fast path — zero allocations, just position.set() selection.onItemsTransform = (items) => { syncRef.current?.broadcastTransform(items); pinOverlayRef.current?.updatePositions(); for (const item of items) { if (item.type === 'markdown') mdOverlayRef.current?.updateItem(item.id); } }; selection.onItemTransform = (item) => { syncRef.current?.broadcastTransform(item); pinOverlayRef.current?.updatePositions(); if (item.type === 'markdown') mdOverlayRef.current?.updateItem(item.id); }; selection.transformBox.onItemTransform = (item) => { syncRef.current?.broadcastTransform(item); pinOverlayRef.current?.updatePositions(); if (item.type === 'markdown') mdOverlayRef.current?.updateItem(item.id); }; selection.onObjectDragEnd = (itemIds) => { onCanvasChange(itemIds); // broadcasts elements + saves + undo + spatial refresh }; selection.transformBox.onDragEnd = (itemIds) => { // Bake scale into fontSize for text/sticky items after resize for (const id of itemIds) { const item = scene.getById(id); if (!item) continue; const absSx = Math.abs(item.data.sx); const absSy = Math.abs(item.data.sy); if (absSx === 1 && absSy === 1) continue; if (item.type === 'text') { const scale = (absSx + absSy) / 2; const d = item.data as any; d.fontSize = Math.round(d.fontSize * scale); d.sx = item.data.sx > 0 ? 1 : -1; d.sy = item.data.sy > 0 ? 1 : -1; if (item.displayObject instanceof TextSprite) { item.displayObject.updateFromData(d); d.w = item.displayObject.measuredWidth; d.h = item.displayObject.measuredHeight; } item.displayObject.scale.set(d.sx, d.sy); } } onCanvasChange(itemIds); }; socket.on('user:joined', (data: any) => { const uid = data.userId || data.id; const name = data.displayName || data.display_name || data.username || ''; setOnlineUsers((prev) => { if (prev.find((u) => u.userId === uid)) return prev; return [...prev, { userId: uid, displayName: name, color: userColor(uid) }]; }); if (name) showToast(`${name} joined`); }); socket.on('user:left', (data: any) => { const uid = data.userId || data.id; const name = data.displayName || data.display_name || data.username || ''; setOnlineUsers((prev) => prev.filter((u) => u.userId !== uid)); if (name) showToast(`${name} left`); }); socket.on('room:users', (data: any) => { if (Array.isArray(data.users)) { setOnlineUsers(data.users.map((u: any) => ({ userId: u.userId || u.id, displayName: u.displayName || u.display_name || u.username || '', color: userColor(u.userId || u.id), }))); } }); socket.on('board:media-arrived', (data: any) => { const assets = data?.assets; if (Array.isArray(assets) && assets.length > 0 && inboxZoneRef.current) { inboxZoneRef.current.addMedia(assets); showToast(`${assets.length} image${assets.length !== 1 ? 's' : ''} arrived in Inbox`); } }); // Media processing pipeline: upgrade videos when poster/metadata arrives socket.on('media:job:update', (data: any) => { if (!data) return; const { imageId, status } = data; if (!imageId) return; // Surface processing failures to upload manager if (status === 'failed') { uploadManager.processingFailed(imageId, data.error || 'Video processing failed'); return; } if (status !== 'done') return; const { posterAssetKey, nativeWidth, nativeHeight, duration } = data; // Update upload manager — transitions video jobs from "processing" to "done" uploadManager.processingComplete(imageId); // PDF thumbnail arrived — update placed pdf-page items if (data.type === 'pdf-thumbnail') { for (const item of scene.items.values()) { if (item.data.type !== 'pdf-page') continue; const d = item.data as any; if (d.pdfImageId === imageId && d.pageNumber === data.pageNumber) { d.thumb = data.thumbAssetKey; if (item.displayObject instanceof PdfPageSprite) { item.displayObject.setThumb(data.thumbAssetKey); } break; } } return; } // PDF hires arrived — upgrade texture on placed pdf-page items if (data.type === 'pdf-hires') { for (const item of scene.items.values()) { if (item.data.type !== 'pdf-page') continue; const d = item.data as any; if (d.pdfImageId === imageId && d.pageNumber === data.pageNumber) { d.asset = data.hiresAssetKey; if (item.displayObject instanceof PdfPageSprite) { item.displayObject.upgradeTexture(data.hiresAssetKey); } break; } } return; } // Find the video item by matching its DB image ID stored in scene data for (const item of scene.items.values()) { if (item.data.type !== 'video') continue; // The asset key contains the imageId (e.g. boards/{boardId}/{imageId}.mp4) if (!item.data.asset?.includes(imageId)) continue; // Update scene data model const vidData = item.data as any; if (posterAssetKey) vidData.poster = posterAssetKey; if (nativeWidth) { vidData.nativeW = nativeWidth; vidData.w = nativeWidth; } if (nativeHeight) { vidData.nativeH = nativeHeight; vidData.h = nativeHeight; } if (duration) vidData.duration = duration; // Update rendered VideoSprite (dimensions, shadow, overlay, poster key) if (item.displayObject instanceof VideoSprite) { item.displayObject.applyProcessedMedia({ posterAssetKey, nativeWidth, nativeHeight }); } // Update spatial index for changed dimensions scene.updateSpatialEntry(item); // No broadcastElements here — all clients receive the same // media:job:update from the server. Echoing would cause fanout churn. break; } }); // Cursor tracking — throttled to ~30fps to avoid flooding the socket let cursorTimer: ReturnType | null = null; const onPointerMove = (e: any) => { if (cursorTimer) return; const world = viewport.toWorld(e.global.x, e.global.y); socket.emit('cursor:move', { boardId: resolvedBoardId, x: world.x, y: world.y, }); cursorTimer = setTimeout(() => { cursorTimer = null; }, 33); }; viewport.on('pointermove', onPointerMove); // ---- Laser pointer (hold L key) ---- const laserColorHex = userColor(user?.id || ''); const laserColorNum = parseInt(laserColorHex.replace('#', ''), 16); const laser = new LaserPointer(viewport, laserColorNum); let laserTimer: ReturnType | null = null; const onLaserMove = (e: any) => { if (!laser.isActive) return; const world = viewport.toWorld(e.global.x, e.global.y); laser.addPoint(world.x, world.y); // Throttle broadcast to ~50ms if (laserTimer) return; socket.volatile.emit('laser:move', { boardId: resolvedBoardId, points: [{ x: world.x, y: world.y }], }); laserTimer = setTimeout(() => { laserTimer = null; }, 50); }; viewport.on('pointermove', onLaserMove); const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'l' || e.key === 'L') { if (laser.isActive) return; laser.start(); } }; const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'l' || e.key === 'L') { laser.stop(); socket.emit('laser:stop', { boardId: resolvedBoardId }); } }; window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); laserCleanupRef.current = () => { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); laser.destroy(); }; // Listen for remote laser events socket.on('laser:move', (data: any) => { if (data.boardId !== resolvedBoardId) return; const uid = data.userId; if (!uid) return; const color = parseInt(userColor(uid).replace('#', ''), 16); laser.addRemotePoints(uid, data.points, color); }); socket.on('laser:stop', (_data: any) => { // Remote user stopped — points will fade naturally }); socket.on('user:left', (data: any) => { const uid = data.userId || data.id; if (uid) { laser.removeRemote(uid); } }); // ── Annotations: load threads, wire socket events ── api.get(`/api/boards/${resolvedBoardId}/threads`) .then((res) => { if (res.data.threads) annotationStoreRef.current?.loadThreads(res.data.threads); }) .catch((err) => console.error('[annotations] load threads error:', err)); socket.on('thread:add', (data: any) => { annotationStoreRef.current?.onThreadAdd(data.thread, data.comment); }); socket.on('thread:status', (data: any) => { annotationStoreRef.current?.onThreadStatus(data.threadId, data.status, data.resolvedBy, data.resolvedAt); }); socket.on('thread:delete', (data: any) => { annotationStoreRef.current?.onThreadDelete(data.threadId); }); socket.on('comment:add', (data: any) => { annotationStoreRef.current?.onCommentAdd(data.threadId, data.comment); }); socket.on('comment:update', (data: any) => { annotationStoreRef.current?.onCommentUpdate(data.threadId, data.commentId, data.content, data.editedAt); }); socket.on('comment:delete', (data: any) => { annotationStoreRef.current?.onCommentDelete(data.threadId, data.commentId); }); // ── Pin overlay — separate Container, positions from item.data directly ── const pinOverlay = new PinOverlay(viewport, scene, annotationStoreRef.current!); pinOverlay.visible = true; viewport.addChild(pinOverlay); pinOverlayRef.current = pinOverlay; // Refresh on viewport move (zoom changes scale) and store changes const onViewportMoved = () => { pinOverlay.refresh(); }; viewport.on('moved', onViewportMoved); const unsubPinOverlay = annotationStoreRef.current!.subscribe(() => { pinOverlay.refresh(); }); (pinOverlay as any)._cleanup = () => { viewport.off('moved', onViewportMoved); unsubPinOverlay(); }; } // Crop overlay const cropOverlay = new CropOverlay(viewport); viewport.addChild(cropOverlay); cropOverlayRef.current = cropOverlay; cropOverlay.onStateChange = (active) => { onCropModeChange?.(active); }; cropOverlay.onConfirm = (item, crop, anchorWorld) => { const imgData = item.data as any; const isFullImage = crop.x < 0.001 && crop.y < 0.001 && crop.w > 0.999 && crop.h > 0.999; imgData.crop = isFullImage ? undefined : crop; if (item.displayObject instanceof ImageSprite) { item.displayObject.applyCrop(imgData.crop); } // Reposition so the crop corner stays at anchorWorld. // Use the display crop's top-left in world space (not AABB min) // to avoid drift on rotated images. const displayCrop = getImageDisplayCropRect(imgData); const currentAnchor = imageViewPointToWorld(imgData, displayCrop.x, displayCrop.y); imgData.x += anchorWorld.x - currentAnchor.x; imgData.y += anchorWorld.y - currentAnchor.y; if (item.type === 'image') { applyImageDisplayTransform(item.displayObject, imgData); } scene.updateSpatialEntry(item); selection.setEnabled(true); selection.transformBox.update([item]); onCanvasChange([item.id]); }; cropOverlay.onCancel = () => { selection.setEnabled(true); }; // Setup drag/drop and paste if (!isPublicView || user) { // Use the ref first, fall back to DOM query for the canvas parent let dropTarget: HTMLElement | null = canvasContainerRef.current; if (!dropTarget) { const canvasEl = document.querySelector('canvas'); dropTarget = canvasEl?.parentElement ?? null; } if (dropTarget) { dropCleanupRef.current = setupDragDrop(dropTarget, viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager, onPdfUploaded); } const mergedPasteOpts = pasteOpts ? { ...pasteOpts, onPdfUploaded } : { onPdfUploaded }; pasteCleanupRef.current = setupPaste(viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager, mergedPasteOpts); } }, 50); return () => { clearInterval(poll); selectionRef.current?.destroy(); selectionRef.current = null; if (inboxZoneRef.current) { inboxZoneRef.current.clear(); inboxZoneRef.current.destroy({ children: true }); inboxZoneRef.current = null; } syncRef.current?.cleanup(); laserCleanupRef.current?.(); laserCleanupRef.current = null; dropCleanupRef.current?.(); pasteCleanupRef.current?.(); textEditorRef.current?.stopEditing(false); if (mdOverlayRef.current) { mdOverlayRef.current.destroy(); mdOverlayRef.current = null; } if (cropOverlayRef.current) { cropOverlayRef.current.destroy(); cropOverlayRef.current = null; } if (pinOverlayRef.current) { (pinOverlayRef.current as any)._cleanup?.(); pinOverlayRef.current.destroy(); pinOverlayRef.current = null; } annotationStoreRef.current?.clear(); sharpnessCleanupRef.current?.(); sharpnessCleanupRef.current = null; disconnectSocket(); }; }, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds, pasteOpts, onPdfUploaded, onCropModeChange]); return { annotationStore: annotationStoreRef.current, pinOverlay: pinOverlayRef.current, textEditor: textEditorRef.current, cropOverlayRef, mdOverlay: mdOverlayRef.current }; }