feat: RefBoard v0.5.0 — PureRef-style shortcuts, clipboard fix, zoom normalization
- 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
This commit is contained in:
@@ -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)
|
## v0.4.0 — Layers, Groups & UI Overhaul (2026-03-09)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -89,17 +89,27 @@ const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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) => {
|
canvas.on('mouse:wheel', (opt: any) => {
|
||||||
const e = opt.e as WheelEvent;
|
const e = opt.e as WheelEvent;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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();
|
let zoom = canvas.getZoom();
|
||||||
zoom *= 0.999 ** delta;
|
zoom *= 1 - direction * ZOOM_STEP;
|
||||||
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
|
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
|
||||||
|
|
||||||
|
// Zoom toward cursor position (industry standard)
|
||||||
const point = canvas.getScenePoint(e);
|
const point = canvas.getScenePoint(e);
|
||||||
canvas.zoomToPoint(point, zoom);
|
canvas.zoomToPoint(point, zoom);
|
||||||
canvas.requestRenderAll();
|
canvas.requestRenderAll();
|
||||||
@@ -163,19 +173,29 @@ const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
|
|||||||
|
|
||||||
if (!parsed || (!parsed.objects && !parsed.version)) return;
|
if (!parsed || (!parsed.objects && !parsed.version)) return;
|
||||||
|
|
||||||
|
// Ensure all images in the JSON have crossOrigin set BEFORE Fabric loads them.
|
||||||
|
// Without this, the underlying <img> 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
|
// Suppress socket broadcasts during initial load — prevents flooding
|
||||||
// other clients with object:add events for objects they already have
|
// other clients with object:add events for objects they already have
|
||||||
suppressBroadcasts();
|
suppressBroadcasts();
|
||||||
canvas.loadFromJSON(parsed).then(() => {
|
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();
|
canvas.requestRenderAll();
|
||||||
// Small delay to ensure all deferred events have fired before resuming
|
// Small delay to ensure all deferred events have fired before resuming
|
||||||
setTimeout(() => resumeBroadcasts(), 100);
|
setTimeout(() => resumeBroadcasts(), 100);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export class UndoManager {
|
|||||||
saveState(): void {
|
saveState(): void {
|
||||||
if (this.locked) return;
|
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 we're not at the end, discard forward history
|
||||||
if (this.pointer < this.stack.length - 1) {
|
if (this.pointer < this.stack.length - 1) {
|
||||||
@@ -69,7 +69,19 @@ export class UndoManager {
|
|||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
this.locked = true;
|
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.canvas.requestRenderAll();
|
||||||
this.locked = false;
|
this.locked = false;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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<void>;
|
||||||
|
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<FabricObject[]>;
|
||||||
|
cloneFabricObject: (obj: any, dx: number, dy: number) => Promise<any>;
|
||||||
|
writeCanvasToClipboard: (objects?: FabricObject[]) => Promise<void>;
|
||||||
|
onCanvasChange: () => void;
|
||||||
|
showToast: (msg: string) => void;
|
||||||
|
fitAll: () => void;
|
||||||
|
setActiveTool: (tool: ToolType) => void;
|
||||||
|
undoRef: React.MutableRefObject<any>;
|
||||||
|
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<string, string> = {
|
||||||
|
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(' + ');
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ export function setupSync(
|
|||||||
if (_suppress) return;
|
if (_suppress) return;
|
||||||
// Ensure all objects have IDs before serializing
|
// Ensure all objects have IDs before serializing
|
||||||
canvas.getObjects().forEach(ensureId);
|
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 });
|
socket.emit('scene:update', { boardId, scene });
|
||||||
}, SCENE_THROTTLE);
|
}, SCENE_THROTTLE);
|
||||||
}
|
}
|
||||||
@@ -89,6 +89,18 @@ export function setupSync(
|
|||||||
|
|
||||||
function doApplyScene(scene: any) {
|
function doApplyScene(scene: any) {
|
||||||
_suppress = true;
|
_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)
|
canvas.loadFromJSON(scene)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
canvas.requestRenderAll();
|
canvas.requestRenderAll();
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { ShortcutDef, formatShortcut } from '../canvas/shortcuts';
|
||||||
|
|
||||||
|
interface ShortcutsHelpProps {
|
||||||
|
shortcuts: ShortcutDef[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryLabels: Record<string, string> = {
|
||||||
|
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<string>();
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 2000,
|
||||||
|
background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(6px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => 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 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '20px 24px 16px', borderBottom: '1px solid #1e1e1e', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '16px', fontWeight: 600, color: '#e0e0e0', letterSpacing: '-0.3px' }}>
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: '1px solid #333', borderRadius: '6px',
|
||||||
|
color: '#888', padding: '4px 12px', cursor: 'pointer', fontSize: '11px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ESC
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '12px 24px 24px' }}>
|
||||||
|
{/* Tool shortcuts (hardcoded since they use a different system) */}
|
||||||
|
<SectionHeader label="Tools" />
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0' }}>
|
||||||
|
{toolShortcuts.map((s) => (
|
||||||
|
<ShortcutRow key={s.keys} keys={s.keys} description={s.description} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Registry shortcuts */}
|
||||||
|
{grouped.map((g) => (
|
||||||
|
<React.Fragment key={g.category}>
|
||||||
|
<SectionHeader label={g.label} />
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0' }}>
|
||||||
|
{g.items.map((s) => (
|
||||||
|
<ShortcutRow key={s.id} keys={formatShortcut(s)} description={s.description} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '10px', fontWeight: 700, color: '#4a9eff', letterSpacing: '0.8px',
|
||||||
|
textTransform: 'uppercase', padding: '16px 0 6px', borderBottom: '1px solid #1a1a1a',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShortcutRow({ keys, description }: { keys: string; description: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '5px 8px', borderRadius: '4px',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '12px', color: '#aaa' }}>{description}</span>
|
||||||
|
<kbd style={{
|
||||||
|
fontSize: '10px', color: '#777', background: '#1a1a1a',
|
||||||
|
border: '1px solid #2a2a2a', borderRadius: '4px',
|
||||||
|
padding: '2px 8px', fontFamily: 'inherit', whiteSpace: 'nowrap',
|
||||||
|
marginLeft: '12px',
|
||||||
|
}}>
|
||||||
|
{keys}
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ interface ToolbarProps {
|
|||||||
onShareClick?: () => void;
|
onShareClick?: () => void;
|
||||||
onToggleLayers?: () => void;
|
onToggleLayers?: () => void;
|
||||||
showLayers?: boolean;
|
showLayers?: boolean;
|
||||||
|
onToggleHelp?: () => void;
|
||||||
boardName?: string;
|
boardName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +141,7 @@ export default function Toolbar({
|
|||||||
onShareClick,
|
onShareClick,
|
||||||
onToggleLayers,
|
onToggleLayers,
|
||||||
showLayers,
|
showLayers,
|
||||||
|
onToggleHelp,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
const showStroke = activeTool === ToolType.PEN;
|
const showStroke = activeTool === ToolType.PEN;
|
||||||
const showFontSize = activeTool === ToolType.TEXT;
|
const showFontSize = activeTool === ToolType.TEXT;
|
||||||
@@ -268,6 +270,17 @@ export default function Toolbar({
|
|||||||
</ActionBtn>
|
</ActionBtn>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Help / shortcuts */}
|
||||||
|
{onToggleHelp && (
|
||||||
|
<ActionBtn onClick={onToggleHelp} title="Keyboard shortcuts (?)">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<circle cx="7" cy="7" r="6" />
|
||||||
|
<path d="M5.5 5.5a1.5 1.5 0 013 0c0 1-1.5 1-1.5 2" strokeLinecap="round" />
|
||||||
|
<circle cx="7" cy="10" r="0.5" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
</ActionBtn>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
|||||||
+207
-313
@@ -6,6 +6,9 @@ import { ToolType, activateTool, toolShortcuts } from '../canvas/tools';
|
|||||||
import { setupSync, isRemoteUpdate } from '../canvas/sync';
|
import { setupSync, isRemoteUpdate } from '../canvas/sync';
|
||||||
import { setupDragDrop, setupPaste } from '../canvas/image-drop';
|
import { setupDragDrop, setupPaste } from '../canvas/image-drop';
|
||||||
import { UndoManager } from '../canvas/history';
|
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 { connectSocket, disconnectSocket, getSocket } from '../socket';
|
||||||
import { getBoard, saveCanvas } from '../api';
|
import { getBoard, saveCanvas } from '../api';
|
||||||
import { useAuth } from '../auth';
|
import { useAuth } from '../auth';
|
||||||
@@ -14,6 +17,10 @@ import StatusBar, { SaveStatus } from '../components/StatusBar';
|
|||||||
import UserCursors from '../components/UserCursors';
|
import UserCursors from '../components/UserCursors';
|
||||||
import ContextMenu from '../components/ContextMenu';
|
import ContextMenu from '../components/ContextMenu';
|
||||||
import LayerPanel from '../components/LayerPanel';
|
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 {
|
interface EditorProps {
|
||||||
isPublicView?: boolean;
|
isPublicView?: boolean;
|
||||||
@@ -68,6 +75,8 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [toasts, setToasts] = useState<{ id: string; text: string }[]>([]);
|
const [toasts, setToasts] = useState<{ id: string; text: string }[]>([]);
|
||||||
const [showLayers, setShowLayers] = useState(false);
|
const [showLayers, setShowLayers] = useState(false);
|
||||||
|
const [showGrid, setShowGrid] = useState(false);
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [layerList, setLayerList] = useState<any[]>([]);
|
const [layerList, setLayerList] = useState<any[]>([]);
|
||||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||||
const clipboardRef = useRef<FabricObject[]>([]);
|
const clipboardRef = useRef<FabricObject[]>([]);
|
||||||
@@ -110,7 +119,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
setSaveStatus('saving');
|
setSaveStatus('saving');
|
||||||
try {
|
try {
|
||||||
const state = JSON.stringify((canvas as any).toJSON(['id']));
|
const state = JSON.stringify((canvas as any).toJSON(['id', 'crossOrigin']));
|
||||||
await saveCanvas(resolvedBoardId, state);
|
await saveCanvas(resolvedBoardId, state);
|
||||||
setSaveStatus('saved');
|
setSaveStatus('saved');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -229,71 +238,90 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
return cloned;
|
return cloned;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Copy selection as cropped image to system clipboard (for pasting in Paint etc.)
|
// Write canvas content to system clipboard as PNG.
|
||||||
const copySelectionToClipboard = useCallback(async () => {
|
// If objects are provided, crop to their bounds. Otherwise copy entire canvas.
|
||||||
|
const writeCanvasToClipboard = useCallback(async (objects?: FabricObject[]) => {
|
||||||
const canvas = canvasRef.current?.getCanvas();
|
const canvas = canvasRef.current?.getCanvas();
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
const active = canvas.getActiveObjects();
|
|
||||||
if (active.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Temporarily hide selection handles by deselecting
|
// Save & clear selection so handles don't appear in screenshot
|
||||||
const activeObj = canvas.getActiveObject();
|
const activeObj = canvas.getActiveObject();
|
||||||
canvas.discardActiveObject();
|
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
|
// Capture full canvas — may throw if canvas is tainted (images without crossOrigin)
|
||||||
const fullCanvas = (canvas as any).toCanvasElement(1);
|
let fullCanvas: HTMLCanvasElement;
|
||||||
|
try {
|
||||||
// Get bounding rect of selected objects on the rendered canvas
|
fullCanvas = (canvas as any).toCanvasElement(1);
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
} catch (taintErr) {
|
||||||
active.forEach((obj) => {
|
// Canvas is tainted — fall back to re-rendering without tainted images
|
||||||
const bound = obj.getBoundingRect();
|
console.warn('Canvas tainted, attempting fallback:', taintErr);
|
||||||
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
|
|
||||||
if (activeObj) {
|
if (activeObj) {
|
||||||
canvas.setActiveObject(activeObj);
|
canvas.setActiveObject(activeObj);
|
||||||
|
canvas.renderAll();
|
||||||
|
}
|
||||||
|
showToast('Copy failed — try re-opening the board');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
canvas.requestRenderAll();
|
|
||||||
|
|
||||||
const padding = 8;
|
// Restore selection immediately
|
||||||
const cropX = Math.max(0, Math.floor(minX - padding));
|
if (activeObj) {
|
||||||
const cropY = Math.max(0, Math.floor(minY - padding));
|
canvas.setActiveObject(activeObj);
|
||||||
const cropW = Math.min(Math.ceil(maxX - minX + padding * 2), fullCanvas.width - cropX);
|
canvas.renderAll();
|
||||||
const cropH = Math.min(Math.ceil(maxY - minY + padding * 2), fullCanvas.height - cropY);
|
}
|
||||||
|
|
||||||
if (cropW <= 0 || cropH <= 0) return;
|
let outputCanvas: HTMLCanvasElement;
|
||||||
|
|
||||||
// Crop to selection bounds
|
if (objects && objects.length > 0) {
|
||||||
const offscreen = document.createElement('canvas');
|
// Compute bounding rect of the specified objects.
|
||||||
offscreen.width = cropW;
|
// getBoundingRect returns screen-space coords (includes viewport transform).
|
||||||
offscreen.height = cropH;
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
const ctx = offscreen.getContext('2d')!;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.fillStyle = '#ffffff';
|
||||||
ctx.fillRect(0, 0, cropW, cropH);
|
ctx.fillRect(0, 0, cw, ch);
|
||||||
ctx.drawImage(fullCanvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);
|
ctx.drawImage(fullCanvas, cx, cy, cw, ch, 0, 0, cw, ch);
|
||||||
|
} else {
|
||||||
|
// No specific objects → copy entire canvas as-is
|
||||||
|
outputCanvas = fullCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
// Write to system clipboard
|
// Convert to blob using promise wrapper (keeps user activation alive)
|
||||||
offscreen.toBlob(async (blob: Blob | null) => {
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
if (!blob) return;
|
outputCanvas.toBlob((b) => {
|
||||||
try {
|
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([
|
await navigator.clipboard.write([
|
||||||
new ClipboardItem({ 'image/png': blob }),
|
new ClipboardItem({ 'image/png': blob }),
|
||||||
]);
|
]);
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
// Clipboard API not available (needs HTTPS)
|
console.error('writeCanvasToClipboard failed:', err);
|
||||||
|
showToast('Copy failed: ' + (err.message || 'clipboard not available'));
|
||||||
}
|
}
|
||||||
}, 'image/png');
|
}, [showToast]);
|
||||||
} catch (err) {
|
|
||||||
console.error('Copy to clipboard failed:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Refresh layer list from canvas
|
// Refresh layer list from canvas
|
||||||
// Auto-name counters
|
// Auto-name counters
|
||||||
@@ -388,56 +416,6 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
refreshLayers();
|
refreshLayers();
|
||||||
}, [onCanvasChange, 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
|
// Right-click context menu
|
||||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -446,65 +424,84 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
|
|
||||||
const contextMenuItems = useCallback(() => {
|
const contextMenuItems = useCallback(() => {
|
||||||
const canvas = canvasRef.current?.getCanvas();
|
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 [
|
return [
|
||||||
{ label: 'Copy', shortcut: 'Ctrl+C', onClick: () => {
|
{ label: 'Copy', shortcut: 'Ctrl+C', onClick: () => {
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const active = canvas.getActiveObjects();
|
if (hasSel) { clipboardRef.current = [...active]; writeCanvasToClipboard(active); }
|
||||||
if (active.length > 0) {
|
else writeCanvasToClipboard();
|
||||||
|
} },
|
||||||
|
{ label: 'Cut', shortcut: 'Ctrl+X', onClick: () => {
|
||||||
|
if (!canvas || !hasSel) return;
|
||||||
clipboardRef.current = [...active];
|
clipboardRef.current = [...active];
|
||||||
copySelectionToClipboard();
|
active.forEach((o) => canvas.remove(o));
|
||||||
}
|
canvas.discardActiveObject(); canvas.requestRenderAll(); onCanvasChange();
|
||||||
}, disabled: !hasSelection },
|
}, disabled: !hasSel },
|
||||||
{ label: 'Paste', shortcut: 'Ctrl+V', onClick: async () => {
|
{ label: 'Paste', shortcut: 'Ctrl+V', onClick: async () => {
|
||||||
if (!canvas || clipboardRef.current.length === 0) return;
|
if (!canvas || clipboardRef.current.length === 0) return;
|
||||||
for (const original of clipboardRef.current) {
|
for (const o of clipboardRef.current) {
|
||||||
try {
|
try { const c = await cloneFabricObject(o, 20, 20); canvas.add(c); } catch {}
|
||||||
const obj = await cloneFabricObject(original, 20, 20);
|
|
||||||
canvas.add(obj);
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
canvas.requestRenderAll();
|
canvas.requestRenderAll(); onCanvasChange();
|
||||||
onCanvasChange();
|
|
||||||
}, disabled: clipboardRef.current.length === 0 },
|
}, disabled: clipboardRef.current.length === 0 },
|
||||||
{ label: 'Duplicate', shortcut: 'Ctrl+D', onClick: async () => {
|
{ label: 'Duplicate', shortcut: 'Ctrl+D', onClick: async () => {
|
||||||
if (!canvas) return;
|
if (!canvas || !hasSel) return;
|
||||||
const active = canvas.getActiveObjects();
|
for (const o of active) {
|
||||||
for (const original of active) {
|
try { const c = await cloneFabricObject(o, 20, 20); canvas.add(c); } catch {}
|
||||||
try {
|
|
||||||
const obj = await cloneFabricObject(original, 20, 20);
|
|
||||||
canvas.add(obj);
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
canvas.discardActiveObject();
|
canvas.discardActiveObject(); canvas.requestRenderAll(); onCanvasChange();
|
||||||
canvas.requestRenderAll();
|
}, disabled: !hasSel },
|
||||||
onCanvasChange();
|
|
||||||
}, disabled: !hasSelection },
|
|
||||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
{ 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: () => {
|
{ label: 'Bring Forward', shortcut: ']', onClick: () => {
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
canvas.getActiveObjects().forEach((obj) => canvas.bringObjectForward(obj));
|
active.forEach((o) => (canvas as any).bringObjectForward(o));
|
||||||
canvas.requestRenderAll();
|
canvas.requestRenderAll(); onCanvasChange();
|
||||||
onCanvasChange();
|
}, disabled: !hasSel },
|
||||||
}, disabled: !hasSelection },
|
|
||||||
{ label: 'Send Backward', shortcut: '[', onClick: () => {
|
{ label: 'Send Backward', shortcut: '[', onClick: () => {
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
canvas.getActiveObjects().forEach((obj) => canvas.sendObjectBackwards(obj));
|
active.forEach((o) => (canvas as any).sendObjectBackwards(o));
|
||||||
canvas.requestRenderAll();
|
canvas.requestRenderAll(); onCanvasChange();
|
||||||
onCanvasChange();
|
}, disabled: !hasSel },
|
||||||
}, disabled: !hasSelection },
|
|
||||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
{ label: 'Group', shortcut: 'Ctrl+G', onClick: handleGroup,
|
// — Group —
|
||||||
disabled: !canvas || canvas.getActiveObjects().length < 2 },
|
{ label: 'Group', shortcut: 'Ctrl+G', onClick: handleGroup, disabled: !multiSel },
|
||||||
{ label: 'Ungroup', shortcut: 'Ctrl+Shift+G', onClick: handleUngroup,
|
{ label: 'Ungroup', shortcut: 'Ctrl+Shift+G', onClick: handleUngroup,
|
||||||
disabled: !canvas || canvas.getActiveObject()?.type !== 'group' },
|
disabled: !canvas || canvas.getActiveObject()?.type !== 'group' },
|
||||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
{ label: 'Arrange Grid', shortcut: '', onClick: () => arrangeObjects('grid'),
|
// — Arrangement —
|
||||||
disabled: !canvas || canvas.getActiveObjects().length < 2 },
|
{ label: 'Arrange Pack', shortcut: 'Ctrl+Shift+P', onClick: () => run((o) => ops.arrangeOptimal(o)), disabled: !multiSel },
|
||||||
{ label: 'Arrange Row', shortcut: '', onClick: () => arrangeObjects('row'),
|
{ label: 'Arrange Grid', shortcut: '', onClick: () => run((o) => ops.arrangeGrid(o)), disabled: !multiSel },
|
||||||
disabled: !canvas || canvas.getActiveObjects().length < 2 },
|
{ label: 'Arrange Row', shortcut: '', onClick: () => run((o) => ops.arrangeRow(o)), disabled: !multiSel },
|
||||||
{ label: 'Arrange Column', shortcut: '', onClick: () => arrangeObjects('column'),
|
{ label: 'Arrange Column', shortcut: '', onClick: () => run((o) => ops.arrangeColumn(o)), disabled: !multiSel },
|
||||||
disabled: !canvas || canvas.getActiveObjects().length < 2 },
|
{ 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: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
{ label: 'Select All', shortcut: 'Ctrl+A', onClick: () => {
|
{ label: 'Select All', shortcut: 'Ctrl+A', onClick: () => {
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
@@ -520,211 +517,84 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
{ label: 'Delete', shortcut: 'Del', onClick: () => {
|
{ label: 'Delete', shortcut: 'Del', onClick: () => {
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const active = canvas.getActiveObjects();
|
active.forEach((o) => canvas.remove(o));
|
||||||
active.forEach((obj) => canvas.remove(obj));
|
canvas.discardActiveObject(); canvas.requestRenderAll(); onCanvasChange();
|
||||||
canvas.discardActiveObject();
|
}, disabled: !hasSel, danger: true },
|
||||||
canvas.requestRenderAll();
|
|
||||||
onCanvasChange();
|
|
||||||
}, disabled: !hasSelection, danger: true },
|
|
||||||
];
|
];
|
||||||
}, [copySelectionToClipboard, onCanvasChange, handleGroup, handleUngroup, arrangeObjects, cloneFabricObject]);
|
}, [writeCanvasToClipboard, onCanvasChange, handleGroup, handleUngroup, cloneFabricObject]);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts — registry-based approach
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function onKeyDown(e: KeyboardEvent) {
|
async function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current?.getCanvas();
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
// Tool shortcuts (bare keys 1-5, v/h/p/t/e — no modifiers)
|
||||||
|
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||||
const tool = toolShortcuts[e.key.toLowerCase()];
|
const tool = toolShortcuts[e.key.toLowerCase()];
|
||||||
if (tool && !e.ctrlKey && !e.metaKey) {
|
if (tool) {
|
||||||
setActiveTool(tool);
|
setActiveTool(tool);
|
||||||
return;
|
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)
|
// Special: Ctrl+V paste — if internal clipboard is empty, let browser/setupPaste handle it
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'g' && !e.shiftKey) {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && !e.shiftKey && !e.altKey) {
|
||||||
e.preventDefault();
|
if (clipboardRef.current.length === 0) return;
|
||||||
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)
|
// Build context for shortcut handlers
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !e.shiftKey) {
|
const ctx: ShortcutContext = {
|
||||||
const canvas = canvasRef.current?.getCanvas();
|
canvas,
|
||||||
if (!canvas) return;
|
getActiveObjects: () => canvas.getActiveObjects(),
|
||||||
const active = canvas.getActiveObjects();
|
getActiveObject: () => canvas.getActiveObject() || null,
|
||||||
if (active.length > 0) {
|
clipboardRef,
|
||||||
// Store actual object refs for cloning later
|
cloneFabricObject,
|
||||||
clipboardRef.current = [...active];
|
writeCanvasToClipboard,
|
||||||
// Write image to system clipboard (async, doesn't block)
|
onCanvasChange,
|
||||||
copySelectionToClipboard();
|
showToast,
|
||||||
showToast('Copied');
|
fitAll: () => canvasRef.current?.fitAll(),
|
||||||
}
|
setActiveTool,
|
||||||
return;
|
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;
|
||||||
|
|
||||||
// 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;
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const newObjs: FabricObject[] = [];
|
await def.handler(ctx);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy as image to system clipboard (Ctrl+Shift+C)
|
// Update zoom display after any action
|
||||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'C') {
|
setZoom(canvas.getZoom());
|
||||||
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();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [onCanvasChange, copySelectionToClipboard, handleGroup, handleUngroup]);
|
}, [onCanvasChange, writeCanvasToClipboard, handleGroup, handleUngroup, cloneFabricObject, showToast, refreshLayers]);
|
||||||
|
|
||||||
// Save on page unload
|
// Save on page unload
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onBeforeUnload() {
|
function onBeforeUnload() {
|
||||||
const canvas = canvasRef.current?.getCanvas();
|
const canvas = canvasRef.current?.getCanvas();
|
||||||
if (!canvas || !resolvedBoardId) return;
|
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' });
|
const blob = new Blob([JSON.stringify({ canvas_state: state })], { type: 'application/json' });
|
||||||
navigator.sendBeacon(`/api/boards/${resolvedBoardId}/save`, blob);
|
navigator.sendBeacon(`/api/boards/${resolvedBoardId}/save`, blob);
|
||||||
}
|
}
|
||||||
@@ -855,6 +725,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
onlineUsers={onlineUsers}
|
onlineUsers={onlineUsers}
|
||||||
onToggleLayers={() => setShowLayers((v) => !v)}
|
onToggleLayers={() => setShowLayers((v) => !v)}
|
||||||
showLayers={showLayers}
|
showLayers={showLayers}
|
||||||
|
onToggleHelp={() => setShowHelp((v) => !v)}
|
||||||
boardName={board?.name}
|
boardName={board?.name}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -872,6 +743,24 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
canvasTransform={canvasTransform}
|
canvasTransform={canvasTransform}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Grid overlay */}
|
||||||
|
{showGrid && (
|
||||||
|
<svg
|
||||||
|
style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 1 }}
|
||||||
|
width="100%" height="100%"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern id="grid50" width={50 * (canvasTransform[0] || 1)} height={50 * (canvasTransform[3] || 1)} patternUnits="userSpaceOnUse"
|
||||||
|
x={(canvasTransform[4] || 0) % (50 * (canvasTransform[0] || 1))}
|
||||||
|
y={(canvasTransform[5] || 0) % (50 * (canvasTransform[3] || 1))}>
|
||||||
|
<line x1="0" y1="0" x2={50 * (canvasTransform[0] || 1)} y2="0" stroke="rgba(255,255,255,0.04)" strokeWidth="1" />
|
||||||
|
<line x1="0" y1="0" x2="0" y2={50 * (canvasTransform[3] || 1)} stroke="rgba(255,255,255,0.04)" strokeWidth="1" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#grid50)" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Empty canvas guide */}
|
{/* Empty canvas guide */}
|
||||||
{imageCount === 0 && !canvasState?.objects?.length && (
|
{imageCount === 0 && !canvasState?.objects?.length && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -997,6 +886,11 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
imageCount={imageCount}
|
imageCount={imageCount}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Shortcuts help overlay */}
|
||||||
|
{showHelp && (
|
||||||
|
<ShortcutsHelp shortcuts={shortcutDefs} onClose={() => setShowHelp(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user