Files
refboard-ayon/frontend/src/hooks/useCanvasSetup.ts
T

596 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PixiCanvasHandle | null>;
selectionRef: React.MutableRefObject<SelectionManager | null>;
undoRef: React.MutableRefObject<UndoManager | null>;
syncRef: React.MutableRefObject<SyncHandle | null>;
inboxZoneRef: React.MutableRefObject<InboxZone | null>;
canvasContainerRef: React.RefObject<HTMLDivElement | null>;
uploadManager: UploadManager;
onCanvasChange: (changedIds?: string[]) => void;
showToast: (msg: string) => void;
setOnlineUsers: React.Dispatch<React.SetStateAction<OnlineUser[]>>;
setSelectedLayerIds: React.Dispatch<React.SetStateAction<string[]>>;
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<AnnotationStore | null>(null);
if (!annotationStoreRef.current) annotationStoreRef.current = new AnnotationStore();
const pinOverlayRef = useRef<PinOverlay | null>(null);
const textEditorRef = useRef<TextEditor | null>(null);
if (!textEditorRef.current) textEditorRef.current = new TextEditor();
const cropOverlayRef = useRef<CropOverlay | null>(null);
const mdOverlayRef = useRef<MarkdownOverlay | null>(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 <canvas> 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<typeof setTimeout> | 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<typeof setTimeout> | 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 };
}