feat(refboard): canvas polish — transform box sync, dot grid, snap tuning, perf fixes
- Fix transform box not updating after alignment/arrangement/normalize/flip operations (shortcuts, context menu, and selection toolbar all fixed with DRY _opUpdate helper) - Replace 100ms polling loop with event-driven viewport updates (moved + wheel-scroll) - Add adaptive dot grid background that responds to zoom/pan - Reduce snap guide threshold from 8px to 4px for subtler snapping - Remove PresenceOverlay (remote selection highlighting) — too heavy for minimal benefit - Offset multiple dropped images so they don't overlap - Add new canvas modules: SnapGuides, clipboard, grouping, FrameSprite, DrawingSprite, LaserPointer, context-menu-items, SelectionToolbar, Minimap - Extract Editor hooks into dedicated files (useBoardLoader, useCanvasSetup, useShortcutHandler, useLayerPanel, useSaveManager, useFollowMode) - Sync improvements: real-time transform broadcast, board rooms, viewport sync
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getBoard } from '../api';
|
||||
|
||||
interface BoardLoaderResult {
|
||||
boardData: any;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads board data by ID with cancellation support.
|
||||
*/
|
||||
export function useBoardLoader(boardId: string | undefined): BoardLoaderResult {
|
||||
const [boardData, setBoardData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
if (!boardId) {
|
||||
setError('No board specified');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const res = await getBoard(boardId);
|
||||
if (!cancelled) {
|
||||
setBoardData(res.data);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
setError(err.response?.data?.error || 'Failed to load board');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [boardId]);
|
||||
|
||||
return { boardData, loading, error };
|
||||
}
|
||||
@@ -6,6 +6,8 @@ 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';
|
||||
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
|
||||
import { connectSocket, disconnectSocket } from '../socket';
|
||||
|
||||
interface OnlineUser {
|
||||
@@ -57,6 +59,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
||||
|
||||
const dropCleanupRef = useRef<(() => void) | null>(null);
|
||||
const pasteCleanupRef = useRef<(() => void) | null>(null);
|
||||
const laserCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!boardData || !resolvedBoardId) return;
|
||||
@@ -70,6 +73,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
||||
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);
|
||||
@@ -176,6 +182,65 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
||||
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
|
||||
@@ -208,6 +273,8 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
||||
inboxZoneRef.current = null;
|
||||
}
|
||||
syncRef.current?.cleanup();
|
||||
laserCleanupRef.current?.();
|
||||
laserCleanupRef.current = null;
|
||||
dropCleanupRef.current?.();
|
||||
pasteCleanupRef.current?.();
|
||||
disconnectSocket();
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
|
||||
interface FollowState {
|
||||
followingUserId: string | null;
|
||||
followingDisplayName: string | null;
|
||||
}
|
||||
|
||||
interface UseFollowModeProps {
|
||||
socket: Socket | null;
|
||||
boardId: string | undefined;
|
||||
getViewport: () => Viewport | null;
|
||||
}
|
||||
|
||||
interface UseFollowModeReturn {
|
||||
followingUserId: string | null;
|
||||
followingDisplayName: string | null;
|
||||
startFollowing: (userId: string, displayName: string) => void;
|
||||
stopFollowing: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow mode — click a user's avatar to track their viewport in real-time.
|
||||
* Broadcasts own viewport position so others can follow us.
|
||||
* Pan/zoom manually to break free.
|
||||
*/
|
||||
export function useFollowMode({ socket, boardId, getViewport }: UseFollowModeProps): UseFollowModeReturn {
|
||||
const [state, setState] = useState<FollowState>({
|
||||
followingUserId: null,
|
||||
followingDisplayName: null,
|
||||
});
|
||||
const followRef = useRef<string | null>(null);
|
||||
const ignoreNextMove = useRef(false);
|
||||
|
||||
// Broadcast own viewport position (always, so others can follow us)
|
||||
useEffect(() => {
|
||||
if (!socket || !boardId) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const vp = getViewport();
|
||||
if (!vp) return;
|
||||
socket.volatile.emit('viewport:sync', {
|
||||
boardId,
|
||||
x: vp.center.x,
|
||||
y: vp.center.y,
|
||||
scale: vp.scale.x,
|
||||
});
|
||||
}, 150);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [socket, boardId, getViewport]);
|
||||
|
||||
// Listen for viewport:sync from followed user and animate to match
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const onViewportSync = (data: { boardId: string; userId: string; x: number; y: number; scale: number }) => {
|
||||
if (data.boardId !== boardId) return;
|
||||
const targetId = followRef.current;
|
||||
if (!targetId || data.userId !== targetId) return;
|
||||
|
||||
const vp = getViewport();
|
||||
if (!vp) return;
|
||||
|
||||
ignoreNextMove.current = true;
|
||||
vp.animate({
|
||||
time: 200,
|
||||
position: { x: data.x, y: data.y },
|
||||
scale: data.scale,
|
||||
ease: 'easeOutQuad',
|
||||
callbackOnComplete: () => {
|
||||
// Brief delay before re-enabling break-free detection
|
||||
setTimeout(() => { ignoreNextMove.current = false; }, 50);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
socket.on('viewport:sync', onViewportSync);
|
||||
return () => { socket.off('viewport:sync', onViewportSync); };
|
||||
}, [socket, boardId, getViewport]);
|
||||
|
||||
// Detect manual pan/zoom to break free from follow mode
|
||||
useEffect(() => {
|
||||
const vp = getViewport();
|
||||
if (!vp) return;
|
||||
|
||||
const onMoved = () => {
|
||||
if (ignoreNextMove.current) return;
|
||||
if (followRef.current) {
|
||||
followRef.current = null;
|
||||
setState({ followingUserId: null, followingDisplayName: null });
|
||||
socket?.emit('follow:stop', { boardId });
|
||||
}
|
||||
};
|
||||
|
||||
vp.on('moved-end', onMoved);
|
||||
return () => { vp.off('moved-end', onMoved); };
|
||||
}, [getViewport, socket, boardId]);
|
||||
|
||||
const startFollowing = useCallback((userId: string, displayName: string) => {
|
||||
// Toggle: if already following this user, stop
|
||||
if (followRef.current === userId) {
|
||||
followRef.current = null;
|
||||
setState({ followingUserId: null, followingDisplayName: null });
|
||||
socket?.emit('follow:stop', { boardId });
|
||||
return;
|
||||
}
|
||||
|
||||
followRef.current = userId;
|
||||
setState({ followingUserId: userId, followingDisplayName: displayName });
|
||||
socket?.emit('follow:start', { boardId, targetUserId: userId });
|
||||
}, [socket, boardId]);
|
||||
|
||||
const stopFollowing = useCallback(() => {
|
||||
followRef.current = null;
|
||||
setState({ followingUserId: null, followingDisplayName: null });
|
||||
socket?.emit('follow:stop', { boardId });
|
||||
}, [socket, boardId]);
|
||||
|
||||
return {
|
||||
followingUserId: state.followingUserId,
|
||||
followingDisplayName: state.followingDisplayName,
|
||||
startFollowing,
|
||||
stopFollowing,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { PixiCanvasHandle } from '../canvas/PixiCanvas';
|
||||
import type { SelectionManager } from '../canvas/SelectionManager';
|
||||
import type { SceneItem } from '../canvas/SceneManager';
|
||||
import type { GroupObject } from '../canvas/scene-format';
|
||||
|
||||
interface LayerItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
isGroup: boolean;
|
||||
children?: LayerItem[];
|
||||
}
|
||||
|
||||
interface LayerPanelDeps {
|
||||
canvasRef: React.RefObject<PixiCanvasHandle | null>;
|
||||
selectionRef: React.RefObject<SelectionManager | null>;
|
||||
onCanvasChange: () => void;
|
||||
}
|
||||
|
||||
function autoName(item: SceneItem, i: number, counters: Record<string, number>): string {
|
||||
if (item.data.name) return item.data.name;
|
||||
const type = item.data.type;
|
||||
if (type === 'image') {
|
||||
counters.image = (counters.image || 0) + 1;
|
||||
return `Image ${counters.image}`;
|
||||
}
|
||||
if (type === 'text') {
|
||||
const textData = item.data as any;
|
||||
return (textData.text || 'Text').slice(0, 20);
|
||||
}
|
||||
if (type === 'group') {
|
||||
const groupData = item.data as GroupObject;
|
||||
return `Group (${groupData.children?.length || 0})`;
|
||||
}
|
||||
return `${type} ${i + 1}`;
|
||||
}
|
||||
|
||||
function buildLayerItem(
|
||||
item: SceneItem,
|
||||
i: number,
|
||||
counters: Record<string, number>,
|
||||
getScene: () => ReturnType<PixiCanvasHandle['getScene']>,
|
||||
): LayerItem {
|
||||
const isGroup = item.data.type === 'group';
|
||||
let children: LayerItem[] | undefined;
|
||||
if (isGroup) {
|
||||
const groupData = item.data as GroupObject;
|
||||
const scene = getScene();
|
||||
if (scene && groupData.children) {
|
||||
children = groupData.children.map((childId, ci) => {
|
||||
const childItem = scene.getById(childId);
|
||||
if (childItem) return buildLayerItem(childItem, ci, counters, getScene);
|
||||
return { id: childId, name: `Missing ${childId.slice(0, 6)}`, type: 'unknown', visible: true, locked: false, isGroup: false };
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: item.id,
|
||||
name: autoName(item, i, counters),
|
||||
type: item.data.type,
|
||||
visible: item.data.visible,
|
||||
locked: item.data.locked,
|
||||
isGroup,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer panel handlers — provides refresh function and all layer panel callbacks.
|
||||
*/
|
||||
export function useLayerPanel(deps: LayerPanelDeps) {
|
||||
const { canvasRef, selectionRef, onCanvasChange } = deps;
|
||||
const nameCounters = useRef<Record<string, number>>({});
|
||||
|
||||
const refreshLayers = useCallback((): LayerItem[] => {
|
||||
const scene = canvasRef.current?.getScene();
|
||||
if (!scene) return [];
|
||||
nameCounters.current = {};
|
||||
const items = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
|
||||
return items.map((item, i) =>
|
||||
buildLayerItem(item, i, nameCounters.current, () => canvasRef.current?.getScene() ?? null),
|
||||
);
|
||||
}, [canvasRef]);
|
||||
|
||||
const onSelect = useCallback((id: string) => {
|
||||
selectionRef.current?.selectOnly(id);
|
||||
}, [selectionRef]);
|
||||
|
||||
const onToggleVisible = useCallback((id: string) => {
|
||||
const scene = canvasRef.current?.getScene();
|
||||
if (!scene) return;
|
||||
const item = scene.getById(id);
|
||||
if (item) {
|
||||
item.data.visible = !item.data.visible;
|
||||
item.displayObject.visible = item.data.visible;
|
||||
onCanvasChange();
|
||||
}
|
||||
}, [canvasRef, onCanvasChange]);
|
||||
|
||||
const onToggleLock = useCallback((id: string) => {
|
||||
const scene = canvasRef.current?.getScene();
|
||||
if (!scene) return;
|
||||
const item = scene.getById(id);
|
||||
if (item) {
|
||||
item.data.locked = !item.data.locked;
|
||||
item.displayObject.eventMode = item.data.locked ? 'none' : 'static';
|
||||
}
|
||||
}, [canvasRef]);
|
||||
|
||||
const onReorder = useCallback((from: number, to: number) => {
|
||||
const scene = canvasRef.current?.getScene();
|
||||
if (!scene) return;
|
||||
const items = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
|
||||
if (from < 0 || from >= items.length || to < 0 || to >= items.length) return;
|
||||
const tmp = items[from].data.z;
|
||||
items[from].data.z = items[to].data.z;
|
||||
items[to].data.z = tmp;
|
||||
scene._applyZOrder();
|
||||
onCanvasChange();
|
||||
}, [canvasRef, onCanvasChange]);
|
||||
|
||||
const onDelete = useCallback((id: string) => {
|
||||
const scene = canvasRef.current?.getScene();
|
||||
if (!scene) return;
|
||||
scene.removeItem(id, true);
|
||||
selectionRef.current?.clear();
|
||||
onCanvasChange();
|
||||
}, [canvasRef, selectionRef, onCanvasChange]);
|
||||
|
||||
const onRename = useCallback((id: string, name: string) => {
|
||||
const scene = canvasRef.current?.getScene();
|
||||
if (!scene) return;
|
||||
const item = scene.getById(id);
|
||||
if (item) {
|
||||
item.data.name = name;
|
||||
// Also update frame label for groups
|
||||
if (item.data.type === 'group') {
|
||||
(item.data as any).label = name;
|
||||
if (item.displayObject && 'setLabel' in item.displayObject) {
|
||||
(item.displayObject as any).setLabel(name);
|
||||
}
|
||||
}
|
||||
onCanvasChange(); // Broadcast rename to other clients
|
||||
}
|
||||
}, [canvasRef, onCanvasChange]);
|
||||
|
||||
return {
|
||||
refreshLayers,
|
||||
layerHandlers: {
|
||||
onSelect,
|
||||
onToggleVisible,
|
||||
onToggleLock,
|
||||
onReorder,
|
||||
onDelete,
|
||||
onRename,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { saveCanvas } from '../api';
|
||||
import type { PixiCanvasHandle } from '../canvas/PixiCanvas';
|
||||
import type { SaveStatus } from '../components/StatusBar';
|
||||
|
||||
interface SaveManagerOptions {
|
||||
resolvedBoardId: string | undefined;
|
||||
isPublicView?: boolean;
|
||||
canvasRef: React.RefObject<PixiCanvasHandle | null>;
|
||||
setSaveStatus: (s: SaveStatus) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced save with thumbnail generation from PixiJS renderer.
|
||||
*/
|
||||
export function useSaveManager({ resolvedBoardId, isPublicView, canvasRef, setSaveStatus }: SaveManagerOptions) {
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const scheduleSave = useCallback(() => {
|
||||
if (!resolvedBoardId || isPublicView) return;
|
||||
setSaveStatus('unsaved');
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
const scene = canvasRef.current?.getScene();
|
||||
if (!scene) return;
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
const state = JSON.stringify(scene.serialize());
|
||||
|
||||
// Generate thumbnail from PixiJS renderer
|
||||
let thumbnail: string | undefined;
|
||||
try {
|
||||
const app = canvasRef.current?.getApp();
|
||||
const viewport = canvasRef.current?.getViewport();
|
||||
if (app?.renderer?.extract && viewport) {
|
||||
const fullCanvas = app.renderer.extract.canvas(viewport) as HTMLCanvasElement;
|
||||
const thumbMax = 400;
|
||||
const sw = fullCanvas.width;
|
||||
const sh = fullCanvas.height;
|
||||
if (sw > 0 && sh > 0) {
|
||||
const ratio = Math.min(thumbMax / sw, thumbMax / sh, 1);
|
||||
const tw = Math.round(sw * ratio);
|
||||
const th = Math.round(sh * ratio);
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = tw;
|
||||
offscreen.height = th;
|
||||
const ctx = offscreen.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(fullCanvas, 0, 0, tw, th);
|
||||
thumbnail = offscreen.toDataURL('image/webp', 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (thumbErr) {
|
||||
console.warn('Thumbnail generation failed:', thumbErr);
|
||||
}
|
||||
await saveCanvas(resolvedBoardId, state, thumbnail);
|
||||
setSaveStatus('saved');
|
||||
} catch {
|
||||
setSaveStatus('unsaved');
|
||||
}
|
||||
}, 2000);
|
||||
}, [resolvedBoardId, isPublicView, canvasRef, setSaveStatus]);
|
||||
|
||||
return { scheduleSave, saveTimerRef };
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { PixiCanvasHandle } from '../canvas/PixiCanvas';
|
||||
import type { SelectionManager } from '../canvas/SelectionManager';
|
||||
import type { UndoManager } from '../canvas/history';
|
||||
import type { SceneItem } from '../canvas/SceneManager';
|
||||
import { getItemWorldBounds } from '../canvas/SceneManager';
|
||||
import { ToolType, toolShortcuts } from '../canvas/tools';
|
||||
import { ShortcutContext, matchesShortcut, sortBySpecificity } from '../canvas/shortcuts';
|
||||
import { shortcuts as shortcutDefs } from '../canvas/shortcut-definitions';
|
||||
import { writeCanvasToClipboard, pasteFromSystemClipboard } from '../canvas/clipboard';
|
||||
|
||||
// Pre-sort shortcuts by specificity (most modifiers first) for correct matching
|
||||
const sortedShortcuts = sortBySpecificity(shortcutDefs);
|
||||
|
||||
interface ShortcutHandlerDeps {
|
||||
canvasRef: React.RefObject<PixiCanvasHandle | null>;
|
||||
selectionRef: React.RefObject<SelectionManager | null>;
|
||||
undoRef: React.RefObject<UndoManager | null>;
|
||||
clipboardRef: React.MutableRefObject<SceneItem[]>;
|
||||
resolvedBoardId: string | undefined;
|
||||
onCanvasChange: () => void;
|
||||
showToast: (msg: string) => void;
|
||||
refreshLayers: () => void;
|
||||
handleGroup: () => void;
|
||||
handleUngroup: () => void;
|
||||
setActiveTool: (t: ToolType) => void;
|
||||
setCanUndo: (v: boolean) => void;
|
||||
setCanRedo: (v: boolean) => void;
|
||||
setZoom: (z: number) => void;
|
||||
setShowGrid: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setFocusMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard shortcut handler — registry-based, delegates to shortcut-definitions.
|
||||
*/
|
||||
export function useShortcutHandler(deps: ShortcutHandlerDeps) {
|
||||
const {
|
||||
canvasRef, selectionRef, undoRef, clipboardRef, resolvedBoardId,
|
||||
onCanvasChange, showToast, refreshLayers,
|
||||
handleGroup, handleUngroup,
|
||||
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
||||
setShowGrid, setShowHelp, setFocusMode,
|
||||
} = deps;
|
||||
|
||||
useEffect(() => {
|
||||
async function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
|
||||
const scene = canvasRef.current?.getScene();
|
||||
const selection = selectionRef.current;
|
||||
const viewport = canvasRef.current?.getViewport();
|
||||
if (!scene || !selection || !viewport) return;
|
||||
|
||||
// Tool shortcuts (bare keys 1-5, v/h/p/t/e -- no modifiers)
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const tool = toolShortcuts[e.key.toLowerCase()];
|
||||
if (tool) {
|
||||
setActiveTool(tool);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build context for shortcut handlers
|
||||
const ctx: ShortcutContext = {
|
||||
scene,
|
||||
selection,
|
||||
history: undoRef.current!,
|
||||
viewport,
|
||||
onChange: onCanvasChange,
|
||||
clipboardRef,
|
||||
writeCanvasToClipboard: async (items?: SceneItem[]) => {
|
||||
try {
|
||||
const app = canvasRef.current?.getApp() ?? null;
|
||||
await writeCanvasToClipboard(app, viewport, items);
|
||||
} catch (err: any) {
|
||||
showToast('Copy failed: ' + (err.message || 'clipboard not available'));
|
||||
}
|
||||
},
|
||||
showToast,
|
||||
fitAll: () => canvasRef.current?.fitAll(),
|
||||
setActiveTool,
|
||||
setCanUndo,
|
||||
setCanRedo,
|
||||
refreshLayers,
|
||||
handleGroup,
|
||||
handleUngroup,
|
||||
toggleGrid: () => setShowGrid((v) => !v),
|
||||
toggleShowHelp: () => setShowHelp((v) => !v),
|
||||
toggleFocusMode: () => setFocusMode((v) => !v),
|
||||
pasteFromSystemClipboard: async (): Promise<string> => {
|
||||
if (!resolvedBoardId) return 'No board';
|
||||
const msg = await pasteFromSystemClipboard(scene, viewport, resolvedBoardId, onCanvasChange);
|
||||
if (msg !== 'No image in clipboard') showToast(msg);
|
||||
return msg;
|
||||
},
|
||||
fitSelection: () => {
|
||||
const vp = canvasRef.current?.getViewport();
|
||||
if (!vp || !selection) return;
|
||||
const items = selection.getSelectedItems();
|
||||
if (items.length === 0) return;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const item of items) {
|
||||
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
||||
if (ix < minX) minX = ix;
|
||||
if (iy < minY) minY = iy;
|
||||
if (ix + iw > maxX) maxX = ix + iw;
|
||||
if (iy + ih > maxY) maxY = iy + ih;
|
||||
}
|
||||
const padding = 60;
|
||||
const contentW = maxX - minX + padding * 2;
|
||||
const contentH = maxY - minY + padding * 2;
|
||||
const scaleX = vp.screenWidth / contentW;
|
||||
const scaleY = vp.screenHeight / contentH;
|
||||
const targetScale = Math.min(scaleX, scaleY, 5);
|
||||
vp.animate({
|
||||
position: { x: (minX + maxX) / 2, y: (minY + maxY) / 2 },
|
||||
scale: targetScale,
|
||||
time: 300,
|
||||
ease: 'easeOutQuad',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Match against sorted registry (most-specific first)
|
||||
for (const def of sortedShortcuts) {
|
||||
if (!matchesShortcut(e, def)) continue;
|
||||
|
||||
// Check selection requirements
|
||||
const activeCount = selection.selectedIds.size;
|
||||
if (def.needsSelection && activeCount === 0) continue;
|
||||
if (def.minSelection && activeCount < def.minSelection) continue;
|
||||
|
||||
e.preventDefault();
|
||||
await def.handler(ctx);
|
||||
|
||||
// Update zoom display after any action
|
||||
setZoom(canvasRef.current?.getZoom() ?? 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [
|
||||
canvasRef, selectionRef, undoRef, clipboardRef, resolvedBoardId,
|
||||
onCanvasChange, showToast, refreshLayers,
|
||||
handleGroup, handleUngroup,
|
||||
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
||||
setShowGrid, setShowHelp, setFocusMode,
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user