diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index cf596aa5406c..2aa449f24aea 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -11341,7 +11341,10 @@ export class Editor extends EventEmitter { // Don't pass right-click panning events to the state chart // as it causes unintended shape selection on release if (slideSpeed > 0) { - this.slideCamera({ speed: slideSpeed, direction: slideDirection }) + this.slideCamera({ + speed: slideSpeed, + direction: { x: slideDirection.x, y: slideDirection.y, z: 0 }, + }) } this._selectedShapeIdsAtPointerDown = [] this._didCaptureSelectionAtPointerDown = false diff --git a/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts b/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts index c31a72db04b5..b2ac8f80371f 100644 --- a/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts +++ b/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts @@ -155,11 +155,21 @@ export function useKeyboardShortcuts() { if (!reg.onKeyDown) continue for (const p of reg.parsed) { if (matchesEvent(e, p)) { - const prev = code ? heldKeyRegistrations.get(code) : undefined - // The held key already triggered a different shortcut; don't fall back to - // this one (or anything else) just because a modifier was released. - if (prev && prev !== reg) return - if (code) heldKeyRegistrations.set(code, reg) + if (code) { + // We only guard auto-repeat events. A fresh keypress (`e.repeat` is + // false) is always free to trigger whatever it matches, even on the same + // physical key — e.g. cmd+z (undo) then cmd+shift+z (redo), where macOS + // swallows the `z` keyup while cmd stays held, so we can't rely on keyup + // to clear the previous registration. + if (e.repeat) { + const prev = heldKeyRegistrations.get(code) + // The held key already triggered a different shortcut; don't fall back + // to this one (or anything else) just because a modifier was released. + if (prev && prev !== reg) return + } else { + heldKeyRegistrations.set(code, reg) + } + } reg.onKeyDown(e) break } diff --git a/packages/tldraw/src/test/rightClickPanning.test.ts b/packages/tldraw/src/test/rightClickPanning.test.ts index 16fa4c4d3815..bc1917bb29b2 100644 --- a/packages/tldraw/src/test/rightClickPanning.test.ts +++ b/packages/tldraw/src/test/rightClickPanning.test.ts @@ -1,3 +1,4 @@ +import { Vec } from '@tldraw/editor' import { TestEditor } from './TestEditor' describe('with rightClickPanning enabled (default)', () => { @@ -17,6 +18,16 @@ describe('with rightClickPanning enabled (default)', () => { expect(editor.inputs.getIsPanning()).toBe(false) }) + it('does not zoom when momentum panning on release', () => { + editor.pointerDown(100, 100, { button: 2 }) + editor.pointerMove(200, 200) + expect(editor.inputs.getIsPanning()).toBe(true) + editor.inputs.setPointerVelocity(new Vec(1, 1)) + editor.pointerUp(200, 200, { button: 2 }).forceTick() + + expect(editor.getCamera().z).toBe(1) + }) + it('does not pan on a static right-click', () => { editor.pointerDown(100, 100, { button: 2 }) editor.pointerUp(100, 100, { button: 2 }) diff --git a/packages/tldraw/src/test/ui/keyboardShortcuts.test.tsx b/packages/tldraw/src/test/ui/keyboardShortcuts.test.tsx index e08e5cbb00d1..d201865f56be 100644 --- a/packages/tldraw/src/test/ui/keyboardShortcuts.test.tsx +++ b/packages/tldraw/src/test/ui/keyboardShortcuts.test.tsx @@ -1,5 +1,5 @@ import { act } from '@testing-library/react' -import { Editor } from '@tldraw/editor' +import { createShapeId, Editor } from '@tldraw/editor' import { useEffect } from 'react' import { Tldraw } from '../../lib/Tldraw' import { useActions } from '../../lib/ui/context/actions' @@ -179,4 +179,29 @@ describe('keyboard shortcuts with a held key', () => { keydown(editor, { key: 'q', code: 'KeyQ' }) expect(editor.getInstanceState().isToolLocked).toBe(true) }) + + // Regression test for #9099: redo (cmd+shift+z) stopped firing after an undo (cmd+z) on + // macOS, where the browser swallows the `z` keyup while cmd stays held. The held-key + // tracking from #9099 never got cleared, so the stale undo registration blocked the redo + // on the same physical `KeyZ`. A fresh keypress must always be free to trigger its match. + it('fires redo after undo on the same physical key when the keyup is swallowed (cmd held)', async () => { + const { editor } = await setupFocusedEditor() + + const id = createShapeId() + act(() => { + editor.markHistoryStoppingPoint() + editor.createShape({ id, type: 'geo', x: 0, y: 0 }) + }) + expect(editor.getCurrentPageShapeIds().has(id)).toBe(true) + + // cmd+z undoes the shape creation. On macOS the `z` keyup is never delivered while cmd + // stays held, so we deliberately don't dispatch it. + keydown(editor, { key: 'z', code: 'KeyZ', metaKey: true }) + expect(editor.getCurrentPageShapeIds().has(id)).toBe(false) + + // Adding shift and pressing z again is a fresh keypress (not an auto-repeat), so it must + // trigger redo rather than being blocked by the stale undo registration on `KeyZ`. + keydown(editor, { key: 'z', code: 'KeyZ', metaKey: true, shiftKey: true }) + expect(editor.getCurrentPageShapeIds().has(id)).toBe(true) + }) })