From 85eb57559e93e2c93b1005589b6aac6fcb7124fd Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 8 Sep 2026 12:29:55 +0300 Subject: [PATCH] fix(keyboard): match Figma zoom behavior --- src/app/editor/profiler/index.ts | 12 +++- src/app/shell/keyboard/registry.ts | 27 +++++--- tests/e2e/keyboard/shortcuts.spec.ts | 98 ++++++++++++++++++++++++---- 3 files changed, 114 insertions(+), 23 deletions(-) diff --git a/src/app/editor/profiler/index.ts b/src/app/editor/profiler/index.ts index cba0271f0..1b7b41fb6 100644 --- a/src/app/editor/profiler/index.ts +++ b/src/app/editor/profiler/index.ts @@ -1,8 +1,16 @@ import type { Editor } from '@open-pencil/core/editor' export function createProfilerActions(editor: Editor) { + function activeCanvas() { + return ( + document.querySelector( + '[data-active-pane="true"] [data-test-id="canvas-element"]' + ) ?? document.querySelector('[data-test-id="canvas-element"]') + ) + } + function viewportScreenCenter() { - const canvas = document.querySelector('[data-test-id="canvas-element"]') + const canvas = activeCanvas() if (canvas) { const rect = canvas.getBoundingClientRect() return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } @@ -11,7 +19,7 @@ export function createProfilerActions(editor: Editor) { } function viewportCanvasCenter() { - const canvas = document.querySelector('[data-test-id="canvas-element"]') + const canvas = activeCanvas() if (canvas) { const rect = canvas.getBoundingClientRect() return { x: rect.width / 2, y: rect.height / 2 } diff --git a/src/app/shell/keyboard/registry.ts b/src/app/shell/keyboard/registry.ts index 7237d3242..cce40c2f6 100644 --- a/src/app/shell/keyboard/registry.ts +++ b/src/app/shell/keyboard/registry.ts @@ -40,6 +40,13 @@ function commandShortcuts(...commands: EditorCommandId[]): ShortcutDefinition[] }) } +function zoomAtViewportCenter(delta: number): ShortcutAction { + return ({ store }) => { + const center = store.viewportCanvasCenter() + store.applyZoom(delta, center.x, center.y) + } +} + function opacityBindings(): ShortcutDefinition[] { return ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].map((digit) => ({ id: `selection-opacity-${digit}`, @@ -141,21 +148,25 @@ export function registerKeyboardShortcuts(options: KeyboardShortcutOptions) { { id: 'zoom-in', keys: [appMenuTinykeysShortcut('zoom-in') ?? '$mod+Equal', '$mod+Shift+Equal'], - run: ({ store }) => { - const center = store.viewportScreenCenter() - store.applyZoom(-100, center.x, center.y) - }, + run: zoomAtViewportCenter(-100), global: true }, { id: 'zoom-out', keys: appMenuTinykeysShortcut('zoom-out') ?? '$mod+Minus', - run: ({ store }) => { - const center = store.viewportScreenCenter() - store.applyZoom(100, center.x, center.y) - }, + run: zoomAtViewportCenter(100), global: true }, + { + id: 'zoom-in-unmodified', + keys: 'Shift+Equal', + run: zoomAtViewportCenter(-100) + }, + { + id: 'zoom-out-unmodified', + keys: 'Minus', + run: zoomAtViewportCenter(100) + }, { id: 'close-tab', keys: appMenuTinykeysShortcut('close') ?? '$mod+KeyW', diff --git a/tests/e2e/keyboard/shortcuts.spec.ts b/tests/e2e/keyboard/shortcuts.spec.ts index 46d176625..5d7dcb981 100644 --- a/tests/e2e/keyboard/shortcuts.spec.ts +++ b/tests/e2e/keyboard/shortcuts.spec.ts @@ -329,22 +329,27 @@ test.describe('zoom shortcuts', () => { }) const pressShortcut = async (shortcut: string, code: 'Equal' | 'Minus') => { - const observedEvent = editor.page.evaluate( - (expectedCode) => - new Promise<{ defaultPrevented: boolean; shiftKey: boolean }>((resolve) => { - const handleKeydown = (event: KeyboardEvent) => { - if (event.code !== expectedCode) return - window.removeEventListener('keydown', handleKeydown) - resolve({ defaultPrevented: event.defaultPrevented, shiftKey: event.shiftKey }) - } - window.addEventListener('keydown', handleKeydown) - }), - code - ) + await editor.page.evaluate((expectedCode) => { + delete document.documentElement.dataset.observedShortcut + const handleKeydown = (event: KeyboardEvent) => { + if (event.code !== expectedCode) return + window.removeEventListener('keydown', handleKeydown) + document.documentElement.dataset.observedShortcut = JSON.stringify({ + defaultPrevented: event.defaultPrevented, + shiftKey: event.shiftKey + }) + } + window.addEventListener('keydown', handleKeydown) + }, code) await editor.page.keyboard.press(shortcut) await editor.canvas.waitForRender() - return { event: await observedEvent, zoom: await getZoom() } + const event = await editor.page.evaluate(() => { + const observed = document.documentElement.dataset.observedShortcut + if (!observed) throw new Error('Expected shortcut keydown event') + return JSON.parse(observed) as { defaultPrevented: boolean; shiftKey: boolean } + }) + return { event, zoom: await getZoom() } } const zoomedIn = await pressShortcut('ControlOrMeta+Equal', 'Equal') @@ -359,6 +364,73 @@ test.describe('zoom shortcuts', () => { expect(zoomedOut.zoom).toBeLessThan(zoomedInWithShift.zoom) }) + test('Figma plus and minus shortcuts zoom around the active canvas center', async () => { + await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + store.zoomTo100() + store.splitPane(store.activePaneId.value, 'horizontal') + }) + await expect(editor.page.locator('[data-active-pane]')).toHaveCount(2) + const splitHandle = editor.page.locator('[data-split-id]').first() + const splitHandleBounds = await splitHandle.boundingBox() + if (!splitHandleBounds) throw new Error('Expected canvas split handle') + await editor.page.mouse.move( + splitHandleBounds.x, + splitHandleBounds.y + splitHandleBounds.height / 2 + ) + await editor.page.mouse.down() + await editor.page.mouse.move( + splitHandleBounds.x + 100, + splitHandleBounds.y + splitHandleBounds.height / 2 + ) + await editor.page.mouse.up() + const activeCanvas = editor.page.locator('[data-active-pane="true"]') + const inactiveCanvas = editor.page.locator('[data-active-pane="false"]') + const activeBounds = await activeCanvas.boundingBox() + const inactiveBounds = await inactiveCanvas.boundingBox() + if (!activeBounds || !inactiveBounds) throw new Error('Expected canvas pane bounds') + expect(activeBounds.width).toBeLessThan(inactiveBounds.width) + + const centerBefore = await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const center = store.viewportCanvasCenter() + return { center, canvasPoint: store.screenToCanvas(center.x, center.y) } + }) + + expect(centerBefore.center.x).toBeCloseTo(activeBounds.width / 2) + expect(centerBefore.center.y).toBeCloseTo(activeBounds.height / 2) + + await editor.page.keyboard.press('Shift+Equal') + await editor.canvas.waitForRender() + const zoomedIn = await getZoom() + const centerAfter = await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const center = store.viewportCanvasCenter() + return { center, canvasPoint: store.screenToCanvas(center.x, center.y) } + }) + + await editor.page.keyboard.press('Minus') + await editor.canvas.waitForRender() + + const inactivePaneId = await inactiveCanvas.getAttribute('data-pane-id') + if (!inactivePaneId) throw new Error('Expected inactive canvas pane') + await editor.page.evaluate((paneId) => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + store.closePane(paneId) + }, inactivePaneId) + await expect(editor.page.locator('[data-active-pane]')).toHaveCount(1) + + expect(centerAfter.center).toEqual(centerBefore.center) + expect(centerAfter.canvasPoint.x).toBeCloseTo(centerBefore.canvasPoint.x) + expect(centerAfter.canvasPoint.y).toBeCloseTo(centerBefore.canvasPoint.y) + expect(zoomedIn).toBeGreaterThan(1) + expect(await getZoom()).toBeLessThan(zoomedIn) + }) + test('⌘0 zooms to 100%', async () => { await editor.canvas.clearCanvas() await editor.canvas.drawRect(100, 100, 60, 60)