diff --git a/bun.lock b/bun.lock index fe5081831..2404ffe91 100644 --- a/bun.lock +++ b/bun.lock @@ -44,7 +44,6 @@ "jspdf": "^4.2.1", "lib0": "^0.2.117", "motion-v": "^2.0.0", - "ofetch": "^1.5.1", "opentype.js": "^2.0.0", "prismjs": "^1.30.0", "reka-ui": "^2.9.0", diff --git a/package.json b/package.json index 26e4be1cf..1196c589f 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,6 @@ "jspdf": "^4.2.1", "lib0": "^0.2.117", "motion-v": "^2.0.0", - "ofetch": "^1.5.1", "opentype.js": "^2.0.0", "prismjs": "^1.30.0", "reka-ui": "^2.9.0", diff --git a/packages/core/src/editor/nodes.ts b/packages/core/src/editor/nodes.ts index 0a35eea31..ce19f306d 100644 --- a/packages/core/src/editor/nodes.ts +++ b/packages/core/src/editor/nodes.ts @@ -60,7 +60,7 @@ export function createNodeActions(ctx: EditorContext) { ctx.requestRender() } - function setOpacity(opacity: number) { + function setOpacity(opacity: number, coalesceKey?: string) { if (!Number.isFinite(opacity)) return const clamped = Math.max(0, Math.min(1, opacity)) const ids = [...ctx.state.selectedIds] @@ -68,11 +68,15 @@ export function createNodeActions(ctx: EditorContext) { const targets = ids.map((id) => ctx.graph.getNode(id)).filter((n): n is SceneNode => n != null) const changed = targets.filter((t) => t.opacity !== clamped) if (changed.length === 0) return - ctx.undo.runBatch('Set opacity', () => { - for (const target of changed) { - updateNodeWithUndo(target.id, { opacity: clamped }, 'Set opacity') - } - }) + ctx.undo.runBatch( + 'Set opacity', + () => { + for (const target of changed) { + updateNodeWithUndo(target.id, { opacity: clamped }, 'Set opacity') + } + }, + coalesceKey + ) } return { diff --git a/packages/scene-graph/src/undo.ts b/packages/scene-graph/src/undo.ts index af9780c23..268aa556f 100644 --- a/packages/scene-graph/src/undo.ts +++ b/packages/scene-graph/src/undo.ts @@ -2,6 +2,7 @@ export interface UndoEntry { label: string forward: () => void inverse: () => void + coalesceKey?: string } export interface UndoManagerOptions { @@ -11,6 +12,7 @@ export interface UndoManagerOptions { interface UndoBatch { label: string entries: UndoEntry[] + coalesceKey?: string } const DEFAULT_HISTORY_LIMIT = 200 @@ -63,8 +65,8 @@ export class UndoManager { return entry.label } - beginBatch(label: string): void { - this.batches.push({ label, entries: [] }) + beginBatch(label: string, coalesceKey?: string): void { + this.batches.push({ label, entries: [], coalesceKey }) } commitBatch(): void { @@ -77,8 +79,8 @@ export class UndoManager { else this.pushUndoEntry(entry) } - runBatch(label: string, fn: () => T): T { - this.beginBatch(label) + runBatch(label: string, fn: () => T, coalesceKey?: string): T { + this.beginBatch(label, coalesceKey) try { const result = fn() this.commitBatch() @@ -129,12 +131,21 @@ export class UndoManager { return { label: batch.label, forward: () => batch.entries.forEach((entry) => entry.forward()), - inverse: () => batch.entries.toReversed().forEach((entry) => entry.inverse()) + inverse: () => batch.entries.toReversed().forEach((entry) => entry.inverse()), + coalesceKey: batch.coalesceKey } } private pushUndoEntry(entry: UndoEntry): void { - this.undoStack.push(entry) + const previous = this.undoStack.at(-1) + if (entry.coalesceKey && previous?.coalesceKey === entry.coalesceKey) { + this.undoStack[this.undoStack.length - 1] = { + ...entry, + inverse: previous.inverse + } + } else { + this.undoStack.push(entry) + } this.redoStack = [] this.trimUndoStack() } diff --git a/packages/vue/src/editor/commands/context.ts b/packages/vue/src/editor/commands/context.ts index cb0eaecd3..aa6f989e3 100644 --- a/packages/vue/src/editor/commands/context.ts +++ b/packages/vue/src/editor/commands/context.ts @@ -16,5 +16,5 @@ export type EditorCommandMapOptions = { messages: CommandMessagesStore otherPages: ComputedRef> moveSelectionToPage: (pageId: string) => void - getOpacityTarget: () => number + getOpacityTarget: () => { value: number; coalesceKey?: string } } diff --git a/packages/vue/src/editor/commands/selection.ts b/packages/vue/src/editor/commands/selection.ts index 6753b53ef..6047e4d96 100644 --- a/packages/vue/src/editor/commands/selection.ts +++ b/packages/vue/src/editor/commands/selection.ts @@ -269,7 +269,10 @@ export function createSelectionCommands({ return t.value.setOpacity }, enabled: capabilities.canSetOpacity, - run: () => editor.setOpacity(getOpacityTarget()) + run: () => { + const target = getOpacityTarget() + editor.setOpacity(target.value, target.coalesceKey) + } } } } diff --git a/packages/vue/src/editor/commands/use.ts b/packages/vue/src/editor/commands/use.ts index a628c0550..b98e4851f 100644 --- a/packages/vue/src/editor/commands/use.ts +++ b/packages/vue/src/editor/commands/use.ts @@ -41,9 +41,9 @@ export function useEditorCommands() { editor.moveToPage(pageId) } - let opacityTarget = 1 - function setOpacityTarget(value: number) { - opacityTarget = value + let opacityTarget: { value: number; coalesceKey?: string } = { value: 1 } + function setOpacityTarget(value: number, coalesceKey?: string) { + opacityTarget = coalesceKey ? { value, coalesceKey } : { value } } const commands = createEditorCommandMap({ diff --git a/src/app/shell/keyboard/actions.ts b/src/app/shell/keyboard/actions.ts index b0ac6eb02..83805e537 100644 --- a/src/app/shell/keyboard/actions.ts +++ b/src/app/shell/keyboard/actions.ts @@ -102,18 +102,31 @@ export function createKeyboardActions({ } let opacityBuffer = '' + let opacitySelectionKey = '' + let opacityCoalesceKey = '' let opacityResetTimer: ReturnType | undefined + function resetOpacityBuffer() { + opacityBuffer = '' + opacitySelectionKey = '' + opacityCoalesceKey = '' + clearTimeout(opacityResetTimer) + } + function opacityDigit(digit: string) { if (store.state.selectedIds.size === 0) return + const selectionKey = [...store.state.selectedIds].sort().join('\0') + if (selectionKey !== opacitySelectionKey) resetOpacityBuffer() + if (!opacityBuffer) { + opacitySelectionKey = selectionKey + opacityCoalesceKey = crypto.randomUUID() + } opacityBuffer += digit if (opacityBuffer.length > 3) opacityBuffer = opacityBuffer.slice(-3) - setOpacityTarget(opacityFromBuffer(opacityBuffer)) + setOpacityTarget(opacityFromBuffer(opacityBuffer), opacityCoalesceKey) runCommand('selection.setOpacity') clearTimeout(opacityResetTimer) - opacityResetTimer = setTimeout(() => { - opacityBuffer = '' - }, 800) + opacityResetTimer = setTimeout(resetOpacityBuffer, 800) } return { diff --git a/src/app/shell/keyboard/registry.ts b/src/app/shell/keyboard/registry.ts index 506e8eb00..a9706c83e 100644 --- a/src/app/shell/keyboard/registry.ts +++ b/src/app/shell/keyboard/registry.ts @@ -20,6 +20,7 @@ type ShortcutDefinition = { id: string keys: string | string[] run: ShortcutAction + shouldPreventDefault?: (event: KeyboardEvent) => boolean } function commandShortcut( @@ -36,19 +37,16 @@ function commandShortcuts(...commands: EditorCommandId[]): ShortcutDefinition[] }) } -const OPACITY_CODES = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].flatMap((d) => [ - `Digit${d}`, - `Numpad${d}` -]) - function opacityBindings(): ShortcutDefinition[] { - return OPACITY_CODES.map((code) => ({ - id: `opacity-${code}`, - keys: code, - run: ({ keyEvent, actions }: KeyboardShortcutRunOptions) => { + return ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].map((digit) => ({ + id: `selection-opacity-${digit}`, + keys: digit, + run: ({ keyEvent, actions }) => { if (keyEvent.metaKey || keyEvent.ctrlKey || keyEvent.altKey || keyEvent.shiftKey) return - actions.opacityDigit(code.slice(-1)) - } + actions.opacityDigit(digit) + }, + shouldPreventDefault: (event) => + !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey })) } @@ -161,8 +159,8 @@ export function registerKeyboardShortcuts(options: KeyboardShortcutOptions) { for (const shortcut of shortcuts) { bindShortcut(bindings, shortcut.keys, (event) => { - event.preventDefault() shortcut.run(runOptions(event)) + if (shortcut.shouldPreventDefault?.(event) ?? true) event.preventDefault() }) } diff --git a/tests/e2e/keyboard/shortcuts.spec.ts b/tests/e2e/keyboard/shortcuts.spec.ts index 328c4185a..c3746c256 100644 --- a/tests/e2e/keyboard/shortcuts.spec.ts +++ b/tests/e2e/keyboard/shortcuts.spec.ts @@ -48,6 +48,15 @@ function getZoom() { }) } +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') @@ -222,6 +231,56 @@ test.describe('duplicate', () => { }) }) +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() diff --git a/tests/engine/editor/opacity.test.ts b/tests/engine/editor/opacity.test.ts index 0f3fafa71..c2cbb378d 100644 --- a/tests/engine/editor/opacity.test.ts +++ b/tests/engine/editor/opacity.test.ts @@ -61,6 +61,31 @@ describe('editor.setOpacity', () => { expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(0.3) }) + test('coalesces buffered shortcut updates into one undo entry', () => { + const { editor, rect } = setup() + + editor.setOpacity(0.2, 'shortcut-session') + editor.setOpacity(0.28, 'shortcut-session') + + editor.undo.undo() + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(1) + + editor.undo.redo() + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(0.28) + }) + + test('keeps separate shortcut sessions as separate undo entries', () => { + const { editor, rect } = setup() + + editor.setOpacity(0.2, 'shortcut-session-1') + editor.setOpacity(0.8, 'shortcut-session-2') + + editor.undo.undo() + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(0.2) + editor.undo.undo() + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(1) + }) + test('opacity batch for multiple selections collapses to one undo entry', () => { const { editor, rect } = setup() const pageId = editor.graph.getPages()[0].id