diff --git a/packages/core/src/editor/index.ts b/packages/core/src/editor/index.ts index 6f266d20c..d7d955d74 100644 --- a/packages/core/src/editor/index.ts +++ b/packages/core/src/editor/index.ts @@ -1,6 +1,7 @@ export { createDefaultEditorState, createEditor } from './create' export type { Editor } from './create' export { createTextActions } from './text' +export { opacityFromBuffer } from './nodes' export { EDITOR_TOOLS, TOOL_SHORTCUTS } from './tool-registry' export type { EditorToolDef } from './tool-registry' export type { diff --git a/packages/core/src/editor/nodes.ts b/packages/core/src/editor/nodes.ts index f00fa443c..ce19f306d 100644 --- a/packages/core/src/editor/nodes.ts +++ b/packages/core/src/editor/nodes.ts @@ -8,6 +8,15 @@ import { textAutoResizeChanges } from './text/auto-resize' import type { EditorContext } from './types' import { createVariableBindingActions } from './variable-bindings' +export function opacityFromBuffer(buffer: string): number { + if (buffer === '0') return 1 + if (!/^\d+$/.test(buffer)) return 1 + const n = Number.parseInt(buffer, 10) + if (!Number.isFinite(n)) return 1 + const percent = buffer.length === 1 ? n * 10 : n + return Math.min(100, Math.max(0, percent)) / 100 +} + export function createNodeActions(ctx: EditorContext) { const layoutModeActions = createLayoutModeActions(ctx) const nudgeActions = createNudgeActions(ctx) @@ -51,9 +60,29 @@ export function createNodeActions(ctx: EditorContext) { ctx.requestRender() } + 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] + if (ids.length === 0) return + 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') + } + }, + coalesceKey + ) + } + return { updateNode, updateNodeWithUndo, + setOpacity, ...layoutModeActions, ...variableBindingActions, ...nudgeActions 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 634d70899..aa6f989e3 100644 --- a/packages/vue/src/editor/commands/context.ts +++ b/packages/vue/src/editor/commands/context.ts @@ -16,4 +16,5 @@ export type EditorCommandMapOptions = { messages: CommandMessagesStore otherPages: ComputedRef> moveSelectionToPage: (pageId: string) => void + getOpacityTarget: () => { value: number; coalesceKey?: string } } diff --git a/packages/vue/src/editor/commands/registry.ts b/packages/vue/src/editor/commands/registry.ts index 77e4de0ee..319f7c2ec 100644 --- a/packages/vue/src/editor/commands/registry.ts +++ b/packages/vue/src/editor/commands/registry.ts @@ -96,6 +96,7 @@ export const EDITOR_COMMAND_METADATA = { 'selection.outlineText': { contextTestId: 'context-outline-text' }, 'selection.outlineStroke': { contextTestId: 'context-outline-stroke' }, 'selection.moveToPage': {}, + 'selection.setOpacity': { shortcut: '1-9, 0' }, 'view.zoom100': { keybinding: '$mod+Digit0' }, 'view.zoomFit': { keybinding: ['$mod+Digit1', 'Shift+Digit1'] }, 'view.zoomSelection': { keybinding: ['$mod+Digit2', 'Shift+Digit2'] } diff --git a/packages/vue/src/editor/commands/selection.ts b/packages/vue/src/editor/commands/selection.ts index 151984b02..6047e4d96 100644 --- a/packages/vue/src/editor/commands/selection.ts +++ b/packages/vue/src/editor/commands/selection.ts @@ -29,7 +29,8 @@ export function createSelectionCommands({ capabilities, messages: t, otherPages, - moveSelectionToPage + moveSelectionToPage, + getOpacityTarget }: EditorCommandMapOptions): Record { return { 'selection.selectAll': { @@ -261,6 +262,17 @@ export function createSelectionCommands({ run: () => { moveSelectionToPage(otherPages.value[0].id) } + }, + 'selection.setOpacity': { + id: 'selection.setOpacity', + get label() { + return t.value.setOpacity + }, + enabled: capabilities.canSetOpacity, + run: () => { + const target = getOpacityTarget() + editor.setOpacity(target.value, target.coalesceKey) + } } } } diff --git a/packages/vue/src/editor/commands/types.ts b/packages/vue/src/editor/commands/types.ts index 2e341182c..a3a2b272d 100644 --- a/packages/vue/src/editor/commands/types.ts +++ b/packages/vue/src/editor/commands/types.ts @@ -30,6 +30,7 @@ export type EditorCommandId = | 'selection.outlineText' | 'selection.outlineStroke' | 'selection.moveToPage' + | 'selection.setOpacity' | 'view.zoom100' | 'view.zoomFit' | 'view.zoomSelection' diff --git a/packages/vue/src/editor/commands/use.ts b/packages/vue/src/editor/commands/use.ts index 0f9ff9b41..b98e4851f 100644 --- a/packages/vue/src/editor/commands/use.ts +++ b/packages/vue/src/editor/commands/use.ts @@ -41,19 +41,26 @@ export function useEditorCommands() { editor.moveToPage(pageId) } + let opacityTarget: { value: number; coalesceKey?: string } = { value: 1 } + function setOpacityTarget(value: number, coalesceKey?: string) { + opacityTarget = coalesceKey ? { value, coalesceKey } : { value } + } + const commands = createEditorCommandMap({ editor, selection, capabilities, messages: t, otherPages, - moveSelectionToPage + moveSelectionToPage, + getOpacityTarget: () => opacityTarget }) return { commands, otherPages, moveSelectionToPage, + setOpacityTarget, ...createEditorCommandActions(commands) } } diff --git a/packages/vue/src/editor/selection-capabilities/use.ts b/packages/vue/src/editor/selection-capabilities/use.ts index a11653f12..a77edd80f 100644 --- a/packages/vue/src/editor/selection-capabilities/use.ts +++ b/packages/vue/src/editor/selection-capabilities/use.ts @@ -66,6 +66,7 @@ export function useSelectionCapabilities() { canGoToMainComponent: computed(() => selection.isInstance.value), canCreateInstance: computed(() => selectedNode.value?.type === 'COMPONENT'), canMoveToPage: useSceneComputed(() => hasSelection.value && editor.graph.getPages().length > 1), + canSetOpacity: computed(() => hasSelection.value), canSelectAll: useSceneComputed( () => editor.graph.getChildren(editor.state.currentPageId).length > 0 ), diff --git a/packages/vue/src/i18n/locales/de/commands.json b/packages/vue/src/i18n/locales/de/commands.json index 642538812..d411cdc11 100644 --- a/packages/vue/src/i18n/locales/de/commands.json +++ b/packages/vue/src/i18n/locales/de/commands.json @@ -15,6 +15,7 @@ "bringToFront": "In den Vordergrund", "sendToBack": "In den Hintergrund", "moveToPage": "Auf Seite verschieben", + "setOpacity": "Deckkraft festlegen", "zoomTo100": "Zoom 100 %", "zoomToFit": "Einpassen", "zoomToSelection": "Auswahl einpassen", diff --git a/packages/vue/src/i18n/locales/es/commands.json b/packages/vue/src/i18n/locales/es/commands.json index 427c5095f..7587ca63e 100644 --- a/packages/vue/src/i18n/locales/es/commands.json +++ b/packages/vue/src/i18n/locales/es/commands.json @@ -15,6 +15,7 @@ "bringToFront": "Traer al frente", "sendToBack": "Enviar al fondo", "moveToPage": "Mover a página", + "setOpacity": "Establecer opacidad", "zoomTo100": "Zoom 100 %", "zoomToFit": "Ajustar a pantalla", "zoomToSelection": "Zoom a selección", diff --git a/packages/vue/src/i18n/locales/fr/commands.json b/packages/vue/src/i18n/locales/fr/commands.json index fd470a22b..a76efb041 100644 --- a/packages/vue/src/i18n/locales/fr/commands.json +++ b/packages/vue/src/i18n/locales/fr/commands.json @@ -15,6 +15,7 @@ "bringToFront": "Mettre au premier plan", "sendToBack": "Mettre en arrière-plan", "moveToPage": "Déplacer vers la page", + "setOpacity": "Définir l'opacité", "zoomTo100": "Zoom 100 %", "zoomToFit": "Ajuster à l'écran", "zoomToSelection": "Zoom sur la sélection", diff --git a/packages/vue/src/i18n/locales/it/commands.json b/packages/vue/src/i18n/locales/it/commands.json index a9542c114..be2f1dd71 100644 --- a/packages/vue/src/i18n/locales/it/commands.json +++ b/packages/vue/src/i18n/locales/it/commands.json @@ -15,6 +15,7 @@ "bringToFront": "Porta in primo piano", "sendToBack": "Porta in secondo piano", "moveToPage": "Sposta nella pagina", + "setOpacity": "Imposta opacità", "zoomTo100": "Zoom 100%", "zoomToFit": "Adatta allo schermo", "zoomToSelection": "Zoom sulla selezione", diff --git a/packages/vue/src/i18n/locales/ja/commands.json b/packages/vue/src/i18n/locales/ja/commands.json index 426d1a306..f83c011d7 100644 --- a/packages/vue/src/i18n/locales/ja/commands.json +++ b/packages/vue/src/i18n/locales/ja/commands.json @@ -15,6 +15,7 @@ "bringToFront": "最前面へ移動", "sendToBack": "最背面へ移動", "moveToPage": "ページへ移動", + "setOpacity": "不透明度を設定", "zoomTo100": "100%にズーム", "zoomToFit": "画面に合わせる", "zoomToSelection": "選択範囲にズーム", diff --git a/packages/vue/src/i18n/locales/pl/commands.json b/packages/vue/src/i18n/locales/pl/commands.json index 32482b1b6..09db34849 100644 --- a/packages/vue/src/i18n/locales/pl/commands.json +++ b/packages/vue/src/i18n/locales/pl/commands.json @@ -15,6 +15,7 @@ "bringToFront": "Przenieś na wierzch", "sendToBack": "Przenieś na spód", "moveToPage": "Przenieś na stronę", + "setOpacity": "Ustaw krycie", "zoomTo100": "Zoom 100%", "zoomToFit": "Dopasuj do ekranu", "zoomToSelection": "Zoom do zaznaczenia", diff --git a/packages/vue/src/i18n/locales/ru/commands.json b/packages/vue/src/i18n/locales/ru/commands.json index 57e59500f..25a100df3 100644 --- a/packages/vue/src/i18n/locales/ru/commands.json +++ b/packages/vue/src/i18n/locales/ru/commands.json @@ -15,6 +15,7 @@ "bringToFront": "На передний план", "sendToBack": "На задний план", "moveToPage": "Переместить на страницу", + "setOpacity": "Задать непрозрачность", "zoomTo100": "Масштаб 100%", "zoomToFit": "Вписать в экран", "zoomToSelection": "Масштаб по выделению", diff --git a/packages/vue/src/i18n/locales/zh-cn/commands.json b/packages/vue/src/i18n/locales/zh-cn/commands.json index 99138a415..ab222b8e3 100644 --- a/packages/vue/src/i18n/locales/zh-cn/commands.json +++ b/packages/vue/src/i18n/locales/zh-cn/commands.json @@ -15,6 +15,7 @@ "bringToFront": "置于顶层", "sendToBack": "置于底层", "moveToPage": "移动到页面", + "setOpacity": "设置不透明度", "zoomTo100": "缩放到 100%", "zoomToFit": "适应屏幕", "zoomToSelection": "适应所选内容", diff --git a/packages/vue/src/i18n/messages/commands.ts b/packages/vue/src/i18n/messages/commands.ts index da696cfa6..1dd11b669 100644 --- a/packages/vue/src/i18n/messages/commands.ts +++ b/packages/vue/src/i18n/messages/commands.ts @@ -33,6 +33,7 @@ export const commandMessageDefaults = { flipHorizontal: 'Flip horizontal', flipVertical: 'Flip vertical', moveToPage: 'Move to page', + setOpacity: 'Set opacity', zoomTo100: 'Zoom to 100%', zoomToFit: 'Zoom to fit', zoomToSelection: 'Zoom to selection' diff --git a/src/app/shell/keyboard/actions.ts b/src/app/shell/keyboard/actions.ts index 5d7f7c4e9..83805e537 100644 --- a/src/app/shell/keyboard/actions.ts +++ b/src/app/shell/keyboard/actions.ts @@ -1,5 +1,6 @@ import type { Ref } from 'vue' +import { opacityFromBuffer } from '@open-pencil/core/editor' import type { useEditorCommands, useViewportKind } from '@open-pencil/vue' import type { EditorStore } from '@/app/editor/active-store' @@ -9,13 +10,15 @@ type KeyboardActionsOptions = { activeTab: Ref<'design' | 'code' | 'ai'> isMobile: ReturnType['isMobile'] runCommand: ReturnType['runCommand'] + setOpacityTarget: ReturnType['setOpacityTarget'] } export function createKeyboardActions({ store, activeTab, isMobile, - runCommand + runCommand, + setOpacityTarget }: KeyboardActionsOptions) { function hasNodeEditSelection() { return ( @@ -98,6 +101,34 @@ export function createKeyboardActions({ if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'png') } + 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), opacityCoalesceKey) + runCommand('selection.setOpacity') + clearTimeout(opacityResetTimer) + opacityResetTimer = setTimeout(resetOpacityBuffer, 800) + } + return { smartDelete, confirmOrEnterText, @@ -105,6 +136,7 @@ export function createKeyboardActions({ toggleAutoLayout, toggleUI, toggleAI, - exportSelectionPng + exportSelectionPng, + opacityDigit } } diff --git a/src/app/shell/keyboard/registry.ts b/src/app/shell/keyboard/registry.ts index c6ba53d29..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,6 +37,19 @@ function commandShortcuts(...commands: EditorCommandId[]): ShortcutDefinition[] }) } +function opacityBindings(): ShortcutDefinition[] { + 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(digit) + }, + shouldPreventDefault: (event) => + !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey + })) +} + function shouldIgnoreShortcut(event: KeyboardEvent, options: KeyboardShortcutOptions) { return ( (event.target instanceof Element && event.target.closest('[data-picker-content]') !== null) || @@ -136,7 +150,8 @@ export function registerKeyboardShortcuts(options: KeyboardShortcutOptions) { { id: 'delete', keys: 'Delete', run: ({ actions }) => actions.smartDelete(false) }, { id: 'delete-alt', keys: 'Alt+Delete', run: ({ actions }) => actions.smartDelete(true) }, { id: 'enter', keys: 'Enter', run: ({ actions }) => actions.confirmOrEnterText() }, - { id: 'escape', keys: 'Escape', run: ({ actions }) => actions.escapeOrDeselect() } + { id: 'escape', keys: 'Escape', run: ({ actions }) => actions.escapeOrDeselect() }, + ...opacityBindings() ] const bindings: KeyBindingMap = {} @@ -144,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/src/app/shell/keyboard/types.ts b/src/app/shell/keyboard/types.ts index b0e311060..d4c3d2a15 100644 --- a/src/app/shell/keyboard/types.ts +++ b/src/app/shell/keyboard/types.ts @@ -12,6 +12,7 @@ export type KeyboardShortcutActions = { toggleUI: () => void toggleAI: () => void exportSelectionPng: () => void + opacityDigit: (digit: string) => void } export type KeyboardShortcutOptions = { diff --git a/src/app/shell/keyboard/use.ts b/src/app/shell/keyboard/use.ts index 6eaa56b2f..38abea9c5 100644 --- a/src/app/shell/keyboard/use.ts +++ b/src/app/shell/keyboard/use.ts @@ -17,11 +17,17 @@ export function useKeyboard() { const { activeTab } = useAIChat() const store = useEditorStore() const { isMobile } = useViewportKind() - const { runCommand } = useEditorCommands() + const { runCommand, setOpacityTarget } = useEditorCommands() const activeElement = useActiveElement() const inputFocused = computed(() => isInputElement(activeElement.value)) - const actions = createKeyboardActions({ store, activeTab, isMobile, runCommand }) + const actions = createKeyboardActions({ + store, + activeTab, + isMobile, + runCommand, + setOpacityTarget + }) bindEditorClipboard(store) bindNudgeKeys(store) 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 new file mode 100644 index 000000000..c2cbb378d --- /dev/null +++ b/tests/engine/editor/opacity.test.ts @@ -0,0 +1,187 @@ +import { describe, test, expect } from 'bun:test' + +import { createEditor, opacityFromBuffer } from '@open-pencil/core/editor' + +import { getNodeOrThrow } from '#tests/helpers/assert' + +describe('editor.setOpacity', () => { + function setup() { + const editor = createEditor() + const pageId = editor.graph.getPages()[0].id + const rect = editor.graph.createNode('RECTANGLE', pageId, { + name: 'Rect', + x: 0, + y: 0, + width: 50, + height: 50 + }) + editor.select([rect.id]) + return { editor, rect } + } + + test('sets opacity to 50%', () => { + const { editor, rect } = setup() + + editor.setOpacity(0.5) + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(0.5) + }) + + test('sets opacity to 100% (digit 0)', () => { + const { editor, rect } = setup() + + editor.setOpacity(0.5) + editor.setOpacity(1) + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(1) + }) + + test('clamps opacity above 100%', () => { + const { editor, rect } = setup() + + editor.setOpacity(1.5) + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(1) + }) + + test('clamps opacity below 0%', () => { + const { editor, rect } = setup() + + editor.setOpacity(-0.3) + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(0) + }) + + test('opacity change is undoable as a single batch entry', () => { + const { editor, rect } = setup() + + editor.setOpacity(0.3) + expect(editor.undo.canUndo).toBe(true) + + editor.undo.undo() + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(1) + + editor.undo.redo() + 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 + const rect2 = editor.graph.createNode('RECTANGLE', pageId, { + name: 'Rect2', + x: 100, + y: 0, + width: 50, + height: 50 + }) + editor.select([rect.id, rect2.id]) + + editor.setOpacity(0.7) + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(0.7) + expect(getNodeOrThrow(editor.graph, rect2.id).opacity).toBe(0.7) + + editor.undo.undo() + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(1) + expect(getNodeOrThrow(editor.graph, rect2.id).opacity).toBe(1) + }) + + test('no-op when opacity already matches', () => { + const { editor, rect } = setup() + + editor.setOpacity(1) + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(1) + expect(editor.undo.canUndo).toBe(false) + }) + + test('no-op with no selection', () => { + const { editor } = setup() + + editor.clearSelection() + editor.setOpacity(0.5) + expect(editor.undo.canUndo).toBe(false) + }) + + test('rejects NaN', () => { + const { editor, rect } = setup() + + editor.setOpacity(Number.NaN) + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(1) + expect(editor.undo.canUndo).toBe(false) + }) + + test('rejects Infinity', () => { + const { editor, rect } = setup() + + editor.setOpacity(Number.POSITIVE_INFINITY) + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(1) + expect(editor.undo.canUndo).toBe(false) + }) + + test('rejects -Infinity', () => { + const { editor, rect } = setup() + + editor.setOpacity(Number.NEGATIVE_INFINITY) + expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(1) + expect(editor.undo.canUndo).toBe(false) + }) +}) + +describe('opacityFromBuffer', () => { + test('single 0 returns 100%', () => { + expect(opacityFromBuffer('0')).toBe(1) + }) + + test('single digit returns decena', () => { + expect(opacityFromBuffer('5')).toBe(0.5) + expect(opacityFromBuffer('2')).toBe(0.2) + expect(opacityFromBuffer('9')).toBe(0.9) + }) + + test('two digits returns literal percent', () => { + expect(opacityFromBuffer('28')).toBe(0.28) + expect(opacityFromBuffer('35')).toBe(0.35) + expect(opacityFromBuffer('00')).toBe(0) + expect(opacityFromBuffer('05')).toBe(0.05) + }) + + test('three digits clamps to 100%', () => { + expect(opacityFromBuffer('100')).toBe(1) + expect(opacityFromBuffer('150')).toBe(1) + }) + + test('invalid buffer returns 100%', () => { + expect(opacityFromBuffer('')).toBe(1) + expect(opacityFromBuffer('abc')).toBe(1) + }) + + test('rejects partially numeric buffers', () => { + expect(opacityFromBuffer('12x')).toBe(1) + expect(opacityFromBuffer('5abc')).toBe(1) + expect(opacityFromBuffer('3.5')).toBe(1) + expect(opacityFromBuffer('1e2')).toBe(1) + expect(opacityFromBuffer('-5')).toBe(1) + expect(opacityFromBuffer(' 5')).toBe(1) + }) +})