Files
refboard-ayon/frontend/src/canvas/tools.ts
T

460 lines
15 KiB
TypeScript

/**
* Tools — PixiJS version.
*
* Canvas navigation is handled by the viewport (space-drag, middle-drag, etc.)
* and SelectionManager.
* PEN/TEXT/ERASER/STICKY are PixiJS tool modes.
*/
import type { Viewport } from 'pixi-viewport';
import type { SceneManager } from './SceneManager';
import type { SelectionManager } from './SelectionManager';
import { DrawingSprite } from './sprites/DrawingSprite';
import type { DrawingObject } from './scene-format';
import { TextEditor } from './TextEditor';
import { clampTextFontSize } from './textLimits';
import { DEFAULT_STICKY_PRESET, STICKY_FONT_MAP, STICKY_WIDTH_MAP } from './stickyPresets';
export enum ToolType {
SELECT = 'SELECT',
PAN = 'PAN',
PEN = 'PEN',
TEXT = 'TEXT',
ERASER = 'ERASER',
STICKY = 'STICKY',
MARKDOWN = 'MARKDOWN',
}
export interface ToolOptions {
color?: string;
strokeWidth?: number;
}
const defaultOptions: ToolOptions = {
color: '#ffffff',
strokeWidth: 4,
};
// Creation defaults — zoom-aware via screenToWorld()
const DEFAULT_TEXT_SCREEN_FONT = 24;
type CleanupFn = (() => void) | null;
/**
* Compute world-space size from a desired screen-space size.
* Clamps to reasonable bounds to avoid extreme values.
*/
function screenToWorld(screenPx: number, zoom: number, min: number, max: number): number {
return Math.min(max, Math.max(min, screenPx / zoom));
}
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;
/** When true, destructive/creative tool clicks (TEXT, ERASER) are suppressed. */
reviewMode?: boolean;
/** Called after a new markdown card is created, to immediately enter edit mode. */
onMarkdownCreated?: (id: 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) => {
if (ctx.reviewMode) return; // Review mode suppresses text creation
const rect = container.getBoundingClientRect();
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
const zoom = viewport.scale.x;
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: clampTextFontSize(screenToWorld(DEFAULT_TEXT_SCREEN_FONT, zoom, 6, 160)),
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;
// Wait one frame so the click event settles before focusing textarea
requestAnimationFrame(() => {
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();
});
}
// 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) => {
if (ctx.reviewMode) return; // Review mode suppresses eraser
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);
};
}
case ToolType.STICKY: {
container.style.cursor = 'crosshair';
const onClick = (e: PointerEvent) => {
if (ctx.reviewMode) return;
const rect = container.getBoundingClientRect();
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
const cardW = STICKY_WIDTH_MAP[DEFAULT_STICKY_PRESET];
const cardH = 60; // auto-computed by StickySprite
const stickyData = {
id: crypto.randomUUID(),
type: 'sticky' as const,
x: world.x - cardW / 2,
y: world.y - cardH / 2,
w: cardW,
h: cardH,
sx: 1,
sy: 1,
angle: 0,
z: scene.nextZ(),
opacity: 1,
locked: false,
name: '',
visible: true,
text: '',
fontSize: STICKY_FONT_MAP[DEFAULT_STICKY_PRESET],
fontFamily: 'Inter, system-ui, sans-serif',
fill: '#ffd43b', // default yellow
textColor: '#1a1a1a',
padding: 16,
cornerRadius: 8,
};
scene._createItem(stickyData, true);
scene._applyZOrder();
ctx.broadcastElements?.([stickyData.id]);
ctx.onChange();
// Immediately open text editor on the new sticky
const item = scene.getById(stickyData.id);
if (item && ctx.textEditor) {
const canvasEl = container.querySelector('canvas');
const domContainer = canvasEl?.parentElement ?? container;
// Wait one frame so the click event settles before focusing textarea
requestAnimationFrame(() => {
ctx.textEditor!.startEditing(item, viewport, domContainer, () => {
// Cleanup rule — fires on BOTH save and cancel (stopEditing always calls _onChange):
// - new sticky + cancel/blur with empty text => remove (no blank notes left behind)
// - new sticky + save with empty text => remove
// - existing sticky + cancel => text already restored to original by stopEditing, no removal
const text = (item.data as any).text?.trim();
if (!text) {
scene.removeItem(item.id, true);
}
ctx.broadcastElements?.([item.id]);
ctx.onChange();
}, (resizedItem) => {
ctx.scene.updateSpatialEntry(resizedItem);
});
ctx.textEditor!.clearText();
});
}
ctx.switchToSelect?.();
container.removeEventListener('pointerdown', onClick);
};
container.addEventListener('pointerdown', onClick);
return () => {
container.removeEventListener('pointerdown', onClick);
};
}
case ToolType.MARKDOWN: {
container.style.cursor = 'crosshair';
const onClick = (e: PointerEvent) => {
if (ctx.reviewMode) return;
const rect = container.getBoundingClientRect();
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
const mdData = {
id: crypto.randomUUID(),
type: 'markdown' as const,
x: world.x - 225,
y: world.y - 20,
w: 450,
h: 60,
sx: 1,
sy: 1,
angle: 0,
z: scene.nextZ(),
opacity: 1,
locked: false,
name: '',
visible: true,
content: '',
bgColor: '#232336',
textColor: '#e0e0e0',
accentColor: '#7950f2',
padding: 16,
cornerRadius: 10,
};
scene._createItem(mdData, true);
scene._applyZOrder();
ctx.broadcastElements?.([mdData.id]);
ctx.onChange();
ctx.switchToSelect?.();
ctx.onMarkdownCreated?.(mdData.id);
container.removeEventListener('pointerdown', onClick);
};
container.addEventListener('pointerdown', onClick);
return () => {
container.removeEventListener('pointerdown', onClick);
};
}
default:
return null;
}
}
export const toolShortcuts: Record<string, ToolType> = {
v: ToolType.SELECT,
p: ToolType.PEN,
t: ToolType.TEXT,
s: ToolType.STICKY,
m: ToolType.MARKDOWN,
'1': ToolType.SELECT,
'3': ToolType.PEN,
'4': ToolType.TEXT,
'5': ToolType.STICKY,
'6': ToolType.MARKDOWN,
};