Merge pull request #410 from sadkodev/master
feat(keyboard): add Figma-style opacity shortcuts 1-9/0
This commit is contained in:
commit
71cf3e5aa0
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<T>(label: string, fn: () => T): T {
|
||||
this.beginBatch(label)
|
||||
runBatch<T>(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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ export type EditorCommandMapOptions = {
|
|||
messages: CommandMessagesStore
|
||||
otherPages: ComputedRef<Array<{ id: string }>>
|
||||
moveSelectionToPage: (pageId: string) => void
|
||||
getOpacityTarget: () => { value: number; coalesceKey?: string }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] }
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ export function createSelectionCommands({
|
|||
capabilities,
|
||||
messages: t,
|
||||
otherPages,
|
||||
moveSelectionToPage
|
||||
moveSelectionToPage,
|
||||
getOpacityTarget
|
||||
}: EditorCommandMapOptions): Record<SelectionCommandId, EditorCommand> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export type EditorCommandId =
|
|||
| 'selection.outlineText'
|
||||
| 'selection.outlineStroke'
|
||||
| 'selection.moveToPage'
|
||||
| 'selection.setOpacity'
|
||||
| 'view.zoom100'
|
||||
| 'view.zoomFit'
|
||||
| 'view.zoomSelection'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"bringToFront": "最前面へ移動",
|
||||
"sendToBack": "最背面へ移動",
|
||||
"moveToPage": "ページへ移動",
|
||||
"setOpacity": "不透明度を設定",
|
||||
"zoomTo100": "100%にズーム",
|
||||
"zoomToFit": "画面に合わせる",
|
||||
"zoomToSelection": "選択範囲にズーム",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"bringToFront": "На передний план",
|
||||
"sendToBack": "На задний план",
|
||||
"moveToPage": "Переместить на страницу",
|
||||
"setOpacity": "Задать непрозрачность",
|
||||
"zoomTo100": "Масштаб 100%",
|
||||
"zoomToFit": "Вписать в экран",
|
||||
"zoomToSelection": "Масштаб по выделению",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"bringToFront": "置于顶层",
|
||||
"sendToBack": "置于底层",
|
||||
"moveToPage": "移动到页面",
|
||||
"setOpacity": "设置不透明度",
|
||||
"zoomTo100": "缩放到 100%",
|
||||
"zoomToFit": "适应屏幕",
|
||||
"zoomToSelection": "适应所选内容",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<typeof useViewportKind>['isMobile']
|
||||
runCommand: ReturnType<typeof useEditorCommands>['runCommand']
|
||||
setOpacityTarget: ReturnType<typeof useEditorCommands>['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<typeof setTimeout> | 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type KeyboardShortcutActions = {
|
|||
toggleUI: () => void
|
||||
toggleAI: () => void
|
||||
exportSelectionPng: () => void
|
||||
opacityDigit: (digit: string) => void
|
||||
}
|
||||
|
||||
export type KeyboardShortcutOptions = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
187
tests/engine/editor/opacity.test.ts
Normal file
187
tests/engine/editor/opacity.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue