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`
*/