Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 70 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,12 @@ Constructor for UndoManager

UnderManagerOptions

| Param | Type | Description |
| -------- | ------------------------------------------------------ | --------------------------------------------------------------------------------- |
| maxSize | <code>Object</code> | The maximum number of entries in the stack. Default is 10000. |
| onChange | <code>(undoManager: UndoRedoStackState) => void</code> | A callback function to be called when the stack canUndo or canRedo values change. |
| Param | Type | Description |
| --------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| maxSize | <code>number</code> | The maximum number of entries in the stack. Default is 10000. |
| onChange | <code>(undoManager: UndoRedoStackState) => void</code> | A callback function to be called when the stack canUndo or canRedo values change. |
| enableShortcuts | <code>boolean</code> | Whether keyboard shortcut handling is active when `addListeners` is called. Default is `false`. |
| shortcuts | <code>ShortcutOptions</code> | Per-platform overrides for the default shortcut keys. Only used when `enableShortcuts` is `true`. |

**Example**

Expand Down Expand Up @@ -148,6 +150,70 @@ Executes the redo function of the current entry in the undoRedo stack. If the cu

---

## <b>addListeners</b>

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 | <code>EventTarget</code> | 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);
```

---

## <b>removeListeners</b>

Removes keydown event listeners previously added via `addListeners`.

| Param | Type | Description |
| ------ | ------------------------ | ------------------------------------------------------ |
| target | <code>EventTarget</code> | The target to remove listeners from (e.g. `window`). |

**Example**

```ts
undoManager.removeListeners(window);
```

---

## <b>startGroup</b>

Sets the undo manager to add `groupID` to all subsequent entries. Sets the `isGrouping` internal state of the stack to `true`
Expand Down
166 changes: 166 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading