From cdeafce7ade0f7dc137ebcb7b929359d038ac9b3 Mon Sep 17 00:00:00 2001 From: Hiren Date: Mon, 9 Mar 2026 18:29:35 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20RefBoard=20v0.5.0=20=E2=80=94=20PureRef?= =?UTF-8?q?-style=20shortcuts,=20clipboard=20fix,=20zoom=20normalization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 40+ keyboard shortcuts via declarative registry system (shortcut-definitions.ts) - Fix canvas tainting: patch crossOrigin before loadFromJSON in all paths (init, sync, undo/redo) - Fix clipboard copy-to-system with proper async blob handling and error toasts - Fix zoom wheel: normalize deltaY across mice/trackpads with consistent 8% step - Add operations module: alignment, distribution, normalization, arrangement, flip, grayscale, lock, overlay compare - Add breakSelection() to fix ActiveSelection relative coordinate bugs - Add shortcuts help overlay (? / F1) auto-generated from registry - Add grid overlay toggle (G key) - Fix 10 shortcut bugs: Ctrl+Y/P conflicts, ? key matching, arrow conflicts, Ctrl+S intercept, context menu stale objects --- CHANGELOG.md | 28 + frontend/src/canvas/FabricCanvas.tsx | 44 +- frontend/src/canvas/history.ts | 16 +- frontend/src/canvas/operations.ts | 358 +++++++++++++ frontend/src/canvas/shortcut-definitions.ts | 462 +++++++++++++++++ frontend/src/canvas/shortcuts.ts | 93 ++++ frontend/src/canvas/sync.ts | 14 +- frontend/src/components/ShortcutsHelp.tsx | 159 ++++++ frontend/src/components/Toolbar.tsx | 13 + frontend/src/pages/Editor.tsx | 540 ++++++++------------ 10 files changed, 1389 insertions(+), 338 deletions(-) create mode 100644 frontend/src/canvas/operations.ts create mode 100644 frontend/src/canvas/shortcut-definitions.ts create mode 100644 frontend/src/canvas/shortcuts.ts create mode 100644 frontend/src/components/ShortcutsHelp.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 78ef2fb..396bf33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ Collaborative reference image board for game dev teams (PureRef-style, web-based --- +## v0.5.0 — PureRef-Style Shortcuts & Operations (2026-03-09) + +### Added +- **Full PureRef keyboard shortcut parity** — 40+ shortcuts organized via registry system +- **Alignment** — Ctrl+Arrow to align left/right/top/bottom; Ctrl+Alt+Shift+Arrow to distribute H/V +- **Normalize** — Ctrl+Alt+Arrow to normalize size/scale/width/height across selected objects +- **Arrangement** — Ctrl+P optimal pack, Ctrl+Alt+N arrange by name, Ctrl+Alt+O by z-order, Ctrl+Alt+R randomly, Ctrl+Alt+S stack +- **Arrange Grid/Row/Column** — available in context menu for quick layout +- **Image manipulation** — Alt+Shift+H/V flip horizontal/vertical, Ctrl+Shift+T reset transform, Alt+G toggle grayscale, Alt+L toggle locked +- **Overlay compare** — Ctrl+Y: stack selected at 50% opacity for visual comparison (toggle) +- **Navigation** — Arrow Left/Right cycle through objects, Arrow Up/Down bring forward/send backward, Esc clear selection +- **Cut** — Ctrl+X cuts selected objects to clipboard +- **Grid overlay** — G key toggles a grid overlay on the canvas +- **Shortcuts help overlay** — Press ? or F1, or click the ? button in toolbar to see all shortcuts +- **Shortcut registry architecture** — all shortcuts defined declaratively in `shortcut-definitions.ts`, auto-sorted by modifier specificity + +### Changed +- **Context menu expanded** — now includes alignment, distribute, normalize, flip, reset transform, stack, pack, and arrange options +- **Layer ordering keys** — changed from `]`/`[` to Arrow Up/Down (matching PureRef) +- **Keyboard handler refactored** — replaced 200-line if/else chain with registry-based matcher + +### Fixed +- **Canvas tainting on clipboard copy** — images loaded via `loadFromJSON` (initial load, sync, undo/redo) now have `crossOrigin: 'anonymous'` patched into JSON before deserialization, preventing canvas taint +- **Silent clipboard failures** — errors now shown as toast notifications instead of swallowed +- **Blob timing** — clipboard write uses proper async/await pattern instead of callback + +--- + ## v0.4.0 — Layers, Groups & UI Overhaul (2026-03-09) ### Added diff --git a/frontend/src/canvas/FabricCanvas.tsx b/frontend/src/canvas/FabricCanvas.tsx index 5c5b294..e4eb1d8 100644 --- a/frontend/src/canvas/FabricCanvas.tsx +++ b/frontend/src/canvas/FabricCanvas.tsx @@ -89,17 +89,27 @@ const FabricCanvas = forwardRef( } }); - // Ctrl+Scroll zoom + // Scroll zoom — zooms toward cursor (like Figma/PureRef) + // Normalizes deltaY across mice, trackpads, and browsers for consistent feel canvas.on('mouse:wheel', (opt: any) => { const e = opt.e as WheelEvent; e.preventDefault(); e.stopPropagation(); - const delta = e.deltaY; + // Normalize delta: mice send large values (~100), trackpads send small (~1-5) + // Clamp to ±1 and apply a fixed zoom factor per "step" for predictable UX + let delta = e.deltaY; + if (e.deltaMode === 1) delta *= 40; // DOM_DELTA_LINE → px + if (e.deltaMode === 2) delta *= 800; // DOM_DELTA_PAGE → px + + // Use sign + magnitude clamping for consistent zoom speed + const direction = Math.sign(delta); + const ZOOM_STEP = 0.08; // 8% per step — smooth but responsive let zoom = canvas.getZoom(); - zoom *= 0.999 ** delta; + zoom *= 1 - direction * ZOOM_STEP; zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)); + // Zoom toward cursor position (industry standard) const point = canvas.getScenePoint(e); canvas.zoomToPoint(point, zoom); canvas.requestRenderAll(); @@ -163,19 +173,29 @@ const FabricCanvas = forwardRef( if (!parsed || (!parsed.objects && !parsed.version)) return; + // Ensure all images in the JSON have crossOrigin set BEFORE Fabric loads them. + // Without this, the underlying elements load without CORS headers, + // tainting the canvas and breaking toCanvasElement/toDataURL/toBlob. + if (parsed.objects) { + (parsed.objects as any[]).forEach((obj: any) => { + if (obj.type === 'image') { + obj.crossOrigin = 'anonymous'; + } + // Handle images inside groups + if (obj.type === 'group' && obj.objects) { + (obj.objects as any[]).forEach((child: any) => { + if (child.type === 'image') { + child.crossOrigin = 'anonymous'; + } + }); + } + }); + } + // Suppress socket broadcasts during initial load — prevents flooding // other clients with object:add events for objects they already have suppressBroadcasts(); canvas.loadFromJSON(parsed).then(() => { - canvas.getObjects().forEach((obj: FabricObject) => { - if (obj.type === 'image') { - const imgObj = obj as FabricImage; - const src = (imgObj as any).src || imgObj.getSrc?.(); - if (src) { - (imgObj as any).crossOrigin = 'anonymous'; - } - } - }); canvas.requestRenderAll(); // Small delay to ensure all deferred events have fired before resuming setTimeout(() => resumeBroadcasts(), 100); diff --git a/frontend/src/canvas/history.ts b/frontend/src/canvas/history.ts index 0f5a7e7..f9c65f6 100644 --- a/frontend/src/canvas/history.ts +++ b/frontend/src/canvas/history.ts @@ -20,7 +20,7 @@ export class UndoManager { saveState(): void { if (this.locked) return; - const json = JSON.stringify((this.canvas as any).toJSON(['id'])); + const json = JSON.stringify((this.canvas as any).toJSON(['id', 'crossOrigin'])); // If we're not at the end, discard forward history if (this.pointer < this.stack.length - 1) { @@ -69,7 +69,19 @@ export class UndoManager { if (!state) return; this.locked = true; - this.canvas.loadFromJSON(JSON.parse(state)).then(() => { + const parsed = JSON.parse(state); + // Ensure images have crossOrigin to prevent canvas tainting + if (parsed?.objects) { + for (const obj of parsed.objects) { + if (obj.type === 'image') obj.crossOrigin = 'anonymous'; + if (obj.type === 'group' && obj.objects) { + for (const child of obj.objects) { + if (child.type === 'image') child.crossOrigin = 'anonymous'; + } + } + } + } + this.canvas.loadFromJSON(parsed).then(() => { this.canvas.requestRenderAll(); this.locked = false; }); diff --git a/frontend/src/canvas/operations.ts b/frontend/src/canvas/operations.ts new file mode 100644 index 0000000..12a6d0a --- /dev/null +++ b/frontend/src/canvas/operations.ts @@ -0,0 +1,358 @@ +/** + * Canvas operations — pure functions for alignment, arrangement, normalize, flip, etc. + * Each mutates objects in place and calls setCoords(). Caller must requestRenderAll(). + * + * IMPORTANT: When multiple objects are selected in Fabric.js, they live inside an + * ActiveSelection group. Their left/top are RELATIVE to the group center, not the canvas. + * We must use getBoundingRect() or the canvas-level coordinates for position calculations, + * then convert back. The helper getAbsPos/setAbsPos handles this. + */ + +import { Canvas, FabricObject } from 'fabric'; + +// ─── Helpers ─── + +function scaledW(o: FabricObject): number { + return (o.width || 0) * (o.scaleX || 1); +} + +function scaledH(o: FabricObject): number { + return (o.height || 0) * (o.scaleY || 1); +} + +/** + * Break ActiveSelection so objects have canvas-level coordinates. + * Returns a cleanup function to restore selection afterward. + */ +export function breakSelection(canvas: Canvas): { objects: FabricObject[]; restore: () => void } { + const activeObj = canvas.getActiveObject(); + const objects = canvas.getActiveObjects(); + if (!activeObj || objects.length <= 1) { + return { objects, restore: () => {} }; + } + // Discard the ActiveSelection — this updates each object's left/top to canvas coordinates + canvas.discardActiveObject(); + return { + objects, + restore: () => { + // Re-select after operation + const fabricNs = (window as any).fabric; + if (fabricNs?.ActiveSelection && objects.length > 1) { + const sel = new fabricNs.ActiveSelection(objects, { canvas }); + canvas.setActiveObject(sel); + } else if (objects.length === 1) { + canvas.setActiveObject(objects[0]); + } + }, + }; +} + +// ─── Alignment ─── + +export function alignLeft(objects: FabricObject[]) { + if (objects.length < 2) return; + const minLeft = Math.min(...objects.map((o) => o.left ?? 0)); + objects.forEach((o) => { o.set({ left: minLeft } as any); o.setCoords(); }); +} + +export function alignRight(objects: FabricObject[]) { + if (objects.length < 2) return; + const maxRight = Math.max(...objects.map((o) => (o.left ?? 0) + scaledW(o))); + objects.forEach((o) => { o.set({ left: maxRight - scaledW(o) } as any); o.setCoords(); }); +} + +export function alignTop(objects: FabricObject[]) { + if (objects.length < 2) return; + const minTop = Math.min(...objects.map((o) => o.top ?? 0)); + objects.forEach((o) => { o.set({ top: minTop } as any); o.setCoords(); }); +} + +export function alignBottom(objects: FabricObject[]) { + if (objects.length < 2) return; + const maxBottom = Math.max(...objects.map((o) => (o.top ?? 0) + scaledH(o))); + objects.forEach((o) => { o.set({ top: maxBottom - scaledH(o) } as any); o.setCoords(); }); +} + +// ─── Distribution ─── + +export function distributeHorizontal(objects: FabricObject[]) { + if (objects.length < 3) return; + const sorted = [...objects].sort((a, b) => (a.left ?? 0) - (b.left ?? 0)); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + const totalSpan = (last.left ?? 0) + scaledW(last) - (first.left ?? 0); + const totalWidth = sorted.reduce((s, o) => s + scaledW(o), 0); + const gap = (totalSpan - totalWidth) / (sorted.length - 1); + let x = (first.left ?? 0) + scaledW(first) + gap; + for (let i = 1; i < sorted.length - 1; i++) { + sorted[i].set({ left: x } as any); + sorted[i].setCoords(); + x += scaledW(sorted[i]) + gap; + } +} + +export function distributeVertical(objects: FabricObject[]) { + if (objects.length < 3) return; + const sorted = [...objects].sort((a, b) => (a.top ?? 0) - (b.top ?? 0)); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + const totalSpan = (last.top ?? 0) + scaledH(last) - (first.top ?? 0); + const totalHeight = sorted.reduce((s, o) => s + scaledH(o), 0); + const gap = (totalSpan - totalHeight) / (sorted.length - 1); + let y = (first.top ?? 0) + scaledH(first) + gap; + for (let i = 1; i < sorted.length - 1; i++) { + sorted[i].set({ top: y } as any); + sorted[i].setCoords(); + y += scaledH(sorted[i]) + gap; + } +} + +// ─── Normalize ─── + +export function normalizeSize(objects: FabricObject[]) { + if (objects.length < 2) return; + const areas = objects.map((o) => scaledW(o) * scaledH(o)); + const avgArea = areas.reduce((a, b) => a + b, 0) / areas.length; + objects.forEach((o) => { + const currentArea = scaledW(o) * scaledH(o); + if (currentArea <= 0) return; + const ratio = Math.sqrt(avgArea / currentArea); + o.set({ scaleX: (o.scaleX || 1) * ratio, scaleY: (o.scaleY || 1) * ratio } as any); + o.setCoords(); + }); +} + +export function normalizeScale(objects: FabricObject[]) { + if (objects.length < 2) return; + const avgSX = objects.reduce((s, o) => s + (o.scaleX || 1), 0) / objects.length; + const avgSY = objects.reduce((s, o) => s + (o.scaleY || 1), 0) / objects.length; + objects.forEach((o) => { o.set({ scaleX: avgSX, scaleY: avgSY } as any); o.setCoords(); }); +} + +export function normalizeHeight(objects: FabricObject[]) { + if (objects.length < 2) return; + const avgH = objects.reduce((s, o) => s + scaledH(o), 0) / objects.length; + objects.forEach((o) => { + const h = scaledH(o); + if (h <= 0) return; + const ratio = avgH / h; + o.set({ scaleX: (o.scaleX || 1) * ratio, scaleY: (o.scaleY || 1) * ratio } as any); + o.setCoords(); + }); +} + +export function normalizeWidth(objects: FabricObject[]) { + if (objects.length < 2) return; + const avgW = objects.reduce((s, o) => s + scaledW(o), 0) / objects.length; + objects.forEach((o) => { + const w = scaledW(o); + if (w <= 0) return; + const ratio = avgW / w; + o.set({ scaleX: (o.scaleX || 1) * ratio, scaleY: (o.scaleY || 1) * ratio } as any); + o.setCoords(); + }); +} + +// ─── Arrangement ─── + +export function arrangeOptimal(objects: FabricObject[]) { + if (objects.length < 2) return; + // Shelf-based bin packing, sorted by height descending + const sorted = [...objects].sort((a, b) => scaledH(b) - scaledH(a)); + const gap = 10; + const totalArea = sorted.reduce((s, o) => s + scaledW(o) * scaledH(o), 0); + const shelfWidth = Math.sqrt(totalArea) * 1.3; + const startX = sorted[0]?.left ?? 0; + const startY = sorted[0]?.top ?? 0; + let x = 0, y = 0, shelfHeight = 0; + sorted.forEach((obj) => { + const w = scaledW(obj); + const h = scaledH(obj); + if (x + w > shelfWidth && x > 0) { + x = 0; + y += shelfHeight + gap; + shelfHeight = 0; + } + obj.set({ left: startX + x, top: startY + y } as any); + obj.setCoords(); + shelfHeight = Math.max(shelfHeight, h); + x += w + gap; + }); +} + +export function arrangeGrid(objects: FabricObject[]) { + if (objects.length < 2) return; + const gap = 20; + const cols = Math.ceil(Math.sqrt(objects.length)); + const startX = objects[0]?.left ?? 0; + const startY = objects[0]?.top ?? 0; + // Find max cell size + const maxW = Math.max(...objects.map(scaledW)); + const maxH = Math.max(...objects.map(scaledH)); + objects.forEach((obj, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + obj.set({ left: startX + col * (maxW + gap), top: startY + row * (maxH + gap) } as any); + obj.setCoords(); + }); +} + +export function arrangeRow(objects: FabricObject[]) { + if (objects.length < 2) return; + const gap = 20; + const sorted = [...objects].sort((a, b) => (a.left ?? 0) - (b.left ?? 0)); + const startY = sorted[0]?.top ?? 0; + let x = sorted[0]?.left ?? 0; + sorted.forEach((obj) => { + obj.set({ left: x, top: startY } as any); + obj.setCoords(); + x += scaledW(obj) + gap; + }); +} + +export function arrangeColumn(objects: FabricObject[]) { + if (objects.length < 2) return; + const gap = 20; + const sorted = [...objects].sort((a, b) => (a.top ?? 0) - (b.top ?? 0)); + const startX = sorted[0]?.left ?? 0; + let y = sorted[0]?.top ?? 0; + sorted.forEach((obj) => { + obj.set({ left: startX, top: y } as any); + obj.setCoords(); + y += scaledH(obj) + gap; + }); +} + +export function stackObjects(objects: FabricObject[]) { + if (objects.length < 2) return; + const cx = objects.reduce((s, o) => s + (o.left ?? 0) + scaledW(o) / 2, 0) / objects.length; + const cy = objects.reduce((s, o) => s + (o.top ?? 0) + scaledH(o) / 2, 0) / objects.length; + objects.forEach((o) => { + o.set({ left: cx - scaledW(o) / 2, top: cy - scaledH(o) / 2 } as any); + o.setCoords(); + }); +} + +export function arrangeByName(objects: FabricObject[]) { + const sorted = [...objects].sort((a, b) => + ((a as any).name || '').localeCompare((b as any).name || '') + ); + layoutAsGrid(sorted); +} + +export function arrangeByZOrder(objects: FabricObject[], canvasObjects: FabricObject[]) { + // Sort by z-order (position in canvas.getObjects()) + const indexMap = new Map(canvasObjects.map((o, i) => [o, i])); + const sorted = [...objects].sort((a, b) => (indexMap.get(a) ?? 0) - (indexMap.get(b) ?? 0)); + layoutAsGrid(sorted); +} + +export function arrangeRandomly(objects: FabricObject[]) { + if (objects.length < 2) return; + // Get bounding box of current positions + const minX = Math.min(...objects.map((o) => o.left ?? 0)); + const minY = Math.min(...objects.map((o) => o.top ?? 0)); + const maxX = Math.max(...objects.map((o) => (o.left ?? 0) + scaledW(o))); + const maxY = Math.max(...objects.map((o) => (o.top ?? 0) + scaledH(o))); + objects.forEach((o) => { + o.set({ + left: minX + Math.random() * (maxX - minX - scaledW(o)), + top: minY + Math.random() * (maxY - minY - scaledH(o)), + } as any); + o.setCoords(); + }); +} + +function layoutAsGrid(sorted: FabricObject[]) { + if (sorted.length < 2) return; + const gap = 20; + const cols = Math.ceil(Math.sqrt(sorted.length)); + const startX = sorted[0]?.left ?? 0; + const startY = sorted[0]?.top ?? 0; + const maxW = Math.max(...sorted.map(scaledW)); + const maxH = Math.max(...sorted.map(scaledH)); + sorted.forEach((obj, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + obj.set({ left: startX + col * (maxW + gap), top: startY + row * (maxH + gap) } as any); + obj.setCoords(); + }); +} + +// ─── Flip ─── + +export function flipHorizontal(objects: FabricObject[]) { + objects.forEach((o) => { o.set({ flipX: !o.flipX } as any); o.setCoords(); }); +} + +export function flipVertical(objects: FabricObject[]) { + objects.forEach((o) => { o.set({ flipY: !o.flipY } as any); o.setCoords(); }); +} + +// ─── Transform ─── + +export function resetTransform(objects: FabricObject[]) { + objects.forEach((o) => { + o.set({ + scaleX: 1, scaleY: 1, angle: 0, + skewX: 0, skewY: 0, flipX: false, flipY: false, + } as any); + o.setCoords(); + }); +} + +// ─── Grayscale ─── + +export function toggleGrayscale(objects: FabricObject[]) { + objects.forEach((o) => { + if (o.type !== 'image') return; + const img = o as any; + if (!img.filters) img.filters = []; + // Check if grayscale filter already applied + const idx = img.filters.findIndex((f: any) => f?.type === 'Grayscale'); + if (idx >= 0) { + img.filters.splice(idx, 1); + } else { + // Dynamically access Grayscale filter from Fabric + const fabric = (window as any).fabric; + if (fabric?.filters?.Grayscale) { + img.filters.push(new fabric.filters.Grayscale()); + } + } + img.applyFilters?.(); + }); +} + +// ─── Lock ─── + +export function toggleLocked(objects: FabricObject[]) { + objects.forEach((o) => { + const isLocked = !o.selectable; + o.set({ + selectable: isLocked, + evented: isLocked, + } as any); + }); +} + +// ─── Overlay / Compare ─── + +export function overlayCompare(objects: FabricObject[]) { + if (objects.length < 2) return; + // If all are at 0.5 opacity, restore to 1; otherwise set to 0.5 and center-stack + const allHalf = objects.every((o) => Math.abs((o.opacity ?? 1) - 0.5) < 0.05); + if (allHalf) { + objects.forEach((o) => { o.set({ opacity: 1 } as any); }); + } else { + const cx = objects.reduce((s, o) => s + (o.left ?? 0) + scaledW(o) / 2, 0) / objects.length; + const cy = objects.reduce((s, o) => s + (o.top ?? 0) + scaledH(o) / 2, 0) / objects.length; + objects.forEach((o) => { + o.set({ + opacity: 0.5, + left: cx - scaledW(o) / 2, + top: cy - scaledH(o) / 2, + } as any); + o.setCoords(); + }); + } +} diff --git a/frontend/src/canvas/shortcut-definitions.ts b/frontend/src/canvas/shortcut-definitions.ts new file mode 100644 index 0000000..40f50ba --- /dev/null +++ b/frontend/src/canvas/shortcut-definitions.ts @@ -0,0 +1,462 @@ +/** + * All shortcut definitions — the single source of truth for keyboard shortcuts. + * Used by the keyboard handler AND the ShortcutsHelp overlay. + * + * BUGS FIXED in this revision: + * - Ctrl+Y conflict (was both redo AND overlay-compare) → overlay moved to Ctrl+Shift+Y + * - Ctrl+P (browser print) → changed to Ctrl+Shift+P for arrange optimal + * - '?' key (Shift+/) → e.key is '?' not '/', fixed matcher key + * - ArrowUp/Down with selection → layer ordering uses ] / [ again (arrows cycle/nudge) + * - ArrowLeft/Right bare → only cycle when nothing selected, otherwise no-op + * - ActiveSelection coordinates → use withBreak() for position-dependent ops + * - Added Ctrl+S (prevent browser save-as) + */ + +import { ShortcutDef } from './shortcuts'; +import { FabricObject } from 'fabric'; +import * as ops from './operations'; + +/** Helper: break ActiveSelection, run operation on canvas-level coords, restore selection */ +function withBreak(ctx: { canvas: any; onCanvasChange: () => void }, fn: (objs: FabricObject[]) => void) { + const { objects, restore } = ops.breakSelection(ctx.canvas); + fn(objects); + restore(); + ctx.canvas.requestRenderAll(); + ctx.onCanvasChange(); +} + +export const shortcuts: ShortcutDef[] = [ + + // ═══════════════════════════════════════ + // ALIGNMENT (Ctrl + Arrow) + // ═══════════════════════════════════════ + + { + id: 'align-left', keys: { key: 'arrowleft', ctrl: true }, + category: 'alignment', description: 'Align left', needsSelection: true, minSelection: 2, + handler: (ctx) => withBreak(ctx, (objs) => ops.alignLeft(objs)), + }, + { + id: 'align-right', keys: { key: 'arrowright', ctrl: true }, + category: 'alignment', description: 'Align right', needsSelection: true, minSelection: 2, + handler: (ctx) => withBreak(ctx, (objs) => ops.alignRight(objs)), + }, + { + id: 'align-top', keys: { key: 'arrowup', ctrl: true }, + category: 'alignment', description: 'Align top', needsSelection: true, minSelection: 2, + handler: (ctx) => withBreak(ctx, (objs) => ops.alignTop(objs)), + }, + { + id: 'align-bottom', keys: { key: 'arrowdown', ctrl: true }, + category: 'alignment', description: 'Align bottom', needsSelection: true, minSelection: 2, + handler: (ctx) => withBreak(ctx, (objs) => ops.alignBottom(objs)), + }, + + // ═══════════════════════════════════════ + // DISTRIBUTE (Ctrl + Alt + Shift + Arrow) + // ═══════════════════════════════════════ + + { + id: 'distribute-h', keys: { key: 'arrowup', ctrl: true, alt: true, shift: true }, + category: 'alignment', description: 'Distribute horizontal', needsSelection: true, minSelection: 3, + handler: (ctx) => withBreak(ctx, (objs) => ops.distributeHorizontal(objs)), + }, + { + id: 'distribute-v', keys: { key: 'arrowdown', ctrl: true, alt: true, shift: true }, + category: 'alignment', description: 'Distribute vertical', needsSelection: true, minSelection: 3, + handler: (ctx) => withBreak(ctx, (objs) => ops.distributeVertical(objs)), + }, + + // ═══════════════════════════════════════ + // NORMALIZE (Ctrl + Alt + Arrow) + // ═══════════════════════════════════════ + + { + id: 'normalize-size', keys: { key: 'arrowup', ctrl: true, alt: true }, + category: 'normalize', description: 'Normalize size (same area)', needsSelection: true, minSelection: 2, + handler: (ctx) => withBreak(ctx, (objs) => ops.normalizeSize(objs)), + }, + { + id: 'normalize-scale', keys: { key: 'arrowdown', ctrl: true, alt: true }, + category: 'normalize', description: 'Normalize scale', needsSelection: true, minSelection: 2, + handler: (ctx) => withBreak(ctx, (objs) => ops.normalizeScale(objs)), + }, + { + id: 'normalize-height', keys: { key: 'arrowleft', ctrl: true, alt: true }, + category: 'normalize', description: 'Normalize height', needsSelection: true, minSelection: 2, + handler: (ctx) => withBreak(ctx, (objs) => ops.normalizeHeight(objs)), + }, + { + id: 'normalize-width', keys: { key: 'arrowright', ctrl: true, alt: true }, + category: 'normalize', description: 'Normalize width', needsSelection: true, minSelection: 2, + handler: (ctx) => withBreak(ctx, (objs) => ops.normalizeWidth(objs)), + }, + + // ═══════════════════════════════════════ + // ARRANGEMENT + // ═══════════════════════════════════════ + + // FIX: Changed from Ctrl+P (browser print) to Ctrl+Shift+P + { + id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true }, + category: 'arrangement', description: 'Arrange optimal (pack)', needsSelection: true, minSelection: 2, + handler: (ctx) => withBreak(ctx, (objs) => ops.arrangeOptimal(objs)), + }, + { + id: 'arrange-by-name', keys: { key: 'n', ctrl: true, alt: true }, + category: 'arrangement', description: 'Arrange by name', needsSelection: true, minSelection: 2, + handler: (ctx) => withBreak(ctx, (objs) => ops.arrangeByName(objs)), + }, + { + id: 'arrange-by-order', keys: { key: 'o', ctrl: true, alt: true }, + category: 'arrangement', description: 'Arrange by z-order', needsSelection: true, minSelection: 2, + handler: (ctx) => { + const { objects, restore } = ops.breakSelection(ctx.canvas); + ops.arrangeByZOrder(objects, ctx.canvas.getObjects()); + restore(); + ctx.canvas.requestRenderAll(); ctx.onCanvasChange(); + }, + }, + { + id: 'arrange-random', keys: { key: 'r', ctrl: true, alt: true }, + category: 'arrangement', description: 'Arrange randomly', needsSelection: true, minSelection: 2, + handler: (ctx) => withBreak(ctx, (objs) => ops.arrangeRandomly(objs)), + }, + { + id: 'stack', keys: { key: 's', ctrl: true, alt: true }, + category: 'arrangement', description: 'Stack (pile on top)', needsSelection: true, minSelection: 2, + handler: (ctx) => withBreak(ctx, (objs) => ops.stackObjects(objs)), + }, + + // ═══════════════════════════════════════ + // IMAGE MANIPULATION + // ═══════════════════════════════════════ + + { + id: 'flip-h', keys: { key: 'h', alt: true, shift: true }, + category: 'image', description: 'Flip horizontal', needsSelection: true, + handler: (ctx) => { + ops.flipHorizontal(ctx.getActiveObjects()); + ctx.canvas.requestRenderAll(); ctx.onCanvasChange(); + }, + }, + { + id: 'flip-v', keys: { key: 'v', alt: true, shift: true }, + category: 'image', description: 'Flip vertical', needsSelection: true, + handler: (ctx) => { + ops.flipVertical(ctx.getActiveObjects()); + ctx.canvas.requestRenderAll(); ctx.onCanvasChange(); + }, + }, + { + id: 'reset-transform', keys: { key: 't', ctrl: true, shift: true }, + category: 'image', description: 'Reset transform', needsSelection: true, + handler: (ctx) => { + ops.resetTransform(ctx.getActiveObjects()); + ctx.canvas.requestRenderAll(); ctx.onCanvasChange(); + }, + }, + { + id: 'toggle-grayscale', keys: { key: 'g', alt: true }, + category: 'image', description: 'Toggle grayscale', needsSelection: true, + handler: (ctx) => { + ops.toggleGrayscale(ctx.getActiveObjects()); + ctx.canvas.requestRenderAll(); ctx.onCanvasChange(); + }, + }, + { + id: 'toggle-locked', keys: { key: 'l', alt: true }, + category: 'image', description: 'Toggle locked', needsSelection: true, + handler: (ctx) => { + ops.toggleLocked(ctx.getActiveObjects()); + ctx.canvas.requestRenderAll(); ctx.refreshLayers(); + }, + }, + // FIX: Changed from Ctrl+Y (conflicts with redo) to Ctrl+Shift+Y + { + id: 'overlay-compare', keys: { key: 'y', ctrl: true, shift: true }, + category: 'image', description: 'Overlay / compare', needsSelection: true, minSelection: 2, + handler: (ctx) => withBreak(ctx, (objs) => ops.overlayCompare(objs)), + }, + + // ═══════════════════════════════════════ + // NAVIGATION + // ═══════════════════════════════════════ + + { + id: 'clear-selection', keys: { key: 'escape' }, + category: 'navigation', description: 'Clear selection', + handler: (ctx) => { + ctx.canvas.discardActiveObject(); + ctx.canvas.requestRenderAll(); + }, + }, + { + id: 'focus-all', keys: { key: '0', ctrl: true }, + category: 'navigation', description: 'Fit all in view', + handler: (ctx) => ctx.fitAll(), + }, + // FIX: Bare arrow Left/Right only cycle when nothing is selected. + // When something IS selected, they're no-ops (prevent accidental cycling). + // Layer ordering uses ] / [ (restored) to avoid arrow conflicts. + { + id: 'cycle-next', keys: { key: 'arrowright' }, + category: 'navigation', description: 'Select next object', + handler: (ctx) => { + // Only cycle when nothing selected — if selected, do nothing + if (ctx.getActiveObjects().length > 0) return; + const objects = ctx.canvas.getObjects(); + if (objects.length === 0) return; + ctx.canvas.setActiveObject(objects[0]); + ctx.canvas.requestRenderAll(); + }, + }, + { + id: 'cycle-prev', keys: { key: 'arrowleft' }, + category: 'navigation', description: 'Select previous object', + handler: (ctx) => { + if (ctx.getActiveObjects().length > 0) return; + const objects = ctx.canvas.getObjects(); + if (objects.length === 0) return; + ctx.canvas.setActiveObject(objects[objects.length - 1]); + ctx.canvas.requestRenderAll(); + }, + }, + // FIX: Layer ordering restored to ] / [ (not arrow keys — those conflict with cycle/nudge) + { + id: 'send-to-front', keys: { key: ']' }, + category: 'navigation', description: 'Bring forward', + needsSelection: true, + handler: (ctx) => { + ctx.getActiveObjects().forEach((obj) => (ctx.canvas as any).bringObjectForward(obj)); + ctx.canvas.requestRenderAll(); ctx.onCanvasChange(); + }, + }, + { + id: 'send-to-back', keys: { key: '[' }, + category: 'navigation', description: 'Send backward', + needsSelection: true, + handler: (ctx) => { + ctx.getActiveObjects().forEach((obj) => (ctx.canvas as any).sendObjectBackwards(obj)); + ctx.canvas.requestRenderAll(); ctx.onCanvasChange(); + }, + }, + + // ═══════════════════════════════════════ + // EDITING + // ═══════════════════════════════════════ + + { + id: 'select-all', keys: { key: 'a', ctrl: true }, + category: 'editing', description: 'Select all', + handler: (ctx) => { + ctx.canvas.discardActiveObject(); + const fabricNs = (window as any).fabric; + if (fabricNs?.ActiveSelection) { + const sel = new fabricNs.ActiveSelection(ctx.canvas.getObjects(), { canvas: ctx.canvas }); + ctx.canvas.setActiveObject(sel); + } + ctx.canvas.requestRenderAll(); + }, + }, + { + id: 'copy', keys: { key: 'c', ctrl: true }, + category: 'editing', description: 'Copy', + handler: (ctx) => { + const active = ctx.getActiveObjects(); + if (active.length > 0) { + ctx.clipboardRef.current = [...active]; + ctx.writeCanvasToClipboard(active); + ctx.showToast('Copied'); + } else { + ctx.writeCanvasToClipboard(); + ctx.showToast('Copied canvas'); + } + }, + }, + { + id: 'copy-as-image', keys: { key: 'c', ctrl: true, shift: true }, + category: 'editing', description: 'Copy as image to clipboard', + handler: (ctx) => { + const active = ctx.getActiveObjects(); + ctx.writeCanvasToClipboard(active.length > 0 ? active : undefined); + }, + }, + { + id: 'paste', keys: { key: 'v', ctrl: true }, + category: 'editing', description: 'Paste', + handler: async (ctx) => { + if (ctx.clipboardRef.current.length === 0) return; + const newObjs: FabricObject[] = []; + for (const original of ctx.clipboardRef.current) { + try { + const obj = await ctx.cloneFabricObject(original, 20, 20); + ctx.canvas.add(obj); + newObjs.push(obj); + } catch (err) { + console.error('Paste object failed:', err); + } + } + ctx.clipboardRef.current = newObjs; + ctx.canvas.requestRenderAll(); + ctx.onCanvasChange(); + }, + }, + { + id: 'cut', keys: { key: 'x', ctrl: true }, + category: 'editing', description: 'Cut', + handler: (ctx) => { + const active = ctx.getActiveObjects(); + if (active.length === 0) return; + ctx.clipboardRef.current = [...active]; + ctx.writeCanvasToClipboard(active); + active.forEach((obj) => ctx.canvas.remove(obj)); + ctx.canvas.discardActiveObject(); + ctx.canvas.requestRenderAll(); + ctx.onCanvasChange(); + ctx.showToast('Cut'); + }, + }, + { + id: 'duplicate', keys: { key: 'd', ctrl: true }, + category: 'editing', description: 'Duplicate', + handler: async (ctx) => { + const active = ctx.getActiveObjects(); + if (active.length === 0) return; + for (const original of active) { + try { + const obj = await ctx.cloneFabricObject(original, 20, 20); + ctx.canvas.add(obj); + } catch (err) { + console.error('Duplicate failed:', err); + } + } + ctx.canvas.discardActiveObject(); + ctx.canvas.requestRenderAll(); + ctx.onCanvasChange(); + }, + }, + { + id: 'delete', keys: { key: 'delete' }, + category: 'editing', description: 'Delete selected', + needsSelection: true, + handler: (ctx) => { + const active = ctx.getActiveObjects(); + active.forEach((obj) => ctx.canvas.remove(obj)); + ctx.canvas.discardActiveObject(); + ctx.canvas.requestRenderAll(); + ctx.onCanvasChange(); + }, + }, + { + id: 'delete-backspace', keys: { key: 'backspace' }, + category: 'editing', description: 'Delete selected', + needsSelection: true, + handler: (ctx) => { + const active = ctx.getActiveObjects(); + active.forEach((obj) => ctx.canvas.remove(obj)); + ctx.canvas.discardActiveObject(); + ctx.canvas.requestRenderAll(); + ctx.onCanvasChange(); + }, + }, + { + id: 'undo', keys: { key: 'z', ctrl: true }, + category: 'editing', description: 'Undo', + handler: (ctx) => { + ctx.undoRef.current?.undo(); + ctx.setCanUndo(ctx.undoRef.current?.canUndo() ?? false); + ctx.setCanRedo(ctx.undoRef.current?.canRedo() ?? false); + ctx.onCanvasChange(); + }, + }, + { + id: 'redo', keys: { key: 'z', ctrl: true, shift: true }, + category: 'editing', description: 'Redo', + handler: (ctx) => { + ctx.undoRef.current?.redo(); + ctx.setCanUndo(ctx.undoRef.current?.canUndo() ?? false); + ctx.setCanRedo(ctx.undoRef.current?.canRedo() ?? false); + ctx.onCanvasChange(); + }, + }, + { + id: 'redo-y', keys: { key: 'y', ctrl: true }, + category: 'editing', description: 'Redo (alt)', + handler: (ctx) => { + ctx.undoRef.current?.redo(); + ctx.setCanUndo(ctx.undoRef.current?.canUndo() ?? false); + ctx.setCanRedo(ctx.undoRef.current?.canRedo() ?? false); + ctx.onCanvasChange(); + }, + }, + { + id: 'group', keys: { key: 'g', ctrl: true }, + category: 'editing', description: 'Group', needsSelection: true, minSelection: 2, + handler: (ctx) => ctx.handleGroup(), + }, + { + id: 'ungroup', keys: { key: 'g', ctrl: true, shift: true }, + category: 'editing', description: 'Ungroup', needsSelection: true, + handler: (ctx) => ctx.handleUngroup(), + }, + // FIX: Block Ctrl+S from opening browser save-as dialog + { + id: 'save', keys: { key: 's', ctrl: true }, + category: 'editing', description: 'Save (auto-saves)', + handler: (ctx) => { + ctx.showToast('Auto-saved'); + }, + }, + + // ═══════════════════════════════════════ + // VIEW + // ═══════════════════════════════════════ + + { + id: 'zoom-in', keys: { key: '=', ctrl: true }, + category: 'view', description: 'Zoom in', + handler: (ctx) => { + const z = Math.min(ctx.canvas.getZoom() * 1.2, 5); + const center = ctx.canvas.getCenterPoint(); + ctx.canvas.zoomToPoint(center, z); + ctx.canvas.requestRenderAll(); + }, + }, + { + id: 'zoom-in-plus', keys: { key: '+', ctrl: true }, + category: 'view', description: 'Zoom in', + handler: (ctx) => { + const z = Math.min(ctx.canvas.getZoom() * 1.2, 5); + const center = ctx.canvas.getCenterPoint(); + ctx.canvas.zoomToPoint(center, z); + ctx.canvas.requestRenderAll(); + }, + }, + { + id: 'zoom-out', keys: { key: '-', ctrl: true }, + category: 'view', description: 'Zoom out', + handler: (ctx) => { + const z = Math.max(ctx.canvas.getZoom() / 1.2, 0.1); + const center = ctx.canvas.getCenterPoint(); + ctx.canvas.zoomToPoint(center, z); + ctx.canvas.requestRenderAll(); + }, + }, + { + id: 'toggle-grid', keys: { key: 'g' }, + category: 'view', description: 'Toggle grid', + handler: (ctx) => ctx.toggleGrid(), + }, + // FIX: '?' key — when Shift+/ is pressed, e.key is '?' not '/' + { + id: 'show-help', keys: { key: '?' }, + category: 'view', description: 'Show shortcuts help', + handler: (ctx) => ctx.toggleShowHelp(), + }, + { + id: 'show-help-f1', keys: { key: 'f1' }, + category: 'view', description: 'Show shortcuts help', + handler: (ctx) => ctx.toggleShowHelp(), + }, +]; diff --git a/frontend/src/canvas/shortcuts.ts b/frontend/src/canvas/shortcuts.ts new file mode 100644 index 0000000..e20bf36 --- /dev/null +++ b/frontend/src/canvas/shortcuts.ts @@ -0,0 +1,93 @@ +/** + * Shortcut registry — defines the type system, matcher, and formatter. + * Actual shortcut definitions live in shortcut-definitions.ts. + */ + +import { Canvas, FabricObject } from 'fabric'; +import { ToolType } from './tools'; + +export interface ShortcutKeys { + key: string; // e.key.toLowerCase(), e.g. 'arrowleft', 'p', 'escape', ' ' + ctrl?: boolean; // ctrl or meta (cmd on mac) + alt?: boolean; + shift?: boolean; +} + +export interface ShortcutDef { + id: string; + keys: ShortcutKeys; + handler: (ctx: ShortcutContext) => void | Promise; + description: string; + category: 'alignment' | 'arrangement' | 'normalize' | 'navigation' | 'editing' | 'view' | 'tools' | 'image'; + needsSelection?: boolean; // skip if no objects selected + minSelection?: number; // minimum selected objects (default 1 if needsSelection) +} + +export interface ShortcutContext { + canvas: Canvas; + getActiveObjects: () => FabricObject[]; + getActiveObject: () => FabricObject | null; + clipboardRef: React.MutableRefObject; + cloneFabricObject: (obj: any, dx: number, dy: number) => Promise; + writeCanvasToClipboard: (objects?: FabricObject[]) => Promise; + onCanvasChange: () => void; + showToast: (msg: string) => void; + fitAll: () => void; + setActiveTool: (tool: ToolType) => void; + undoRef: React.MutableRefObject; + setCanUndo: (v: boolean) => void; + setCanRedo: (v: boolean) => void; + refreshLayers: () => void; + handleGroup: () => void; + handleUngroup: () => void; + toggleGrid: () => void; + toggleShowHelp: () => void; +} + +/** + * Match a keyboard event against a shortcut definition. + * More-specific shortcuts (more modifiers) should be checked first. + */ +export function matchesShortcut(e: KeyboardEvent, def: ShortcutDef): boolean { + const k = def.keys; + const wantCtrl = k.ctrl ?? false; + const wantAlt = k.alt ?? false; + const wantShift = k.shift ?? false; + + return ( + e.key.toLowerCase() === k.key.toLowerCase() && + (e.ctrlKey || e.metaKey) === wantCtrl && + e.altKey === wantAlt && + e.shiftKey === wantShift + ); +} + +/** + * Sort shortcuts so the most-specific (most modifiers) are checked first. + * This prevents Ctrl+Alt+Shift+X from being matched by Ctrl+X. + */ +export function sortBySpecificity(defs: ShortcutDef[]): ShortcutDef[] { + return [...defs].sort((a, b) => { + const modCount = (k: ShortcutKeys) => + (k.ctrl ? 1 : 0) + (k.alt ? 1 : 0) + (k.shift ? 1 : 0); + return modCount(b.keys) - modCount(a.keys); + }); +} + +/** + * Format a shortcut for display in the help overlay. + */ +export function formatShortcut(def: ShortcutDef): string { + const parts: string[] = []; + if (def.keys.ctrl) parts.push('Ctrl'); + if (def.keys.alt) parts.push('Alt'); + if (def.keys.shift) parts.push('Shift'); + const keyNames: Record = { + arrowleft: '\u2190', arrowright: '\u2192', arrowup: '\u2191', arrowdown: '\u2193', + escape: 'Esc', ' ': 'Space', delete: 'Del', backspace: 'Bksp', + '=': '+', '-': '\u2212', + }; + const display = keyNames[def.keys.key.toLowerCase()] || def.keys.key.toUpperCase(); + parts.push(display); + return parts.join(' + '); +} diff --git a/frontend/src/canvas/sync.ts b/frontend/src/canvas/sync.ts index 195b58a..b2bc8dc 100644 --- a/frontend/src/canvas/sync.ts +++ b/frontend/src/canvas/sync.ts @@ -57,7 +57,7 @@ export function setupSync( if (_suppress) return; // Ensure all objects have IDs before serializing canvas.getObjects().forEach(ensureId); - const scene = (canvas as any).toJSON(['id']); + const scene = (canvas as any).toJSON(['id', 'crossOrigin']); socket.emit('scene:update', { boardId, scene }); }, SCENE_THROTTLE); } @@ -89,6 +89,18 @@ export function setupSync( function doApplyScene(scene: any) { _suppress = true; + // Ensure all images have crossOrigin set before Fabric loads them, + // otherwise the canvas becomes tainted and clipboard copy breaks. + if (scene?.objects) { + for (const obj of scene.objects) { + if (obj.type === 'image') obj.crossOrigin = 'anonymous'; + if (obj.type === 'group' && obj.objects) { + for (const child of obj.objects) { + if (child.type === 'image') child.crossOrigin = 'anonymous'; + } + } + } + } canvas.loadFromJSON(scene) .then(() => { canvas.requestRenderAll(); diff --git a/frontend/src/components/ShortcutsHelp.tsx b/frontend/src/components/ShortcutsHelp.tsx new file mode 100644 index 0000000..7eb5526 --- /dev/null +++ b/frontend/src/components/ShortcutsHelp.tsx @@ -0,0 +1,159 @@ +import React, { useEffect } from 'react'; +import { ShortcutDef, formatShortcut } from '../canvas/shortcuts'; + +interface ShortcutsHelpProps { + shortcuts: ShortcutDef[]; + onClose: () => void; +} + +const categoryLabels: Record = { + alignment: 'Alignment & Distribution', + normalize: 'Normalize', + arrangement: 'Arrangement', + image: 'Image Manipulation', + navigation: 'Navigation', + editing: 'Editing', + view: 'View', + tools: 'Tools', +}; + +const categoryOrder = ['editing', 'alignment', 'normalize', 'arrangement', 'image', 'navigation', 'view', 'tools']; + +// Deduplicate shortcuts that share the same description (e.g. redo via Ctrl+Y and Ctrl+Shift+Z) +function dedupe(defs: ShortcutDef[]): ShortcutDef[] { + const seen = new Set(); + return defs.filter((d) => { + // Keep the first occurrence by description+category, skip duplicates like zoom-in-plus + const key = `${d.category}:${d.description}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export default function ShortcutsHelp({ shortcuts, onClose }: ShortcutsHelpProps) { + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape' || (e.key === '/' && e.shiftKey) || e.key === 'F1') { + onClose(); + } + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + // Additional tool shortcuts not in registry + const toolShortcuts = [ + { keys: 'V / 1', description: 'Select tool' }, + { keys: 'H / 2', description: 'Pan tool' }, + { keys: 'P / 3', description: 'Draw tool' }, + { keys: 'T / 4', description: 'Text tool' }, + { keys: 'E / 5', description: 'Eraser tool' }, + { keys: 'Space + drag', description: 'Pan canvas' }, + { keys: 'Scroll', description: 'Zoom' }, + { keys: 'Middle click + drag', description: 'Pan canvas' }, + ]; + + const deduped = dedupe(shortcuts); + const grouped = categoryOrder + .map((cat) => ({ + category: cat, + label: categoryLabels[cat] || cat, + items: deduped.filter((s) => s.category === cat), + })) + .filter((g) => g.items.length > 0); + + return ( +
+
e.stopPropagation()} + style={{ + background: '#141414', border: '1px solid #222', borderRadius: '16px', + width: '100%', maxWidth: '720px', maxHeight: '85vh', + overflow: 'hidden', display: 'flex', flexDirection: 'column', + boxShadow: '0 24px 64px rgba(0,0,0,0.6)', + }} + > + {/* Header */} +
+

