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,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