diff --git a/README.md b/README.md index ade86b2..c00bedd 100644 --- a/README.md +++ b/README.md @@ -83,10 +83,12 @@ Constructor for UndoManager UnderManagerOptions -| Param | Type | Description | -| -------- | ------------------------------------------------------ | --------------------------------------------------------------------------------- | -| maxSize | Object | The maximum number of entries in the stack. Default is 10000. | -| onChange | (undoManager: UndoRedoStackState) => void | A callback function to be called when the stack canUndo or canRedo values change. | +| Param | Type | Description | +| --------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| maxSize | number | The maximum number of entries in the stack. Default is 10000. | +| onChange | (undoManager: UndoRedoStackState) => void | A callback function to be called when the stack canUndo or canRedo values change. | +| enableShortcuts | boolean | Whether keyboard shortcut handling is active when `addListeners` is called. Default is `false`. | +| shortcuts | ShortcutOptions | Per-platform overrides for the default shortcut keys. Only used when `enableShortcuts` is `true`. | **Example** @@ -148,6 +150,70 @@ Executes the redo function of the current entry in the undoRedo stack. If the cu --- +## addListeners + +Attaches keydown event listeners to the given `EventTarget` for undo/redo keyboard shortcuts. Requires `enableShortcuts: true` in the constructor. + +Default shortcuts (using `ctrlKey` on Windows/Linux, `metaKey` on Mac): + +| Action | Keys | +| ------ | ---- | +| Undo | Ctrl+Z / Cmd+Z | +| Redo | Ctrl+Y / Cmd+Y or Ctrl+Shift+Z / Cmd+Shift+Z | + +| Param | Type | Description | +| ------ | ------------------------ | -------------------------------------------------- | +| target | EventTarget | The target to attach listeners to (e.g. `window`). | + +**Basic example** + +```ts +const undoManager = new UndoManager({ enableShortcuts: true }); +undoManager.addListeners(window); +``` + +**Custom key bindings** + +Key bindings are configured per-platform via `shortcuts.mac` and `shortcuts.other`. Each action accepts a `KeyBinding` (a subset of `KeyboardEventInit`: `key`, `shiftKey`, `altKey`, `ctrlKey`, `metaKey`) or an array of them. + +```ts +const undoManager = new UndoManager({ + enableShortcuts: true, + shortcuts: { + // Ctrl modifier (Windows / Linux) + other: { + undo: { key: 'z' }, + redo: [{ key: 'y' }, { key: 'z', shiftKey: true }], + }, + // Cmd modifier (Mac) + mac: { + undo: { key: 'z' }, + redo: [{ key: 'z', shiftKey: true }], // only Cmd+Shift+Z, no Cmd+Y + }, + }, +}); + +undoManager.addListeners(window); +``` + +--- + +## removeListeners + +Removes keydown event listeners previously added via `addListeners`. + +| Param | Type | Description | +| ------ | ------------------------ | ------------------------------------------------------ | +| target | EventTarget | The target to remove listeners from (e.g. `window`). | + +**Example** + +```ts +undoManager.removeListeners(window); +``` + +--- + ## startGroup Sets the undo manager to add `groupID` to all subsequent entries. Sets the `isGrouping` internal state of the stack to `true` diff --git a/src/index.test.ts b/src/index.test.ts index 2299d58..f5f37f7 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -322,4 +322,170 @@ describe('UndoManager', () => { } expect(expectedError.message).to.be.equal('Async error in redo'); }); + + describe('addListeners / removeListeners', () => { + let target: EventTarget; + const fireKey = ( + key: string, + opts: {ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; altKey?: boolean} = {}, + ) => { + const event = Object.assign(new Event('keydown'), { + key, + ctrlKey: opts.ctrlKey ?? false, + metaKey: opts.metaKey ?? false, + shiftKey: opts.shiftKey ?? false, + altKey: opts.altKey ?? false, + preventDefault: () => undefined, + }); + target.dispatchEvent(event); + }; + + beforeEach(() => { + target = new EventTarget(); + modifiedValue = 0; + // enableShortcuts must be true for these tests + undoManager = new UndoManager({onChange: onChangeSpy, enableShortcuts: true}); + }); + + it('Ctrl+Z triggers undo', async () => { + await undoManager.add(OneRemoveOneAdd); + undoManager.addListeners(target); + fireKey('z', {ctrlKey: true}); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(-1); + undoManager.removeListeners(target); + }); + + it('Ctrl+Shift+Z triggers redo', async () => { + await undoManager.add(OneRemoveOneAdd); + await undoManager.undo(); + undoManager.addListeners(target); + fireKey('z', {ctrlKey: true, shiftKey: true}); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(0); + undoManager.removeListeners(target); + }); + + it('Ctrl+Y triggers redo', async () => { + await undoManager.add(OneRemoveOneAdd); + await undoManager.undo(); + undoManager.addListeners(target); + fireKey('y', {ctrlKey: true}); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(0); + undoManager.removeListeners(target); + }); + + it('Cmd+Z (metaKey) triggers undo', async () => { + await undoManager.add(OneRemoveOneAdd); + undoManager.addListeners(target); + fireKey('z', {metaKey: true}); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(-1); + undoManager.removeListeners(target); + }); + + it('Cmd+Shift+Z (metaKey) triggers redo', async () => { + await undoManager.add(OneRemoveOneAdd); + await undoManager.undo(); + undoManager.addListeners(target); + fireKey('z', {metaKey: true, shiftKey: true}); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(0); + undoManager.removeListeners(target); + }); + + it('removeListeners stops handling events', async () => { + await undoManager.add(OneRemoveOneAdd); + undoManager.addListeners(target); + undoManager.removeListeners(target); + fireKey('z', {ctrlKey: true}); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(0); + }); + + it('does not trigger without modifier key', async () => { + await undoManager.add(OneRemoveOneAdd); + undoManager.addListeners(target); + fireKey('z'); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(0); + undoManager.removeListeners(target); + }); + + it('enableShortcuts: false disables shortcut handling', async () => { + undoManager = new UndoManager({enableShortcuts: false}); + await undoManager.add(OneRemoveOneAdd); + undoManager.addListeners(target); + fireKey('z', {ctrlKey: true}); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(0); + undoManager.removeListeners(target); + }); + + it('custom undo/redo keys (other platform)', async () => { + undoManager = new UndoManager({ + enableShortcuts: true, + shortcuts: { + other: { + undo: {key: 'u'}, + redo: {key: 'r'}, + }, + }, + }); + await undoManager.add(OneRemoveOneAdd); + undoManager.addListeners(target); + fireKey('u', {ctrlKey: true}); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(-1); + fireKey('r', {ctrlKey: true}); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(0); + undoManager.removeListeners(target); + }); + + it('custom undo/redo keys (mac)', async () => { + undoManager = new UndoManager({ + enableShortcuts: true, + shortcuts: { + mac: { + undo: {key: 'u'}, + redo: {key: 'r'}, + }, + }, + }); + await undoManager.add(OneRemoveOneAdd); + undoManager.addListeners(target); + fireKey('u', {metaKey: true}); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(-1); + fireKey('r', {metaKey: true}); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(0); + undoManager.removeListeners(target); + }); + + it('custom redo with shift binding', async () => { + undoManager = new UndoManager({ + enableShortcuts: true, + shortcuts: { + other: { + redo: {key: 'z', shiftKey: true}, + }, + }, + }); + await undoManager.add(OneRemoveOneAdd); + await undoManager.undo(); + undoManager.addListeners(target); + // Ctrl+Y should NOT trigger redo (not in custom config) + fireKey('y', {ctrlKey: true}); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(-1); + // Ctrl+Shift+Z should trigger redo + fireKey('z', {ctrlKey: true, shiftKey: true}); + await new Promise(r => setTimeout(r, 10)); + expect(modifiedValue).to.equal(0); + undoManager.removeListeners(target); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index f123606..c2c9e4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,14 +46,72 @@ export type UndoRedoStackState = { canRedo: boolean; }; +/** + * A single keyboard shortcut binding, using the same modifier key names as + * the browser's `KeyboardEvent` (`shiftKey`, `altKey`, `ctrlKey`, `metaKey`). + * Only the modifiers you specify are checked; unspecified ones default to `false`. + */ +export type KeyBinding = Pick< + KeyboardEventInit, + 'key' | 'shiftKey' | 'altKey' | 'ctrlKey' | 'metaKey' +> & {key: string}; + +/** + * Keyboard shortcut key mapping for a platform. + * Each action accepts a single binding or an array of bindings. + */ +export type ShortcutMap = { + /** Binding(s) for undo. Default: `{key: 'z'}`. */ + undo?: KeyBinding | KeyBinding[]; + /** + * Binding(s) for redo. + * Default: `[{key: 'y'}, {key: 'z', shiftKey: true}]`. + */ + redo?: KeyBinding | KeyBinding[]; +}; + +/** + * Per-platform shortcut key overrides passed to UndoManagerOption. + */ +export type ShortcutOptions = { + /** Shortcut keys on macOS (uses the Meta/Cmd modifier). */ + mac?: ShortcutMap; + /** Shortcut keys on all other platforms (uses the Ctrl modifier). */ + other?: ShortcutMap; +}; + /** * The arguments interface for the constructor of UndoManager. * @param maxSize The maximum number of entries in the stack. Default is 10000. * @param onChange A callback function to be called when the UndoRedoStackState values change. + * @param enableShortcuts Whether keyboard shortcut handling is active when addListeners is called. Default is true. + * @param shortcuts Per-platform overrides for the default shortcut keys. */ interface UndoManagerOption { maxSize?: number; onChange?: (undoRedoStackState: UndoRedoStackState) => void; + /** + * Whether keyboard shortcut handling is active when `addListeners` is called. + * Default is `true`. + */ + enableShortcuts?: boolean; + /** Override the default keyboard shortcut keys per platform. */ + shortcuts?: ShortcutOptions; +} + +function toArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value]; +} + +function matchesBinding(ke: KeyboardEvent, bindings: KeyBinding[]): boolean { + return bindings.some( + b => + ke.key === b.key && + (b.shiftKey ?? false) === ke.shiftKey && + (b.altKey ?? false) === ke.altKey && + (b.ctrlKey === undefined || b.ctrlKey === ke.ctrlKey) && + (b.metaKey === undefined || b.metaKey === ke.metaKey), + ); } export class UndoManager { @@ -76,6 +134,8 @@ export class UndoManager { }; private _onChange: OnChangeHandlerType = undefined; + private _enableShortcuts: boolean; + private _shortcuts: ShortcutOptions | undefined; /** * Constructor for UndoManager @@ -86,8 +146,10 @@ export class UndoManager { * }}); */ constructor(options: UndoManagerOption = {}) { - const {maxSize = 10_000, onChange} = options; + const {maxSize = 10_000, onChange, enableShortcuts = false, shortcuts} = options; this._maxSize = maxSize; + this._enableShortcuts = enableShortcuts; + this._shortcuts = shortcuts; if (onChange) { this._onChange = onChange; } @@ -205,6 +267,61 @@ export class UndoManager { } } + /** + * Handles keydown events to trigger undo/redo via keyboard shortcuts. + */ + private _handleKeydown = (e: Event): void => { + if (!this._enableShortcuts) return; + const ke = e as KeyboardEvent; + + // Check both independently so each uses its own shortcut config. + if (ke.metaKey) { + if (this._tryShortcut(e, ke, this._shortcuts?.mac)) return; + } + if (ke.ctrlKey) { + this._tryShortcut(e, ke, this._shortcuts?.other); + } + }; + + private _tryShortcut( + e: Event, + ke: KeyboardEvent, + map: ShortcutMap | undefined, + ): boolean { + const undoBindings = toArray(map?.undo ?? {key: 'z'}); + const redoBindings = toArray( + map?.redo ?? [{key: 'y'}, {key: 'z', shiftKey: true}], + ); + + if (matchesBinding(ke, undoBindings)) { + e.preventDefault(); + void this.undo(); + return true; + } + if (matchesBinding(ke, redoBindings)) { + e.preventDefault(); + void this.redo(); + return true; + } + return false; + } + + /** + * Adds keydown event listeners to the given EventTarget for undo/redo keyboard shortcuts. + * @param target The EventTarget (e.g. window) to attach listeners to. + */ + addListeners(target: EventTarget): void { + target.addEventListener('keydown', this._handleKeydown); + } + + /** + * Removes keydown event listeners previously added via `addListeners`. + * @param target The EventTarget (e.g. window) to remove listeners from. + */ + removeListeners(target: EventTarget): void { + target.removeEventListener('keydown', this._handleKeydown); + } + /** * Sets the undo manager to mark all subsequent added entries `groupID` to internal `lastGroupID` */