Files
refboard-ayon/frontend/src/canvas/tools.ts
T
Hiren Kangad 01f22c9d43 fix: address code review findings from crop/text commit
- CropOverlay: register move/up handlers dynamically on drag start (single
  handler instead of 8x per-handle), remove on drag end. Override destroy()
  to call _cleanup() preventing keyboard listener leaks.
- Text format toolbar: re-measure text bounds (w/h) after fontSize/fontFamily
  changes, update spatial index and transform box.
- TextEditor: add clearText() method; tools.ts uses it instead of fragile
  document.querySelector('textarea').
- SceneManager._updateItem: apply crop mask on remote sync for image items.
- useCanvasSetup: stop TextEditor on unmount to prevent orphaned textarea.
- Double-click zoom: use item.data dimensions instead of getBounds() (which
  includes shadow offset).
2026-03-11 17:28:52 +05:30

318 lines
9.8 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';
import { TextEditor } from './TextEditor';
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;
/** Text editor instance for inline editing. */
textEditor?: TextEditor;
/** Switch back to select tool after placing text. */
switchToSelect?: () => 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: ' ', // placeholder — will be replaced by user input
fontSize: opts.fontSize!,
fill: opts.color!,
fontFamily: 'sans-serif',
};
scene._createItem(textData, true);
scene._applyZOrder();
ctx.broadcastElements?.([textData.id]);
ctx.onChange();
// Immediately open inline editor on the new text item
const item = scene.getById(textData.id);
if (item && ctx.textEditor) {
// Find the canvas DOM container
const canvasEl = container.querySelector('canvas');
const domContainer = canvasEl?.parentElement ?? container;
// Small delay to let PixiJS render the text sprite
setTimeout(() => {
ctx.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);
}
ctx.broadcastElements?.([item.id]);
ctx.onChange();
});
// Clear placeholder so user types from scratch
ctx.textEditor!.clearText();
}, 50);
}
// Switch back to select tool after placing
ctx.switchToSelect?.();
// 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,
'1': ToolType.SELECT,
'2': ToolType.PAN,
'3': ToolType.PEN,
'4': ToolType.TEXT,
};