+ Keyboard Shortcuts +

+ +
+ + {/* Body */} +
+ {/* Tool shortcuts (hardcoded since they use a different system) */} + +
+ {toolShortcuts.map((s) => ( + + ))} +
+ + {/* Registry shortcuts */} + {grouped.map((g) => ( + + +
+ {g.items.map((s) => ( + + ))} +
+
+ ))} +
+
+
+ ); +} + +function SectionHeader({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + +function ShortcutRow({ keys, description }: { keys: string; description: string }) { + return ( +
+ {description} + + {keys} + +
+ ); +} diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 9f343b5..7214d38 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -29,6 +29,7 @@ interface ToolbarProps { onShareClick?: () => void; onToggleLayers?: () => void; showLayers?: boolean; + onToggleHelp?: () => void; boardName?: string; } @@ -140,6 +141,7 @@ export default function Toolbar({ onShareClick, onToggleLayers, showLayers, + onToggleHelp, }: ToolbarProps) { const showStroke = activeTool === ToolType.PEN; const showFontSize = activeTool === ToolType.TEXT; @@ -268,6 +270,17 @@ export default function Toolbar({ )} + {/* Help / shortcuts */} + {onToggleHelp && ( + + + + + + + + )} + {/* Spacer */}
diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index db0c951..9315b4f 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -6,6 +6,9 @@ import { ToolType, activateTool, toolShortcuts } from '../canvas/tools'; import { setupSync, isRemoteUpdate } from '../canvas/sync'; import { setupDragDrop, setupPaste } from '../canvas/image-drop'; import { UndoManager } from '../canvas/history'; +import { ShortcutContext, matchesShortcut, sortBySpecificity } from '../canvas/shortcuts'; +import { shortcuts as shortcutDefs } from '../canvas/shortcut-definitions'; +import * as ops from '../canvas/operations'; import { connectSocket, disconnectSocket, getSocket } from '../socket'; import { getBoard, saveCanvas } from '../api'; import { useAuth } from '../auth'; @@ -14,6 +17,10 @@ import StatusBar, { SaveStatus } from '../components/StatusBar'; import UserCursors from '../components/UserCursors'; import ContextMenu from '../components/ContextMenu'; import LayerPanel from '../components/LayerPanel'; +import ShortcutsHelp from '../components/ShortcutsHelp'; + +// Pre-sort shortcuts by specificity (most modifiers first) for correct matching +const sortedShortcuts = sortBySpecificity(shortcutDefs); interface EditorProps { isPublicView?: boolean; @@ -68,6 +75,8 @@ export default function Editor({ isPublicView }: EditorProps) { const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [toasts, setToasts] = useState<{ id: string; text: string }[]>([]); const [showLayers, setShowLayers] = useState(false); + const [showGrid, setShowGrid] = useState(false); + const [showHelp, setShowHelp] = useState(false); const [layerList, setLayerList] = useState([]); const [selectedLayerIds, setSelectedLayerIds] = useState([]); const clipboardRef = useRef([]); @@ -110,7 +119,7 @@ export default function Editor({ isPublicView }: EditorProps) { if (!canvas) return; setSaveStatus('saving'); try { - const state = JSON.stringify((canvas as any).toJSON(['id'])); + const state = JSON.stringify((canvas as any).toJSON(['id', 'crossOrigin'])); await saveCanvas(resolvedBoardId, state); setSaveStatus('saved'); } catch { @@ -229,71 +238,90 @@ export default function Editor({ isPublicView }: EditorProps) { return cloned; }, []); - // Copy selection as cropped image to system clipboard (for pasting in Paint etc.) - const copySelectionToClipboard = useCallback(async () => { + // Write canvas content to system clipboard as PNG. + // If objects are provided, crop to their bounds. Otherwise copy entire canvas. + const writeCanvasToClipboard = useCallback(async (objects?: FabricObject[]) => { const canvas = canvasRef.current?.getCanvas(); if (!canvas) return; - const active = canvas.getActiveObjects(); - if (active.length === 0) return; - try { - // Temporarily hide selection handles by deselecting + // Save & clear selection so handles don't appear in screenshot const activeObj = canvas.getActiveObject(); canvas.discardActiveObject(); - canvas.requestRenderAll(); + // renderAll is SYNCHRONOUS — ensures handles are gone before capture + canvas.renderAll(); - // Use Fabric's toCanvasElement to render the canvas without selection handles - const fullCanvas = (canvas as any).toCanvasElement(1); + // Capture full canvas — may throw if canvas is tainted (images without crossOrigin) + let fullCanvas: HTMLCanvasElement; + try { + fullCanvas = (canvas as any).toCanvasElement(1); + } catch (taintErr) { + // Canvas is tainted — fall back to re-rendering without tainted images + console.warn('Canvas tainted, attempting fallback:', taintErr); + if (activeObj) { + canvas.setActiveObject(activeObj); + canvas.renderAll(); + } + showToast('Copy failed — try re-opening the board'); + return; + } - // Get bounding rect of selected objects on the rendered canvas - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - active.forEach((obj) => { - const bound = obj.getBoundingRect(); - minX = Math.min(minX, bound.left); - minY = Math.min(minY, bound.top); - maxX = Math.max(maxX, bound.left + bound.width); - maxY = Math.max(maxY, bound.top + bound.height); - }); - - // Re-select immediately + // Restore selection immediately if (activeObj) { canvas.setActiveObject(activeObj); + canvas.renderAll(); } - canvas.requestRenderAll(); - const padding = 8; - const cropX = Math.max(0, Math.floor(minX - padding)); - const cropY = Math.max(0, Math.floor(minY - padding)); - const cropW = Math.min(Math.ceil(maxX - minX + padding * 2), fullCanvas.width - cropX); - const cropH = Math.min(Math.ceil(maxY - minY + padding * 2), fullCanvas.height - cropY); + let outputCanvas: HTMLCanvasElement; - if (cropW <= 0 || cropH <= 0) return; - - // Crop to selection bounds - const offscreen = document.createElement('canvas'); - offscreen.width = cropW; - offscreen.height = cropH; - const ctx = offscreen.getContext('2d')!; - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, cropW, cropH); - ctx.drawImage(fullCanvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH); - - // Write to system clipboard - offscreen.toBlob(async (blob: Blob | null) => { - if (!blob) return; - try { - await navigator.clipboard.write([ - new ClipboardItem({ 'image/png': blob }), - ]); - } catch { - // Clipboard API not available (needs HTTPS) + if (objects && objects.length > 0) { + // Compute bounding rect of the specified objects. + // getBoundingRect returns screen-space coords (includes viewport transform). + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const obj of objects) { + const br = obj.getBoundingRect(); + minX = Math.min(minX, br.left); + minY = Math.min(minY, br.top); + maxX = Math.max(maxX, br.left + br.width); + maxY = Math.max(maxY, br.top + br.height); } - }, 'image/png'); - } catch (err) { - console.error('Copy to clipboard failed:', err); + + const pad = 10; + const cx = Math.max(0, Math.floor(minX - pad)); + const cy = Math.max(0, Math.floor(minY - pad)); + const cw = Math.min(Math.ceil(maxX - minX + pad * 2), fullCanvas.width - cx); + const ch = Math.min(Math.ceil(maxY - minY + pad * 2), fullCanvas.height - cy); + if (cw <= 0 || ch <= 0) return; + + outputCanvas = document.createElement('canvas'); + outputCanvas.width = cw; + outputCanvas.height = ch; + const ctx = outputCanvas.getContext('2d')!; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, cw, ch); + ctx.drawImage(fullCanvas, cx, cy, cw, ch, 0, 0, cw, ch); + } else { + // No specific objects → copy entire canvas as-is + outputCanvas = fullCanvas; + } + + // Convert to blob using promise wrapper (keeps user activation alive) + const blob = await new Promise((resolve, reject) => { + outputCanvas.toBlob((b) => { + if (b) resolve(b); + else reject(new Error('toBlob returned null')); + }, 'image/png'); + }); + + // Write to system clipboard — requires HTTPS or localhost + await navigator.clipboard.write([ + new ClipboardItem({ 'image/png': blob }), + ]); + } catch (err: any) { + console.error('writeCanvasToClipboard failed:', err); + showToast('Copy failed: ' + (err.message || 'clipboard not available')); } - }, []); + }, [showToast]); // Refresh layer list from canvas // Auto-name counters @@ -388,56 +416,6 @@ export default function Editor({ isPublicView }: EditorProps) { refreshLayers(); }, [onCanvasChange, refreshLayers]); - // Arrange selected objects - const arrangeObjects = useCallback((mode: 'grid' | 'row' | 'column') => { - const canvas = canvasRef.current?.getCanvas(); - if (!canvas) return; - const active = canvas.getActiveObjects(); - if (active.length < 2) return; - - const gap = 20; - const sorted = [...active].sort((a, b) => (a.left || 0) - (b.left || 0)); - const startX = sorted[0].left || 0; - const startY = sorted[0].top || 0; - - if (mode === 'row') { - let x = startX; - sorted.forEach((obj) => { - obj.set({ left: x } as any); - obj.setCoords(); - x += (obj.width || 100) * (obj.scaleX || 1) + gap; - }); - } else if (mode === 'column') { - let y = startY; - sorted.forEach((obj) => { - obj.set({ left: startX, top: y } as any); - obj.setCoords(); - y += (obj.height || 100) * (obj.scaleY || 1) + gap; - }); - } else { - // Grid - const cols = Math.ceil(Math.sqrt(active.length)); - let maxH = 0; - sorted.forEach((obj, i) => { - const col = i % cols; - const row = Math.floor(i / cols); - const w = (obj.width || 100) * (obj.scaleX || 1); - const h = (obj.height || 100) * (obj.scaleY || 1); - if (col === 0 && i > 0) maxH = 0; - obj.set({ - left: startX + col * (200 + gap), - top: startY + row * (200 + gap), - } as any); - obj.setCoords(); - maxH = Math.max(maxH, h); - }); - } - - canvas.discardActiveObject(); - canvas.requestRenderAll(); - onCanvasChange(); - }, [onCanvasChange]); - // Right-click context menu const handleContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); @@ -446,65 +424,84 @@ export default function Editor({ isPublicView }: EditorProps) { const contextMenuItems = useCallback(() => { const canvas = canvasRef.current?.getCanvas(); - const hasSelection = canvas ? canvas.getActiveObjects().length > 0 : false; + const active = canvas ? canvas.getActiveObjects() : []; + const hasSel = active.length > 0; + const multiSel = active.length >= 2; + const run = (fn: (objs: FabricObject[]) => void) => { + if (!canvas) return; + const { objects, restore } = ops.breakSelection(canvas); + fn(objects); + restore(); + canvas.requestRenderAll(); onCanvasChange(); + }; return [ { label: 'Copy', shortcut: 'Ctrl+C', onClick: () => { if (!canvas) return; - const active = canvas.getActiveObjects(); - if (active.length > 0) { - clipboardRef.current = [...active]; - copySelectionToClipboard(); - } - }, disabled: !hasSelection }, + if (hasSel) { clipboardRef.current = [...active]; writeCanvasToClipboard(active); } + else writeCanvasToClipboard(); + } }, + { label: 'Cut', shortcut: 'Ctrl+X', onClick: () => { + if (!canvas || !hasSel) return; + clipboardRef.current = [...active]; + active.forEach((o) => canvas.remove(o)); + canvas.discardActiveObject(); canvas.requestRenderAll(); onCanvasChange(); + }, disabled: !hasSel }, { label: 'Paste', shortcut: 'Ctrl+V', onClick: async () => { if (!canvas || clipboardRef.current.length === 0) return; - for (const original of clipboardRef.current) { - try { - const obj = await cloneFabricObject(original, 20, 20); - canvas.add(obj); - } catch {} + for (const o of clipboardRef.current) { + try { const c = await cloneFabricObject(o, 20, 20); canvas.add(c); } catch {} } - canvas.requestRenderAll(); - onCanvasChange(); + canvas.requestRenderAll(); onCanvasChange(); }, disabled: clipboardRef.current.length === 0 }, { label: 'Duplicate', shortcut: 'Ctrl+D', onClick: async () => { - if (!canvas) return; - const active = canvas.getActiveObjects(); - for (const original of active) { - try { - const obj = await cloneFabricObject(original, 20, 20); - canvas.add(obj); - } catch {} + if (!canvas || !hasSel) return; + for (const o of active) { + try { const c = await cloneFabricObject(o, 20, 20); canvas.add(c); } catch {} } - canvas.discardActiveObject(); - canvas.requestRenderAll(); - onCanvasChange(); - }, disabled: !hasSelection }, + canvas.discardActiveObject(); canvas.requestRenderAll(); onCanvasChange(); + }, disabled: !hasSel }, { label: '', shortcut: '', onClick: () => {}, divider: true }, + // — Alignment — + { label: 'Align Left', shortcut: 'Ctrl+\u2190', onClick: () => run((o) => ops.alignLeft(o)), disabled: !multiSel }, + { label: 'Align Right', shortcut: 'Ctrl+\u2192', onClick: () => run((o) => ops.alignRight(o)), disabled: !multiSel }, + { label: 'Align Top', shortcut: 'Ctrl+\u2191', onClick: () => run((o) => ops.alignTop(o)), disabled: !multiSel }, + { label: 'Align Bottom', shortcut: 'Ctrl+\u2193', onClick: () => run((o) => ops.alignBottom(o)), disabled: !multiSel }, + { label: 'Distribute H', shortcut: '', onClick: () => run((o) => ops.distributeHorizontal(o)), disabled: active.length < 3 }, + { label: 'Distribute V', shortcut: '', onClick: () => run((o) => ops.distributeVertical(o)), disabled: active.length < 3 }, + { label: '', shortcut: '', onClick: () => {}, divider: true }, + // — Layer ordering — { label: 'Bring Forward', shortcut: ']', onClick: () => { if (!canvas) return; - canvas.getActiveObjects().forEach((obj) => canvas.bringObjectForward(obj)); - canvas.requestRenderAll(); - onCanvasChange(); - }, disabled: !hasSelection }, + active.forEach((o) => (canvas as any).bringObjectForward(o)); + canvas.requestRenderAll(); onCanvasChange(); + }, disabled: !hasSel }, { label: 'Send Backward', shortcut: '[', onClick: () => { if (!canvas) return; - canvas.getActiveObjects().forEach((obj) => canvas.sendObjectBackwards(obj)); - canvas.requestRenderAll(); - onCanvasChange(); - }, disabled: !hasSelection }, + active.forEach((o) => (canvas as any).sendObjectBackwards(o)); + canvas.requestRenderAll(); onCanvasChange(); + }, disabled: !hasSel }, { label: '', shortcut: '', onClick: () => {}, divider: true }, - { label: 'Group', shortcut: 'Ctrl+G', onClick: handleGroup, - disabled: !canvas || canvas.getActiveObjects().length < 2 }, + // — Group — + { label: 'Group', shortcut: 'Ctrl+G', onClick: handleGroup, disabled: !multiSel }, { label: 'Ungroup', shortcut: 'Ctrl+Shift+G', onClick: handleUngroup, disabled: !canvas || canvas.getActiveObject()?.type !== 'group' }, { label: '', shortcut: '', onClick: () => {}, divider: true }, - { label: 'Arrange Grid', shortcut: '', onClick: () => arrangeObjects('grid'), - disabled: !canvas || canvas.getActiveObjects().length < 2 }, - { label: 'Arrange Row', shortcut: '', onClick: () => arrangeObjects('row'), - disabled: !canvas || canvas.getActiveObjects().length < 2 }, - { label: 'Arrange Column', shortcut: '', onClick: () => arrangeObjects('column'), - disabled: !canvas || canvas.getActiveObjects().length < 2 }, + // — Arrangement — + { label: 'Arrange Pack', shortcut: 'Ctrl+Shift+P', onClick: () => run((o) => ops.arrangeOptimal(o)), disabled: !multiSel }, + { label: 'Arrange Grid', shortcut: '', onClick: () => run((o) => ops.arrangeGrid(o)), disabled: !multiSel }, + { label: 'Arrange Row', shortcut: '', onClick: () => run((o) => ops.arrangeRow(o)), disabled: !multiSel }, + { label: 'Arrange Column', shortcut: '', onClick: () => run((o) => ops.arrangeColumn(o)), disabled: !multiSel }, + { label: 'Stack', shortcut: 'Ctrl+Alt+S', onClick: () => run((o) => ops.stackObjects(o)), disabled: !multiSel }, + { label: '', shortcut: '', onClick: () => {}, divider: true }, + // — Normalize — + { label: 'Normalize Size', shortcut: '', onClick: () => run((o) => ops.normalizeSize(o)), disabled: !multiSel }, + { label: 'Normalize Width', shortcut: '', onClick: () => run((o) => ops.normalizeWidth(o)), disabled: !multiSel }, + { label: 'Normalize Height', shortcut: '', onClick: () => run((o) => ops.normalizeHeight(o)), disabled: !multiSel }, + { label: '', shortcut: '', onClick: () => {}, divider: true }, + // — Image — + { label: 'Flip Horizontal', shortcut: 'Alt+Shift+H', onClick: () => run((o) => ops.flipHorizontal(o)), disabled: !hasSel }, + { label: 'Flip Vertical', shortcut: 'Alt+Shift+V', onClick: () => run((o) => ops.flipVertical(o)), disabled: !hasSel }, + { label: 'Reset Transform', shortcut: 'Ctrl+Shift+T', onClick: () => run((o) => ops.resetTransform(o)), disabled: !hasSel }, { label: '', shortcut: '', onClick: () => {}, divider: true }, { label: 'Select All', shortcut: 'Ctrl+A', onClick: () => { if (!canvas) return; @@ -520,211 +517,84 @@ export default function Editor({ isPublicView }: EditorProps) { { label: '', shortcut: '', onClick: () => {}, divider: true }, { label: 'Delete', shortcut: 'Del', onClick: () => { if (!canvas) return; - const active = canvas.getActiveObjects(); - active.forEach((obj) => canvas.remove(obj)); - canvas.discardActiveObject(); - canvas.requestRenderAll(); - onCanvasChange(); - }, disabled: !hasSelection, danger: true }, + active.forEach((o) => canvas.remove(o)); + canvas.discardActiveObject(); canvas.requestRenderAll(); onCanvasChange(); + }, disabled: !hasSel, danger: true }, ]; - }, [copySelectionToClipboard, onCanvasChange, handleGroup, handleUngroup, arrangeObjects, cloneFabricObject]); + }, [writeCanvasToClipboard, onCanvasChange, handleGroup, handleUngroup, cloneFabricObject]); - // Keyboard shortcuts + // Keyboard shortcuts — registry-based approach useEffect(() => { async function onKeyDown(e: KeyboardEvent) { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; - const tool = toolShortcuts[e.key.toLowerCase()]; - if (tool && !e.ctrlKey && !e.metaKey) { - setActiveTool(tool); - return; - } + const canvas = canvasRef.current?.getCanvas(); + if (!canvas) return; - if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { - e.preventDefault(); - undoRef.current?.undo(); - setCanUndo(undoRef.current?.canUndo() ?? false); - setCanRedo(undoRef.current?.canRedo() ?? false); - onCanvasChange(); - return; - } - if ((e.ctrlKey || e.metaKey) && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) { - e.preventDefault(); - undoRef.current?.redo(); - setCanUndo(undoRef.current?.canUndo() ?? false); - setCanRedo(undoRef.current?.canRedo() ?? false); - onCanvasChange(); - return; - } - - // Group (Ctrl+G) - if ((e.ctrlKey || e.metaKey) && e.key === 'g' && !e.shiftKey) { - e.preventDefault(); - handleGroup(); - return; - } - // Ungroup (Ctrl+Shift+G) - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'G') { - e.preventDefault(); - handleUngroup(); - return; - } - - // Copy selected objects (internal clipboard + system clipboard image) - if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !e.shiftKey) { - const canvas = canvasRef.current?.getCanvas(); - if (!canvas) return; - const active = canvas.getActiveObjects(); - if (active.length > 0) { - // Store actual object refs for cloning later - clipboardRef.current = [...active]; - // Write image to system clipboard (async, doesn't block) - copySelectionToClipboard(); - showToast('Copied'); + // 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; } - return; } - // Paste canvas objects (Ctrl+V) — only if we have internal clipboard - // Image paste from system clipboard is handled by setupPaste in image-drop.ts - if ((e.ctrlKey || e.metaKey) && e.key === 'v') { - if (clipboardRef.current.length === 0) return; // let setupPaste handle it - const canvas = canvasRef.current?.getCanvas(); - if (!canvas) return; + // Special: Ctrl+V paste — if internal clipboard is empty, let browser/setupPaste handle it + if ((e.ctrlKey || e.metaKey) && e.key === 'v' && !e.shiftKey && !e.altKey) { + if (clipboardRef.current.length === 0) return; + } + + // Build context for shortcut handlers + const ctx: ShortcutContext = { + canvas, + getActiveObjects: () => canvas.getActiveObjects(), + getActiveObject: () => canvas.getActiveObject() || null, + clipboardRef, + cloneFabricObject, + writeCanvasToClipboard, + onCanvasChange, + showToast, + fitAll: () => canvasRef.current?.fitAll(), + setActiveTool, + undoRef, + setCanUndo, + setCanRedo, + refreshLayers, + handleGroup, + handleUngroup, + toggleGrid: () => setShowGrid((v) => !v), + toggleShowHelp: () => setShowHelp((v) => !v), + }; + + // Match against sorted registry (most-specific first) + for (const def of sortedShortcuts) { + if (!matchesShortcut(e, def)) continue; + + // Check selection requirements + const activeCount = canvas.getActiveObjects().length; + if (def.needsSelection && activeCount === 0) continue; + if (def.minSelection && activeCount < def.minSelection) continue; + e.preventDefault(); - const newObjs: FabricObject[] = []; - for (const original of clipboardRef.current) { - try { - const obj = await cloneFabricObject(original, 20, 20); - canvas.add(obj); - newObjs.push(obj); - } catch (err) { - console.error('Paste object failed:', err); - } - } - // Update clipboard to cloned positions for subsequent pastes - clipboardRef.current = newObjs; - canvas.requestRenderAll(); - onCanvasChange(); - return; - } + await def.handler(ctx); - // Copy as image to system clipboard (Ctrl+Shift+C) - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'C') { - e.preventDefault(); - copySelectionToClipboard(); - return; - } - - // Duplicate selected (Ctrl+D) - if ((e.ctrlKey || e.metaKey) && e.key === 'd') { - e.preventDefault(); - const canvas = canvasRef.current?.getCanvas(); - if (!canvas) return; - const active = canvas.getActiveObjects(); - if (active.length === 0) return; - for (const original of active) { - try { - const obj = await cloneFabricObject(original, 20, 20); - canvas.add(obj); - } catch (err) { - console.error('Duplicate failed:', err); - } - } - canvas.discardActiveObject(); - canvas.requestRenderAll(); - onCanvasChange(); - return; - } - - // Layer ordering - if (e.key === ']') { - const canvas = canvasRef.current?.getCanvas(); - if (!canvas) return; - const active = canvas.getActiveObjects(); - active.forEach((obj) => (canvas as any).bringObjectForward(obj)); - canvas.requestRenderAll(); - onCanvasChange(); - return; - } - if (e.key === '[') { - const canvas = canvasRef.current?.getCanvas(); - if (!canvas) return; - const active = canvas.getActiveObjects(); - active.forEach((obj) => (canvas as any).sendObjectBackwards(obj)); - canvas.requestRenderAll(); - onCanvasChange(); - return; - } - - // Zoom in/out with Ctrl+=/Ctrl+- - if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) { - e.preventDefault(); - const canvas = canvasRef.current?.getCanvas(); - if (!canvas) return; - const z = Math.min(canvas.getZoom() * 1.2, 5); - const center = canvas.getCenterPoint(); - canvas.zoomToPoint(center, z); - canvas.requestRenderAll(); - setZoom(z); - return; - } - if ((e.ctrlKey || e.metaKey) && e.key === '-') { - e.preventDefault(); - const canvas = canvasRef.current?.getCanvas(); - if (!canvas) return; - const z = Math.max(canvas.getZoom() / 1.2, 0.1); - const center = canvas.getCenterPoint(); - canvas.zoomToPoint(center, z); - canvas.requestRenderAll(); - setZoom(z); - return; - } - - if (e.key === 'Delete' || e.key === 'Backspace') { - const canvas = canvasRef.current?.getCanvas(); - if (!canvas) return; - const active = canvas.getActiveObjects(); - if (active.length > 0) { - active.forEach((obj) => canvas.remove(obj)); - canvas.discardActiveObject(); - canvas.requestRenderAll(); - onCanvasChange(); - } - return; - } - - if ((e.ctrlKey || e.metaKey) && e.key === 'a') { - e.preventDefault(); - const canvas = canvasRef.current?.getCanvas(); - if (!canvas) return; - canvas.discardActiveObject(); - const fabricNs = (window as any).fabric; - if (fabricNs?.ActiveSelection) { - const sel = new fabricNs.ActiveSelection(canvas.getObjects(), { canvas }); - canvas.setActiveObject(sel); - } - canvas.requestRenderAll(); - return; - } - - if ((e.ctrlKey || e.metaKey) && e.key === '0') { - e.preventDefault(); - canvasRef.current?.fitAll(); + // Update zoom display after any action + setZoom(canvas.getZoom()); return; } } window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [onCanvasChange, copySelectionToClipboard, handleGroup, handleUngroup]); + }, [onCanvasChange, writeCanvasToClipboard, handleGroup, handleUngroup, cloneFabricObject, showToast, refreshLayers]); // Save on page unload useEffect(() => { function onBeforeUnload() { const canvas = canvasRef.current?.getCanvas(); if (!canvas || !resolvedBoardId) return; - const state = JSON.stringify((canvas as any).toJSON(['id'])); + const state = JSON.stringify((canvas as any).toJSON(['id', 'crossOrigin'])); const blob = new Blob([JSON.stringify({ canvas_state: state })], { type: 'application/json' }); navigator.sendBeacon(`/api/boards/${resolvedBoardId}/save`, blob); } @@ -855,6 +725,7 @@ export default function Editor({ isPublicView }: EditorProps) { onlineUsers={onlineUsers} onToggleLayers={() => setShowLayers((v) => !v)} showLayers={showLayers} + onToggleHelp={() => setShowHelp((v) => !v)} boardName={board?.name} /> @@ -872,6 +743,24 @@ export default function Editor({ isPublicView }: EditorProps) { canvasTransform={canvasTransform} /> + {/* Grid overlay */} + {showGrid && ( + + + + + + + + + + )} + {/* Empty canvas guide */} {imageCount === 0 && !canvasState?.objects?.length && (
+ + {/* Shortcuts help overlay */} + {showHelp && ( + setShowHelp(false)} /> + )}
); }