fix(keyboard): match Figma zoom behavior

This commit is contained in:
Danila Poyarkov 2026-09-08 12:29:55 +03:00
parent 992102f3a5
commit 85eb57559e
3 changed files with 114 additions and 23 deletions

View file

@ -1,8 +1,16 @@
import type { Editor } from '@open-pencil/core/editor'
export function createProfilerActions(editor: Editor) {
function activeCanvas() {
return (
document.querySelector<HTMLCanvasElement>(
'[data-active-pane="true"] [data-test-id="canvas-element"]'
) ?? document.querySelector<HTMLCanvasElement>('[data-test-id="canvas-element"]')
)
}
function viewportScreenCenter() {
const canvas = document.querySelector<HTMLCanvasElement>('[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<HTMLCanvasElement>('[data-test-id="canvas-element"]')
const canvas = activeCanvas()
if (canvas) {
const rect = canvas.getBoundingClientRect()
return { x: rect.width / 2, y: rect.height / 2 }

View file

@ -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',

View file

@ -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)