Pass SelectionManager to setupDragDrop and setupPaste. After each upload completes, the newly created scene items are selected: - Single file/URL: selectOnly - Multi-file drop/paste: select all new items
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
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';
|
|
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
|
|
import { connectSocket, disconnectSocket } from '../socket';
|
|
|
|
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>;
|
|
onCanvasChange: (changedIds?: string[]) => void;
|
|
showToast: (msg: string) => void;
|
|
setOnlineUsers: React.Dispatch<React.SetStateAction<OnlineUser[]>>;
|
|
setSelectedLayerIds: React.Dispatch<React.SetStateAction<string[]>>;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
|
|
} = deps;
|
|
|
|
const dropCleanupRef = useRef<(() => void) | null>(null);
|
|
const pasteCleanupRef = useRef<(() => void) | null>(null);
|
|
const laserCleanupRef = useRef<(() => void) | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!boardData || !resolvedBoardId) return;
|
|
|
|
const timer = setTimeout(() => {
|
|
const scene = canvasRef.current?.getScene();
|
|
const viewport = canvasRef.current?.getViewport();
|
|
if (!scene || !viewport) return;
|
|
|
|
// 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 = new TextEditor();
|
|
// 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, () => {
|
|
syncRef.current?.broadcastElements([item.id]);
|
|
onCanvasChange();
|
|
});
|
|
};
|
|
|
|
// 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);
|
|
|
|
// 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 (batched for multi-select)
|
|
selection.onItemsTransform = (items) => {
|
|
syncRef.current?.broadcastTransform(items);
|
|
};
|
|
selection.onItemTransform = (item) => {
|
|
syncRef.current?.broadcastTransform(item);
|
|
};
|
|
selection.transformBox.onItemTransform = (item) => {
|
|
syncRef.current?.broadcastTransform(item);
|
|
};
|
|
selection.onObjectDragEnd = (itemIds) => {
|
|
onCanvasChange(itemIds); // broadcasts elements + saves + undo + spatial refresh
|
|
};
|
|
selection.transformBox.onDragEnd = (itemIds) => {
|
|
onCanvasChange(itemIds); // broadcasts elements + saves + undo + spatial refresh
|
|
};
|
|
|
|
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 || data.status !== 'done') return;
|
|
const { imageId, posterAssetKey, nativeWidth, nativeHeight, duration } = data;
|
|
if (!imageId) 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);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
pasteCleanupRef.current = setupPaste(viewport, scene, resolvedBoardId, onCanvasChange, selection);
|
|
}
|
|
}, 200);
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
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?.();
|
|
disconnectSocket();
|
|
};
|
|
}, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, setOnlineUsers, setSelectedLayerIds]);
|
|
}
|