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:
Hiren
2026-03-09 18:29:35 +05:30
parent 9e5c4ecdd0
commit cdeafce7ad
10 changed files with 1389 additions and 338 deletions
+32 -12
View File
@@ -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) => {
const e = opt.e as WheelEvent;
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY;
// Normalize delta: mice send large values (~100), trackpads send small (~1-5)
// Clamp to ±1 and apply a fixed zoom factor per "step" for predictable UX
let delta = e.deltaY;
if (e.deltaMode === 1) delta *= 40; // DOM_DELTA_LINE → px
if (e.deltaMode === 2) delta *= 800; // DOM_DELTA_PAGE → px
// Use sign + magnitude clamping for consistent zoom speed
const direction = Math.sign(delta);
const ZOOM_STEP = 0.08; // 8% per step — smooth but responsive
let zoom = canvas.getZoom();
zoom *= 0.999 ** delta;
zoom *= 1 - direction * ZOOM_STEP;
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
// Zoom toward cursor position (industry standard)
const point = canvas.getScenePoint(e);
canvas.zoomToPoint(point, zoom);
canvas.requestRenderAll();
@@ -163,19 +173,29 @@ const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
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
// other clients with object:add events for objects they already have
suppressBroadcasts();
canvas.loadFromJSON(parsed).then(() => {
canvas.getObjects().forEach((obj: FabricObject) => {
if (obj.type === 'image') {
const imgObj = obj as FabricImage;
const src = (imgObj as any).src || imgObj.getSrc?.();
if (src) {
(imgObj as any).crossOrigin = 'anonymous';
}
}
});
canvas.requestRenderAll();
// Small delay to ensure all deferred events have fired before resuming
setTimeout(() => resumeBroadcasts(), 100);
+14 -2
View File
@@ -20,7 +20,7 @@ export class UndoManager {
saveState(): void {
if (this.locked) return;
const json = JSON.stringify((this.canvas as any).toJSON(['id']));
const json = JSON.stringify((this.canvas as any).toJSON(['id', 'crossOrigin']));
// If we're not at the end, discard forward history
if (this.pointer < this.stack.length - 1) {
@@ -69,7 +69,19 @@ export class UndoManager {
if (!state) return;
this.locked = true;
this.canvas.loadFromJSON(JSON.parse(state)).then(() => {
const parsed = JSON.parse(state);
// Ensure images have crossOrigin to prevent canvas tainting
if (parsed?.objects) {
for (const obj of parsed.objects) {
if (obj.type === 'image') obj.crossOrigin = 'anonymous';
if (obj.type === 'group' && obj.objects) {
for (const child of obj.objects) {
if (child.type === 'image') child.crossOrigin = 'anonymous';
}
}
}
}
this.canvas.loadFromJSON(parsed).then(() => {
this.canvas.requestRenderAll();
this.locked = false;
});
+358
View File
@@ -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();
});
}
}
+462
View File
@@ -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(),
},
];
+93
View File
@@ -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(' + ');
}
+13 -1
View File
@@ -57,7 +57,7 @@ export function setupSync(
if (_suppress) return;
// Ensure all objects have IDs before serializing
canvas.getObjects().forEach(ensureId);
const scene = (canvas as any).toJSON(['id']);
const scene = (canvas as any).toJSON(['id', 'crossOrigin']);
socket.emit('scene:update', { boardId, scene });
}, SCENE_THROTTLE);
}
@@ -89,6 +89,18 @@ export function setupSync(
function doApplyScene(scene: any) {
_suppress = true;
// Ensure all images have crossOrigin set before Fabric loads them,
// otherwise the canvas becomes tainted and clipboard copy breaks.
if (scene?.objects) {
for (const obj of scene.objects) {
if (obj.type === 'image') obj.crossOrigin = 'anonymous';
if (obj.type === 'group' && obj.objects) {
for (const child of obj.objects) {
if (child.type === 'image') child.crossOrigin = 'anonymous';
}
}
}
}
canvas.loadFromJSON(scene)
.then(() => {
canvas.requestRenderAll();