openpencil/tests/e2e/keyboard/shortcuts.spec.ts
Danila Poyarkov 798a3c2b2c fix(editor): harden opacity shortcuts
- Match numeric keypad input by generated key so NumLock-off navigation remains available
- Coalesce buffered opacity updates into one undo interaction
- Add end-to-end coverage for digit composition, modifiers, and undo
2026-07-18 04:22:38 +03:00

412 lines
12 KiB
TypeScript

import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
const editor = useEditorSetup()
function getActiveTool() {
return editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return store.state.activeTool
})
}
function getSelectedCount() {
return editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return store.state.selectedIds.size
})
}
function getPageChildren() {
return editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return store.graph.getChildren(store.state.currentPageId).map((n) => ({
id: n.id,
type: n.type,
name: n.name,
childIds: n.childIds
}))
})
}
function getUIVisible() {
return editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return store.state.showUI
})
}
function getZoom() {
return editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return store.state.zoom
})
}
function getSelectedOpacity() {
return editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
return id ? store.graph.getNode(id)?.opacity : undefined
})
}
test.describe('tool switching', () => {
test('V → SELECT', async () => {
await editor.page.keyboard.press('v')
expect(await getActiveTool()).toBe('SELECT')
})
test('R → RECTANGLE', async () => {
await editor.page.keyboard.press('r')
expect(await getActiveTool()).toBe('RECTANGLE')
})
test('O → ELLIPSE', async () => {
await editor.page.keyboard.press('o')
expect(await getActiveTool()).toBe('ELLIPSE')
})
test('F → FRAME', async () => {
await editor.page.keyboard.press('f')
expect(await getActiveTool()).toBe('FRAME')
})
test('T → TEXT', async () => {
await editor.page.keyboard.press('t')
expect(await getActiveTool()).toBe('TEXT')
})
test('L → LINE', async () => {
await editor.page.keyboard.press('l')
expect(await getActiveTool()).toBe('LINE')
})
test('P → PEN', async () => {
await editor.page.keyboard.press('p')
expect(await getActiveTool()).toBe('PEN')
})
test('H → HAND', async () => {
await editor.page.keyboard.press('h')
expect(await getActiveTool()).toBe('HAND')
})
test('S → SECTION', async () => {
await editor.page.keyboard.press('s')
expect(await getActiveTool()).toBe('SECTION')
})
})
test.describe('selection shortcuts', () => {
test('⌘A selects all', async () => {
await editor.page.keyboard.press('v')
await editor.canvas.drawRect(100, 100, 60, 60)
await editor.canvas.drawRect(200, 100, 60, 60)
await editor.page.keyboard.press('Meta+a')
expect(await getSelectedCount()).toBe(2)
})
test('Escape clears selection and resets to SELECT tool', async () => {
await editor.page.keyboard.press('Escape')
expect(await getSelectedCount()).toBe(0)
expect(await getActiveTool()).toBe('SELECT')
})
test('Backspace deletes selected', async () => {
await editor.canvas.selectAll()
const beforeCount = await getSelectedCount()
expect(beforeCount).toBeGreaterThan(0)
await editor.page.keyboard.press('Backspace')
await editor.canvas.waitForRender()
const children = await getPageChildren()
expect(children).toHaveLength(0)
})
})
test.describe('z-order shortcuts', () => {
test('] brings to front', async () => {
await editor.canvas.drawRect(100, 100, 60, 60)
await editor.canvas.drawRect(100, 100, 60, 60)
const childrenBefore = await getPageChildren()
const firstId = expectDefined(childrenBefore[0], 'first page child').id
// Select the first (bottom) node
await editor.page.evaluate((id) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.select([id])
}, firstId)
await editor.canvas.waitForRender()
await editor.page.keyboard.press(']')
await editor.canvas.waitForRender()
const childrenAfter = await getPageChildren()
expect(childrenAfter[childrenAfter.length - 1].id).toBe(firstId)
})
test('[ sends to back', async () => {
const children = await getPageChildren()
const lastId = expectDefined(children.at(-1), 'last page child').id
// Select the last (top) node
await editor.page.evaluate((id) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.select([id])
}, lastId)
await editor.canvas.waitForRender()
await editor.page.keyboard.press('[')
await editor.canvas.waitForRender()
const after = await getPageChildren()
expect(after[0].id).toBe(lastId)
})
})
test.describe('group/ungroup shortcuts', () => {
test('⌘G groups selection', async () => {
await editor.canvas.selectAll()
expect(await getSelectedCount()).toBeGreaterThanOrEqual(2)
await editor.page.keyboard.press('Meta+g')
await editor.canvas.waitForRender()
const children = await getPageChildren()
const group = children.find((c) => c.type === 'GROUP')
expect(group).toBeTruthy()
expect(group?.childIds.length).toBeGreaterThanOrEqual(2)
})
test('⌘⇧G ungroups', async () => {
await editor.page.keyboard.press('Meta+Shift+g')
await editor.canvas.waitForRender()
const children = await getPageChildren()
expect(children.every((c) => c.type !== 'GROUP')).toBe(true)
})
})
test.describe('UI toggles', () => {
test('⌘\\ toggles UI visibility', async () => {
const before = await getUIVisible()
await editor.page.keyboard.press('Meta+\\')
await editor.canvas.waitForRender()
const after = await getUIVisible()
expect(after).toBe(!before)
// Restore
await editor.page.keyboard.press('Meta+\\')
await editor.canvas.waitForRender()
expect(await getUIVisible()).toBe(before)
})
})
test.describe('duplicate', () => {
test('⌘D duplicates selection', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(100, 100, 60, 60)
await editor.canvas.selectAll()
expect(await getSelectedCount()).toBe(1)
await editor.page.keyboard.press('Meta+d')
await editor.canvas.waitForRender()
const children = await getPageChildren()
expect(children).toHaveLength(2)
})
})
test.describe('opacity shortcuts', () => {
test.beforeEach(async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(100, 100, 60, 60)
})
test('combines digits and undoes them as one interaction', async () => {
await editor.page.keyboard.press('2')
await editor.page.keyboard.press('8')
expect(await getSelectedOpacity()).toBe(0.28)
await editor.page.keyboard.press('Meta+z')
expect(await getSelectedOpacity()).toBe(1)
await editor.page.keyboard.press('Meta+Shift+z')
expect(await getSelectedOpacity()).toBe(0.28)
})
test('maps 0 to 100% and 00 to 0%', async () => {
await editor.page.keyboard.press('5')
expect(await getSelectedOpacity()).toBe(0.5)
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(100, 100, 60, 60)
await editor.page.keyboard.press('0')
expect(await getSelectedOpacity()).toBe(1)
await editor.page.keyboard.press('0')
expect(await getSelectedOpacity()).toBe(0)
})
test('does not consume shifted digits or NumLock-off navigation keys', async () => {
await editor.page.keyboard.press('5')
await editor.page.keyboard.press('Shift+1')
expect(await getSelectedOpacity()).toBe(0.5)
const prevented = await editor.page.evaluate(() => {
const event = new KeyboardEvent('keydown', {
key: 'End',
code: 'Numpad1',
bubbles: true,
cancelable: true
})
window.dispatchEvent(event)
return event.defaultPrevented
})
expect(prevented).toBe(false)
expect(await getSelectedOpacity()).toBe(0.5)
})
})
test.describe('zoom shortcuts', () => {
test('⌘0 zooms to 100%', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(100, 100, 60, 60)
// Set zoom to something other than 100%
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.state.zoom = 2
})
await editor.canvas.waitForRender()
await editor.page.keyboard.press('Meta+0')
await editor.canvas.waitForRender()
const zoomAfter = await getZoom()
expect(zoomAfter).toBe(1)
})
test('⌘1 zooms to fit', async () => {
await editor.page.keyboard.press('Meta+1')
await editor.canvas.waitForRender()
const zoom = await getZoom()
expect(zoom).toBeGreaterThan(0)
expect(zoom).toBeLessThanOrEqual(1)
})
test('⌘2 zooms to selection', async () => {
await editor.canvas.selectAll()
await editor.page.keyboard.press('Meta+2')
await editor.canvas.waitForRender()
const zoom = await getZoom()
expect(zoom).toBeGreaterThan(0)
expect(zoom).toBeLessThanOrEqual(1)
})
test('⇧1 zooms to fit (same as ⌘1)', async () => {
await editor.page.keyboard.press('Shift+1')
await editor.canvas.waitForRender()
const zoom = await getZoom()
expect(zoom).toBeGreaterThan(0)
expect(zoom).toBeLessThanOrEqual(1)
})
test('⇧2 zooms to selection (same as ⌘2)', async () => {
await editor.canvas.selectAll()
await editor.page.keyboard.press('Shift+2')
await editor.canvas.waitForRender()
const zoom = await getZoom()
expect(zoom).toBeGreaterThan(0)
expect(zoom).toBeLessThanOrEqual(1)
})
})
test.describe('undo/redo', () => {
test('⌘Z undoes last action', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(100, 100, 60, 60)
expect((await getPageChildren()).length).toBe(1)
await editor.page.keyboard.press('Meta+z')
await editor.canvas.waitForRender()
expect((await getPageChildren()).length).toBe(0)
})
test('⌘⇧Z redoes undone action', async () => {
await editor.page.keyboard.press('Meta+Shift+z')
await editor.canvas.waitForRender()
expect((await getPageChildren()).length).toBe(1)
})
})
test.describe('auto-layout shortcut', () => {
test('⇧A toggles auto-layout on frame', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(100, 100, 200, 200)
await editor.canvas.selectAll()
// Change to frame type for auto-layout
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const nodes = [...store.state.selectedIds]
if (nodes[0]) store.updateNode(nodes[0], { type: 'FRAME' })
})
await editor.canvas.waitForRender()
const layoutBefore = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const nodes = [...store.state.selectedIds]
return store.graph.getNode(nodes[0])?.layoutMode
})
expect(layoutBefore).toBe('NONE')
await editor.page.keyboard.press('Shift+a')
await editor.canvas.waitForRender()
const layoutAfter = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const nodes = [...store.state.selectedIds]
return store.graph.getNode(nodes[0])?.layoutMode
})
expect(layoutAfter).toBe('VERTICAL')
// Toggle off
await editor.page.keyboard.press('Shift+a')
await editor.canvas.waitForRender()
const layoutFinal = await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const nodes = [...store.state.selectedIds]
return store.graph.getNode(nodes[0])?.layoutMode
})
expect(layoutFinal).toBe('NONE')
})
})