Port shortcut definitions from Fabric.js to PixiJS SceneManager/SelectionManager

Replace all Fabric.js Canvas references with SceneManager, SelectionManager,
UndoManager, and Viewport. Remove withBreak/breakSelection helper (no longer
needed without ActiveSelection). Implement z-swap layer ordering for ] and [
keys, clone-based paste/duplicate via SceneManager._createItem, and direct
history.undo()/redo() calls instead of undoRef indirection.
This commit is contained in:
Hiren Kangad
2026-03-09 23:23:10 +05:30
parent 9c5056d11e
commit edcac21983
2 changed files with 206 additions and 152 deletions
+195 -143
View File
@@ -2,29 +2,20 @@
* All shortcut definitions — the single source of truth for keyboard shortcuts. * All shortcut definitions — the single source of truth for keyboard shortcuts.
* Used by the keyboard handler AND the ShortcutsHelp overlay. * Used by the keyboard handler AND the ShortcutsHelp overlay.
* *
* BUGS FIXED in this revision: * Ported to PixiJS: uses SceneManager/SelectionManager instead of Fabric.js Canvas.
*
* BUGS FIXED in previous revision (preserved):
* - Ctrl+Y conflict (was both redo AND overlay-compare) → overlay moved to Ctrl+Shift+Y * - 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 * - Ctrl+P (browser print) → changed to Ctrl+Shift+P for arrange optimal
* - '?' key (Shift+/) → e.key is '?' not '/', fixed matcher key * - '?' key (Shift+/) → e.key is '?' not '/', fixed matcher key
* - ArrowUp/Down with selection → layer ordering uses ] / [ again (arrows cycle/nudge) * - ArrowUp/Down with selection → layer ordering uses ] / [ again (arrows cycle/nudge)
* - ArrowLeft/Right bare → only cycle when nothing selected, otherwise no-op * - 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) * - Added Ctrl+S (prevent browser save-as)
*/ */
import { ShortcutDef } from './shortcuts'; import { ShortcutDef } from './shortcuts';
import { FabricObject } from 'fabric';
import * as ops from './operations'; 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[] = [ export const shortcuts: ShortcutDef[] = [
// ═══════════════════════════════════════ // ═══════════════════════════════════════
@@ -34,22 +25,34 @@ export const shortcuts: ShortcutDef[] = [
{ {
id: 'align-left', keys: { key: 'arrowleft', ctrl: true }, id: 'align-left', keys: { key: 'arrowleft', ctrl: true },
category: 'alignment', description: 'Align left', needsSelection: true, minSelection: 2, category: 'alignment', description: 'Align left', needsSelection: true, minSelection: 2,
handler: (ctx) => withBreak(ctx, (objs) => ops.alignLeft(objs)), handler: (ctx) => {
ops.alignLeft(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'align-right', keys: { key: 'arrowright', ctrl: true }, id: 'align-right', keys: { key: 'arrowright', ctrl: true },
category: 'alignment', description: 'Align right', needsSelection: true, minSelection: 2, category: 'alignment', description: 'Align right', needsSelection: true, minSelection: 2,
handler: (ctx) => withBreak(ctx, (objs) => ops.alignRight(objs)), handler: (ctx) => {
ops.alignRight(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'align-top', keys: { key: 'arrowup', ctrl: true }, id: 'align-top', keys: { key: 'arrowup', ctrl: true },
category: 'alignment', description: 'Align top', needsSelection: true, minSelection: 2, category: 'alignment', description: 'Align top', needsSelection: true, minSelection: 2,
handler: (ctx) => withBreak(ctx, (objs) => ops.alignTop(objs)), handler: (ctx) => {
ops.alignTop(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'align-bottom', keys: { key: 'arrowdown', ctrl: true }, id: 'align-bottom', keys: { key: 'arrowdown', ctrl: true },
category: 'alignment', description: 'Align bottom', needsSelection: true, minSelection: 2, category: 'alignment', description: 'Align bottom', needsSelection: true, minSelection: 2,
handler: (ctx) => withBreak(ctx, (objs) => ops.alignBottom(objs)), handler: (ctx) => {
ops.alignBottom(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
// ═══════════════════════════════════════ // ═══════════════════════════════════════
@@ -59,12 +62,18 @@ export const shortcuts: ShortcutDef[] = [
{ {
id: 'distribute-h', keys: { key: 'arrowup', ctrl: true, alt: true, shift: true }, id: 'distribute-h', keys: { key: 'arrowup', ctrl: true, alt: true, shift: true },
category: 'alignment', description: 'Distribute horizontal', needsSelection: true, minSelection: 3, category: 'alignment', description: 'Distribute horizontal', needsSelection: true, minSelection: 3,
handler: (ctx) => withBreak(ctx, (objs) => ops.distributeHorizontal(objs)), handler: (ctx) => {
ops.distributeHorizontal(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'distribute-v', keys: { key: 'arrowdown', ctrl: true, alt: true, shift: true }, id: 'distribute-v', keys: { key: 'arrowdown', ctrl: true, alt: true, shift: true },
category: 'alignment', description: 'Distribute vertical', needsSelection: true, minSelection: 3, category: 'alignment', description: 'Distribute vertical', needsSelection: true, minSelection: 3,
handler: (ctx) => withBreak(ctx, (objs) => ops.distributeVertical(objs)), handler: (ctx) => {
ops.distributeVertical(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
// ═══════════════════════════════════════ // ═══════════════════════════════════════
@@ -74,58 +83,79 @@ export const shortcuts: ShortcutDef[] = [
{ {
id: 'normalize-size', keys: { key: 'arrowup', ctrl: true, alt: true }, id: 'normalize-size', keys: { key: 'arrowup', ctrl: true, alt: true },
category: 'normalize', description: 'Normalize size (same area)', needsSelection: true, minSelection: 2, category: 'normalize', description: 'Normalize size (same area)', needsSelection: true, minSelection: 2,
handler: (ctx) => withBreak(ctx, (objs) => ops.normalizeSize(objs)), handler: (ctx) => {
ops.normalizeSize(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'normalize-scale', keys: { key: 'arrowdown', ctrl: true, alt: true }, id: 'normalize-scale', keys: { key: 'arrowdown', ctrl: true, alt: true },
category: 'normalize', description: 'Normalize scale', needsSelection: true, minSelection: 2, category: 'normalize', description: 'Normalize scale', needsSelection: true, minSelection: 2,
handler: (ctx) => withBreak(ctx, (objs) => ops.normalizeScale(objs)), handler: (ctx) => {
ops.normalizeScale(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'normalize-height', keys: { key: 'arrowleft', ctrl: true, alt: true }, id: 'normalize-height', keys: { key: 'arrowleft', ctrl: true, alt: true },
category: 'normalize', description: 'Normalize height', needsSelection: true, minSelection: 2, category: 'normalize', description: 'Normalize height', needsSelection: true, minSelection: 2,
handler: (ctx) => withBreak(ctx, (objs) => ops.normalizeHeight(objs)), handler: (ctx) => {
ops.normalizeHeight(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'normalize-width', keys: { key: 'arrowright', ctrl: true, alt: true }, id: 'normalize-width', keys: { key: 'arrowright', ctrl: true, alt: true },
category: 'normalize', description: 'Normalize width', needsSelection: true, minSelection: 2, category: 'normalize', description: 'Normalize width', needsSelection: true, minSelection: 2,
handler: (ctx) => withBreak(ctx, (objs) => ops.normalizeWidth(objs)), handler: (ctx) => {
ops.normalizeWidth(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
// ═══════════════════════════════════════ // ═══════════════════════════════════════
// ARRANGEMENT // ARRANGEMENT
// ═══════════════════════════════════════ // ═══════════════════════════════════════
// FIX: Changed from Ctrl+P (browser print) to Ctrl+Shift+P
{ {
id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true }, id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true },
category: 'arrangement', description: 'Arrange optimal (pack)', needsSelection: true, minSelection: 2, category: 'arrangement', description: 'Arrange optimal (pack)', needsSelection: true, minSelection: 2,
handler: (ctx) => withBreak(ctx, (objs) => ops.arrangeOptimal(objs)), handler: (ctx) => {
ops.arrangeOptimal(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'arrange-by-name', keys: { key: 'n', ctrl: true, alt: true }, id: 'arrange-by-name', keys: { key: 'n', ctrl: true, alt: true },
category: 'arrangement', description: 'Arrange by name', needsSelection: true, minSelection: 2, category: 'arrangement', description: 'Arrange by name', needsSelection: true, minSelection: 2,
handler: (ctx) => withBreak(ctx, (objs) => ops.arrangeByName(objs)), handler: (ctx) => {
ops.arrangeByName(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'arrange-by-order', keys: { key: 'o', ctrl: true, alt: true }, id: 'arrange-by-order', keys: { key: 'o', ctrl: true, alt: true },
category: 'arrangement', description: 'Arrange by z-order', needsSelection: true, minSelection: 2, category: 'arrangement', description: 'Arrange by z-order', needsSelection: true, minSelection: 2,
handler: (ctx) => { handler: (ctx) => {
const { objects, restore } = ops.breakSelection(ctx.canvas); ops.arrangeByZOrder(ctx.selection.getSelectedItems());
ops.arrangeByZOrder(objects, ctx.canvas.getObjects()); ctx.onChange();
restore();
ctx.canvas.requestRenderAll(); ctx.onCanvasChange();
}, },
}, },
{ {
id: 'arrange-random', keys: { key: 'r', ctrl: true, alt: true }, id: 'arrange-random', keys: { key: 'r', ctrl: true, alt: true },
category: 'arrangement', description: 'Arrange randomly', needsSelection: true, minSelection: 2, category: 'arrangement', description: 'Arrange randomly', needsSelection: true, minSelection: 2,
handler: (ctx) => withBreak(ctx, (objs) => ops.arrangeRandomly(objs)), handler: (ctx) => {
ops.arrangeRandomly(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
{ {
id: 'stack', keys: { key: 's', ctrl: true, alt: true }, id: 'stack', keys: { key: 's', ctrl: true, alt: true },
category: 'arrangement', description: 'Stack (pile on top)', needsSelection: true, minSelection: 2, category: 'arrangement', description: 'Stack (pile on top)', needsSelection: true, minSelection: 2,
handler: (ctx) => withBreak(ctx, (objs) => ops.stackObjects(objs)), handler: (ctx) => {
ops.stackObjects(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
// ═══════════════════════════════════════ // ═══════════════════════════════════════
@@ -136,47 +166,49 @@ export const shortcuts: ShortcutDef[] = [
id: 'flip-h', keys: { key: 'h', alt: true, shift: true }, id: 'flip-h', keys: { key: 'h', alt: true, shift: true },
category: 'image', description: 'Flip horizontal', needsSelection: true, category: 'image', description: 'Flip horizontal', needsSelection: true,
handler: (ctx) => { handler: (ctx) => {
ops.flipHorizontal(ctx.getActiveObjects()); ops.flipHorizontal(ctx.selection.getSelectedItems());
ctx.canvas.requestRenderAll(); ctx.onCanvasChange(); ctx.onChange();
}, },
}, },
{ {
id: 'flip-v', keys: { key: 'v', alt: true, shift: true }, id: 'flip-v', keys: { key: 'v', alt: true, shift: true },
category: 'image', description: 'Flip vertical', needsSelection: true, category: 'image', description: 'Flip vertical', needsSelection: true,
handler: (ctx) => { handler: (ctx) => {
ops.flipVertical(ctx.getActiveObjects()); ops.flipVertical(ctx.selection.getSelectedItems());
ctx.canvas.requestRenderAll(); ctx.onCanvasChange(); ctx.onChange();
}, },
}, },
{ {
id: 'reset-transform', keys: { key: 't', ctrl: true, shift: true }, id: 'reset-transform', keys: { key: 't', ctrl: true, shift: true },
category: 'image', description: 'Reset transform', needsSelection: true, category: 'image', description: 'Reset transform', needsSelection: true,
handler: (ctx) => { handler: (ctx) => {
ops.resetTransform(ctx.getActiveObjects()); ops.resetTransform(ctx.selection.getSelectedItems());
ctx.canvas.requestRenderAll(); ctx.onCanvasChange(); ctx.onChange();
}, },
}, },
{ {
id: 'toggle-grayscale', keys: { key: 'g', alt: true }, id: 'toggle-grayscale', keys: { key: 'g', alt: true },
category: 'image', description: 'Toggle grayscale', needsSelection: true, category: 'image', description: 'Toggle grayscale', needsSelection: true,
handler: (ctx) => { handler: (ctx) => {
ops.toggleGrayscale(ctx.getActiveObjects()); ops.toggleGrayscale(ctx.selection.getSelectedItems());
ctx.canvas.requestRenderAll(); ctx.onCanvasChange(); ctx.onChange();
}, },
}, },
{ {
id: 'toggle-locked', keys: { key: 'l', alt: true }, id: 'toggle-locked', keys: { key: 'l', alt: true },
category: 'image', description: 'Toggle locked', needsSelection: true, category: 'image', description: 'Toggle locked', needsSelection: true,
handler: (ctx) => { handler: (ctx) => {
ops.toggleLocked(ctx.getActiveObjects()); ops.toggleLocked(ctx.selection.getSelectedItems());
ctx.canvas.requestRenderAll(); ctx.refreshLayers(); ctx.refreshLayers();
}, },
}, },
// FIX: Changed from Ctrl+Y (conflicts with redo) to Ctrl+Shift+Y
{ {
id: 'overlay-compare', keys: { key: 'y', ctrl: true, shift: true }, id: 'overlay-compare', keys: { key: 'y', ctrl: true, shift: true },
category: 'image', description: 'Overlay / compare', needsSelection: true, minSelection: 2, category: 'image', description: 'Overlay / compare', needsSelection: true, minSelection: 2,
handler: (ctx) => withBreak(ctx, (objs) => ops.overlayCompare(objs)), handler: (ctx) => {
ops.overlayCompare(ctx.selection.getSelectedItems());
ctx.onChange();
},
}, },
// ═══════════════════════════════════════ // ═══════════════════════════════════════
@@ -187,8 +219,7 @@ export const shortcuts: ShortcutDef[] = [
id: 'clear-selection', keys: { key: 'escape' }, id: 'clear-selection', keys: { key: 'escape' },
category: 'navigation', description: 'Clear selection', category: 'navigation', description: 'Clear selection',
handler: (ctx) => { handler: (ctx) => {
ctx.canvas.discardActiveObject(); ctx.selection.clear();
ctx.canvas.requestRenderAll();
}, },
}, },
{ {
@@ -196,40 +227,51 @@ export const shortcuts: ShortcutDef[] = [
category: 'navigation', description: 'Fit all in view', category: 'navigation', description: 'Fit all in view',
handler: (ctx) => ctx.fitAll(), handler: (ctx) => ctx.fitAll(),
}, },
// FIX: Bare arrow Left/Right only cycle when nothing is selected. // Bare arrow Left/Right only cycle when nothing is selected.
// When something IS selected, they're no-ops (prevent accidental cycling). // When something IS selected, they're no-ops (prevent accidental cycling).
// Layer ordering uses ] / [ (restored) to avoid arrow conflicts. // Layer ordering uses ] / [ to avoid arrow conflicts.
{ {
id: 'cycle-next', keys: { key: 'arrowright' }, id: 'cycle-next', keys: { key: 'arrowright' },
category: 'navigation', description: 'Select next object', category: 'navigation', description: 'Select next object',
handler: (ctx) => { handler: (ctx) => {
// Only cycle when nothing selected — if selected, do nothing if (ctx.selection.selectedIds.size > 0) return;
if (ctx.getActiveObjects().length > 0) return; const all = ctx.scene.getAllItems();
const objects = ctx.canvas.getObjects(); if (all.length === 0) return;
if (objects.length === 0) return; // Sort by z to get consistent order
ctx.canvas.setActiveObject(objects[0]); all.sort((a, b) => a.data.z - b.data.z);
ctx.canvas.requestRenderAll(); ctx.selection.selectOnly(all[0].id);
}, },
}, },
{ {
id: 'cycle-prev', keys: { key: 'arrowleft' }, id: 'cycle-prev', keys: { key: 'arrowleft' },
category: 'navigation', description: 'Select previous object', category: 'navigation', description: 'Select previous object',
handler: (ctx) => { handler: (ctx) => {
if (ctx.getActiveObjects().length > 0) return; if (ctx.selection.selectedIds.size > 0) return;
const objects = ctx.canvas.getObjects(); const all = ctx.scene.getAllItems();
if (objects.length === 0) return; if (all.length === 0) return;
ctx.canvas.setActiveObject(objects[objects.length - 1]); all.sort((a, b) => a.data.z - b.data.z);
ctx.canvas.requestRenderAll(); ctx.selection.selectOnly(all[all.length - 1].id);
}, },
}, },
// FIX: Layer ordering restored to ] / [ (not arrow keys — those conflict with cycle/nudge) // Layer ordering: ] brings forward, [ sends backward
{ {
id: 'send-to-front', keys: { key: ']' }, id: 'send-to-front', keys: { key: ']' },
category: 'navigation', description: 'Bring forward', category: 'navigation', description: 'Bring forward',
needsSelection: true, needsSelection: true,
handler: (ctx) => { handler: (ctx) => {
ctx.getActiveObjects().forEach((obj) => (ctx.canvas as any).bringObjectForward(obj)); const selected = ctx.selection.getSelectedItems();
ctx.canvas.requestRenderAll(); ctx.onCanvasChange(); const all = ctx.scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
// For each selected item, swap z with the next non-selected item above it
const selectedIds = new Set(selected.map((s) => s.id));
for (let i = all.length - 2; i >= 0; i--) {
if (selectedIds.has(all[i].id) && !selectedIds.has(all[i + 1].id)) {
const tmp = all[i].data.z;
all[i].data.z = all[i + 1].data.z;
all[i + 1].data.z = tmp;
}
}
ctx.scene._applyZOrder();
ctx.onChange();
}, },
}, },
{ {
@@ -237,8 +279,18 @@ export const shortcuts: ShortcutDef[] = [
category: 'navigation', description: 'Send backward', category: 'navigation', description: 'Send backward',
needsSelection: true, needsSelection: true,
handler: (ctx) => { handler: (ctx) => {
ctx.getActiveObjects().forEach((obj) => (ctx.canvas as any).sendObjectBackwards(obj)); const selected = ctx.selection.getSelectedItems();
ctx.canvas.requestRenderAll(); ctx.onCanvasChange(); const all = ctx.scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
const selectedIds = new Set(selected.map((s) => s.id));
for (let i = 1; i < all.length; i++) {
if (selectedIds.has(all[i].id) && !selectedIds.has(all[i - 1].id)) {
const tmp = all[i].data.z;
all[i].data.z = all[i - 1].data.z;
all[i - 1].data.z = tmp;
}
}
ctx.scene._applyZOrder();
ctx.onChange();
}, },
}, },
@@ -250,23 +302,17 @@ export const shortcuts: ShortcutDef[] = [
id: 'select-all', keys: { key: 'a', ctrl: true }, id: 'select-all', keys: { key: 'a', ctrl: true },
category: 'editing', description: 'Select all', category: 'editing', description: 'Select all',
handler: (ctx) => { handler: (ctx) => {
ctx.canvas.discardActiveObject(); ctx.selection.selectAll();
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 }, id: 'copy', keys: { key: 'c', ctrl: true },
category: 'editing', description: 'Copy', category: 'editing', description: 'Copy',
handler: (ctx) => { handler: (ctx) => {
const active = ctx.getActiveObjects(); const selected = ctx.selection.getSelectedItems();
if (active.length > 0) { if (selected.length > 0) {
ctx.clipboardRef.current = [...active]; ctx.clipboardRef.current = [...selected];
ctx.writeCanvasToClipboard(active); ctx.writeCanvasToClipboard(selected);
ctx.showToast('Copied'); ctx.showToast('Copied');
} else { } else {
ctx.writeCanvasToClipboard(); ctx.writeCanvasToClipboard();
@@ -278,8 +324,8 @@ export const shortcuts: ShortcutDef[] = [
id: 'copy-as-image', keys: { key: 'c', ctrl: true, shift: true }, id: 'copy-as-image', keys: { key: 'c', ctrl: true, shift: true },
category: 'editing', description: 'Copy as image to clipboard', category: 'editing', description: 'Copy as image to clipboard',
handler: (ctx) => { handler: (ctx) => {
const active = ctx.getActiveObjects(); const selected = ctx.selection.getSelectedItems();
ctx.writeCanvasToClipboard(active.length > 0 ? active : undefined); ctx.writeCanvasToClipboard(selected.length > 0 ? selected : undefined);
}, },
}, },
{ {
@@ -287,33 +333,38 @@ export const shortcuts: ShortcutDef[] = [
category: 'editing', description: 'Paste', category: 'editing', description: 'Paste',
handler: async (ctx) => { handler: async (ctx) => {
if (ctx.clipboardRef.current.length === 0) return; if (ctx.clipboardRef.current.length === 0) return;
const newObjs: FabricObject[] = []; const newItems: typeof ctx.clipboardRef.current = [];
for (const original of ctx.clipboardRef.current) { for (const original of ctx.clipboardRef.current) {
try { // Clone: duplicate the item data with new ID and offset position
const obj = await ctx.cloneFabricObject(original, 20, 20); const newData = {
ctx.canvas.add(obj); ...original.data,
newObjs.push(obj); id: crypto.randomUUID(),
} catch (err) { x: original.data.x + 20,
console.error('Paste object failed:', err); y: original.data.y + 20,
} z: ctx.scene.nextZ(),
};
await ctx.scene._createItem(newData, true);
const newItem = ctx.scene.getById(newData.id);
if (newItem) newItems.push(newItem);
} }
ctx.clipboardRef.current = newObjs; ctx.clipboardRef.current = newItems;
ctx.canvas.requestRenderAll(); ctx.scene._applyZOrder();
ctx.onCanvasChange(); ctx.onChange();
}, },
}, },
{ {
id: 'cut', keys: { key: 'x', ctrl: true }, id: 'cut', keys: { key: 'x', ctrl: true },
category: 'editing', description: 'Cut', category: 'editing', description: 'Cut',
handler: (ctx) => { handler: (ctx) => {
const active = ctx.getActiveObjects(); const selected = ctx.selection.getSelectedItems();
if (active.length === 0) return; if (selected.length === 0) return;
ctx.clipboardRef.current = [...active]; ctx.clipboardRef.current = [...selected];
ctx.writeCanvasToClipboard(active); ctx.writeCanvasToClipboard(selected);
active.forEach((obj) => ctx.canvas.remove(obj)); for (const item of selected) {
ctx.canvas.discardActiveObject(); ctx.scene.removeItem(item.id, true);
ctx.canvas.requestRenderAll(); }
ctx.onCanvasChange(); ctx.selection.clear();
ctx.onChange();
ctx.showToast('Cut'); ctx.showToast('Cut');
}, },
}, },
@@ -321,19 +372,21 @@ export const shortcuts: ShortcutDef[] = [
id: 'duplicate', keys: { key: 'd', ctrl: true }, id: 'duplicate', keys: { key: 'd', ctrl: true },
category: 'editing', description: 'Duplicate', category: 'editing', description: 'Duplicate',
handler: async (ctx) => { handler: async (ctx) => {
const active = ctx.getActiveObjects(); const selected = ctx.selection.getSelectedItems();
if (active.length === 0) return; if (selected.length === 0) return;
for (const original of active) { for (const original of selected) {
try { const newData = {
const obj = await ctx.cloneFabricObject(original, 20, 20); ...original.data,
ctx.canvas.add(obj); id: crypto.randomUUID(),
} catch (err) { x: original.data.x + 20,
console.error('Duplicate failed:', err); y: original.data.y + 20,
} z: ctx.scene.nextZ(),
};
await ctx.scene._createItem(newData, true);
} }
ctx.canvas.discardActiveObject(); ctx.selection.clear();
ctx.canvas.requestRenderAll(); ctx.scene._applyZOrder();
ctx.onCanvasChange(); ctx.onChange();
}, },
}, },
{ {
@@ -341,11 +394,12 @@ export const shortcuts: ShortcutDef[] = [
category: 'editing', description: 'Delete selected', category: 'editing', description: 'Delete selected',
needsSelection: true, needsSelection: true,
handler: (ctx) => { handler: (ctx) => {
const active = ctx.getActiveObjects(); const selected = ctx.selection.getSelectedItems();
active.forEach((obj) => ctx.canvas.remove(obj)); for (const item of selected) {
ctx.canvas.discardActiveObject(); ctx.scene.removeItem(item.id, true);
ctx.canvas.requestRenderAll(); }
ctx.onCanvasChange(); ctx.selection.clear();
ctx.onChange();
}, },
}, },
{ {
@@ -353,41 +407,42 @@ export const shortcuts: ShortcutDef[] = [
category: 'editing', description: 'Delete selected', category: 'editing', description: 'Delete selected',
needsSelection: true, needsSelection: true,
handler: (ctx) => { handler: (ctx) => {
const active = ctx.getActiveObjects(); const selected = ctx.selection.getSelectedItems();
active.forEach((obj) => ctx.canvas.remove(obj)); for (const item of selected) {
ctx.canvas.discardActiveObject(); ctx.scene.removeItem(item.id, true);
ctx.canvas.requestRenderAll(); }
ctx.onCanvasChange(); ctx.selection.clear();
ctx.onChange();
}, },
}, },
{ {
id: 'undo', keys: { key: 'z', ctrl: true }, id: 'undo', keys: { key: 'z', ctrl: true },
category: 'editing', description: 'Undo', category: 'editing', description: 'Undo',
handler: (ctx) => { handler: (ctx) => {
ctx.undoRef.current?.undo(); ctx.history.undo();
ctx.setCanUndo(ctx.undoRef.current?.canUndo() ?? false); ctx.setCanUndo(ctx.history.canUndo());
ctx.setCanRedo(ctx.undoRef.current?.canRedo() ?? false); ctx.setCanRedo(ctx.history.canRedo());
ctx.onCanvasChange(); ctx.onChange();
}, },
}, },
{ {
id: 'redo', keys: { key: 'z', ctrl: true, shift: true }, id: 'redo', keys: { key: 'z', ctrl: true, shift: true },
category: 'editing', description: 'Redo', category: 'editing', description: 'Redo',
handler: (ctx) => { handler: (ctx) => {
ctx.undoRef.current?.redo(); ctx.history.redo();
ctx.setCanUndo(ctx.undoRef.current?.canUndo() ?? false); ctx.setCanUndo(ctx.history.canUndo());
ctx.setCanRedo(ctx.undoRef.current?.canRedo() ?? false); ctx.setCanRedo(ctx.history.canRedo());
ctx.onCanvasChange(); ctx.onChange();
}, },
}, },
{ {
id: 'redo-y', keys: { key: 'y', ctrl: true }, id: 'redo-y', keys: { key: 'y', ctrl: true },
category: 'editing', description: 'Redo (alt)', category: 'editing', description: 'Redo (alt)',
handler: (ctx) => { handler: (ctx) => {
ctx.undoRef.current?.redo(); ctx.history.redo();
ctx.setCanUndo(ctx.undoRef.current?.canUndo() ?? false); ctx.setCanUndo(ctx.history.canUndo());
ctx.setCanRedo(ctx.undoRef.current?.canRedo() ?? false); ctx.setCanRedo(ctx.history.canRedo());
ctx.onCanvasChange(); ctx.onChange();
}, },
}, },
{ {
@@ -400,7 +455,7 @@ export const shortcuts: ShortcutDef[] = [
category: 'editing', description: 'Ungroup', needsSelection: true, category: 'editing', description: 'Ungroup', needsSelection: true,
handler: (ctx) => ctx.handleUngroup(), handler: (ctx) => ctx.handleUngroup(),
}, },
// FIX: Block Ctrl+S from opening browser save-as dialog // Block Ctrl+S from opening browser save-as dialog
{ {
id: 'save', keys: { key: 's', ctrl: true }, id: 'save', keys: { key: 's', ctrl: true },
category: 'editing', description: 'Save (auto-saves)', category: 'editing', description: 'Save (auto-saves)',
@@ -417,30 +472,27 @@ export const shortcuts: ShortcutDef[] = [
id: 'zoom-in', keys: { key: '=', ctrl: true }, id: 'zoom-in', keys: { key: '=', ctrl: true },
category: 'view', description: 'Zoom in', category: 'view', description: 'Zoom in',
handler: (ctx) => { handler: (ctx) => {
const z = Math.min(ctx.canvas.getZoom() * 1.2, 5); const current = ctx.viewport.scale.x;
const center = ctx.canvas.getCenterPoint(); const z = Math.min(current * 1.2, 5);
ctx.canvas.zoomToPoint(center, z); ctx.viewport.setZoom(z, true);
ctx.canvas.requestRenderAll();
}, },
}, },
{ {
id: 'zoom-in-plus', keys: { key: '+', ctrl: true }, id: 'zoom-in-plus', keys: { key: '+', ctrl: true },
category: 'view', description: 'Zoom in', category: 'view', description: 'Zoom in',
handler: (ctx) => { handler: (ctx) => {
const z = Math.min(ctx.canvas.getZoom() * 1.2, 5); const current = ctx.viewport.scale.x;
const center = ctx.canvas.getCenterPoint(); const z = Math.min(current * 1.2, 5);
ctx.canvas.zoomToPoint(center, z); ctx.viewport.setZoom(z, true);
ctx.canvas.requestRenderAll();
}, },
}, },
{ {
id: 'zoom-out', keys: { key: '-', ctrl: true }, id: 'zoom-out', keys: { key: '-', ctrl: true },
category: 'view', description: 'Zoom out', category: 'view', description: 'Zoom out',
handler: (ctx) => { handler: (ctx) => {
const z = Math.max(ctx.canvas.getZoom() / 1.2, 0.1); const current = ctx.viewport.scale.x;
const center = ctx.canvas.getCenterPoint(); const z = Math.max(current / 1.2, 0.1);
ctx.canvas.zoomToPoint(center, z); ctx.viewport.setZoom(z, true);
ctx.canvas.requestRenderAll();
}, },
}, },
{ {
@@ -448,7 +500,7 @@ export const shortcuts: ShortcutDef[] = [
category: 'view', description: 'Toggle grid', category: 'view', description: 'Toggle grid',
handler: (ctx) => ctx.toggleGrid(), handler: (ctx) => ctx.toggleGrid(),
}, },
// FIX: '?' key — when Shift+/ is pressed, e.key is '?' not '/' // '?' key — when Shift+/ is pressed, e.key is '?' not '/'
{ {
id: 'show-help', keys: { key: '?' }, id: 'show-help', keys: { key: '?' },
category: 'view', description: 'Show shortcuts help', category: 'view', description: 'Show shortcuts help',
+11 -9
View File
@@ -3,7 +3,10 @@
* Actual shortcut definitions live in shortcut-definitions.ts. * Actual shortcut definitions live in shortcut-definitions.ts.
*/ */
import { Canvas, FabricObject } from 'fabric'; import type { SceneManager, SceneItem } from './SceneManager';
import type { SelectionManager } from './SelectionManager';
import type { UndoManager } from './history';
import type { Viewport } from 'pixi-viewport';
import { ToolType } from './tools'; import { ToolType } from './tools';
export interface ShortcutKeys { export interface ShortcutKeys {
@@ -24,17 +27,15 @@ export interface ShortcutDef {
} }
export interface ShortcutContext { export interface ShortcutContext {
canvas: Canvas; scene: SceneManager;
getActiveObjects: () => FabricObject[]; selection: SelectionManager;
getActiveObject: () => FabricObject | null; history: UndoManager;
clipboardRef: React.MutableRefObject<FabricObject[]>; viewport: Viewport;
cloneFabricObject: (obj: any, dx: number, dy: number) => Promise<any>; onChange: () => void;
writeCanvasToClipboard: (objects?: FabricObject[]) => Promise<void>; clipboardRef: React.MutableRefObject<SceneItem[]>;
onCanvasChange: () => void;
showToast: (msg: string) => void; showToast: (msg: string) => void;
fitAll: () => void; fitAll: () => void;
setActiveTool: (tool: ToolType) => void; setActiveTool: (tool: ToolType) => void;
undoRef: React.MutableRefObject<any>;
setCanUndo: (v: boolean) => void; setCanUndo: (v: boolean) => void;
setCanRedo: (v: boolean) => void; setCanRedo: (v: boolean) => void;
refreshLayers: () => void; refreshLayers: () => void;
@@ -42,6 +43,7 @@ export interface ShortcutContext {
handleUngroup: () => void; handleUngroup: () => void;
toggleGrid: () => void; toggleGrid: () => void;
toggleShowHelp: () => void; toggleShowHelp: () => void;
writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>;
} }
/** /**