Files
refboard-ayon/frontend/src/canvas/tools.ts
T
Hiren Kangad e9509ef7b0 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
2026-03-10 03:58:53 +05:30

289 lines
8.6 KiB
TypeScript

/**
* Tools — PixiJS version.
*
* SELECT/PAN are handled by the viewport (pixi-viewport) and SelectionManager.
* PEN/TEXT/ERASER need PixiJS implementations.
* For now, only SELECT and PAN are fully functional; PEN/TEXT/ERASER are stubs
* that will be implemented when drawing support is added.
*/
import type { Viewport } from 'pixi-viewport';
import type { SceneManager } from './SceneManager';
import type { SelectionManager } from './SelectionManager';
import { Text, TextStyle } from 'pixi.js';
import { DrawingSprite } from './sprites/DrawingSprite';
import type { DrawingObject } from './scene-format';
export enum ToolType {
SELECT = 'SELECT',
PAN = 'PAN',
PEN = 'PEN',
TEXT = 'TEXT',
ERASER = 'ERASER',
}
export interface ToolOptions {
color?: string;
strokeWidth?: number;
fontSize?: number;
}
const defaultOptions: ToolOptions = {
color: '#ffffff',
strokeWidth: 4,
fontSize: 24,
};
type CleanupFn = (() => void) | null;
export interface ToolContext {
viewport: Viewport;
scene: SceneManager;
selection: SelectionManager;
container: HTMLElement;
onChange: () => void;
/** Broadcast only specific changed elements (lightweight, for live drawing). */
broadcastElements?: (ids: string[]) => void;
}
export function activateTool(
ctx: ToolContext,
tool: ToolType,
options: ToolOptions = {}
): CleanupFn {
const opts = { ...defaultOptions, ...options };
const { viewport, scene, selection, container } = ctx;
// Reset cursor
container.style.cursor = '';
// Enable/disable SelectionManager based on tool
selection.setEnabled(tool === ToolType.SELECT);
switch (tool) {
case ToolType.SELECT: {
container.style.cursor = 'default';
// SelectionManager handles click/rubber-band selection
return null;
}
case ToolType.PAN: {
container.style.cursor = 'grab';
// Space+drag is handled by PixiCanvas; this just sets cursor
return null;
}
case ToolType.TEXT: {
container.style.cursor = 'text';
const onClick = (e: PointerEvent) => {
const rect = container.getBoundingClientRect();
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
const textData = {
id: crypto.randomUUID(),
type: 'text' as const,
x: world.x,
y: world.y,
w: 200,
h: 30,
sx: 1,
sy: 1,
angle: 0,
z: scene.nextZ(),
opacity: 1,
locked: false,
name: '',
visible: true,
text: 'Type here',
fontSize: opts.fontSize!,
fill: opts.color!,
fontFamily: 'sans-serif',
};
scene._createItem(textData, true);
scene._applyZOrder();
ctx.broadcastElements?.([textData.id]);
ctx.onChange();
// Remove handler after placing text
container.removeEventListener('pointerdown', onClick);
};
container.addEventListener('pointerdown', onClick);
return () => {
container.removeEventListener('pointerdown', onClick);
};
}
case ToolType.ERASER: {
container.style.cursor = 'crosshair';
// Clear any existing selection/transform box when switching to eraser
selection.clear();
selection.transformBox.update([]);
const onClick = (e: PointerEvent) => {
const rect = container.getBoundingClientRect();
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
const hit = selection._hitTest(world.x, world.y);
if (hit) {
selection.selectedIds.delete(hit.id);
selection.transformBox.update([]);
scene.removeItem(hit.id, true);
ctx.onChange();
}
};
container.addEventListener('pointerdown', onClick);
return () => {
container.removeEventListener('pointerdown', onClick);
};
}
case ToolType.PEN: {
container.style.cursor = 'crosshair';
let drawing = false;
let currentSprite: DrawingSprite | null = null;
let originX = 0;
let originY = 0;
let itemId = '';
let syncTimer: ReturnType<typeof setTimeout> | null = null;
const SYNC_INTERVAL = 100; // ~10fps — lightweight element-only sync
const scheduleLiveSync = () => {
if (syncTimer) return;
syncTimer = setTimeout(() => {
syncTimer = null;
if (!drawing) return;
// Copy current points into item.data for serialization
const item = scene.getById(itemId);
if (item && currentSprite) {
(item.data as DrawingObject).points = [...currentSprite.points];
}
// Send only the drawing element, not the entire scene
ctx.broadcastElements?.([itemId]);
}, SYNC_INTERVAL);
};
const onDown = (e: PointerEvent) => {
if (e.button !== 0) return;
drawing = true;
const rect = container.getBoundingClientRect();
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
originX = world.x;
originY = world.y;
itemId = crypto.randomUUID();
const drawData: DrawingObject = {
id: itemId,
type: 'drawing',
x: originX,
y: originY,
w: 1,
h: 1,
sx: 1,
sy: 1,
angle: 0,
z: scene.nextZ(),
opacity: 1,
locked: false,
name: '',
visible: true,
points: [0, 0],
color: opts.color!,
strokeWidth: opts.strokeWidth!,
};
scene._createItem(drawData, false);
const item = scene.getById(itemId);
if (item && item.displayObject instanceof DrawingSprite) {
currentSprite = item.displayObject as DrawingSprite;
}
container.setPointerCapture(e.pointerId);
};
const onMove = (e: PointerEvent) => {
if (!drawing || !currentSprite) return;
const rect = container.getBoundingClientRect();
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
currentSprite.addPoint(world.x - originX, world.y - originY);
scheduleLiveSync();
};
const onUp = () => {
if (!drawing) return;
drawing = false;
if (syncTimer) { clearTimeout(syncTimer); syncTimer = null; }
// Compute bounding box and normalize points so origin = top-left of stroke
const item = scene.getById(itemId);
if (item && currentSprite) {
const pts = currentSprite.points;
if (pts.length < 4) {
scene.removeItem(itemId, false);
} else {
// Find bounds of all points
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i < pts.length; i += 2) {
minX = Math.min(minX, pts[i]);
minY = Math.min(minY, pts[i + 1]);
maxX = Math.max(maxX, pts[i]);
maxY = Math.max(maxY, pts[i + 1]);
}
const sw = opts.strokeWidth!;
// Shift all points so min = strokeWidth/2 (padding for stroke)
const normalized: number[] = [];
for (let i = 0; i < pts.length; i += 2) {
normalized.push(pts[i] - minX + sw / 2);
normalized.push(pts[i + 1] - minY + sw / 2);
}
// Update origin to account for the shift
item.data.x = originX + minX - sw / 2;
item.data.y = originY + minY - sw / 2;
item.data.w = Math.max(maxX - minX + sw, 1);
item.data.h = Math.max(maxY - minY + sw, 1);
(item.data as DrawingObject).points = normalized;
// Redraw with normalized points and reposition
currentSprite.setPoints(normalized);
item.displayObject.position.set(item.data.x, item.data.y);
}
}
currentSprite = null;
scene._applyZOrder();
ctx.onChange();
};
container.addEventListener('pointerdown', onDown);
container.addEventListener('pointermove', onMove);
container.addEventListener('pointerup', onUp);
return () => {
if (syncTimer) clearTimeout(syncTimer);
container.removeEventListener('pointerdown', onDown);
container.removeEventListener('pointermove', onMove);
container.removeEventListener('pointerup', onUp);
};
}
default:
return null;
}
}
export const toolShortcuts: Record<string, ToolType> = {
v: ToolType.SELECT,
h: ToolType.PAN,
p: ToolType.PEN,
t: ToolType.TEXT,
e: ToolType.ERASER,
'1': ToolType.SELECT,
'2': ToolType.PAN,
'3': ToolType.PEN,
'4': ToolType.TEXT,
'5': ToolType.ERASER,
};