Merge pull request #410 from sadkodev/master

feat(keyboard): add Figma-style opacity shortcuts 1-9/0
This commit is contained in:
Danila Poyarkov 2026-07-18 05:04:38 +03:00 committed by GitHub
commit 71cf3e5aa0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 387 additions and 14 deletions

View file

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

View file

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

View file

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

View file

@ -16,4 +16,5 @@ export type EditorCommandMapOptions = {
messages: CommandMessagesStore
otherPages: ComputedRef<Array<{ id: string }>>
moveSelectionToPage: (pageId: string) => void
getOpacityTarget: () => { value: number; coalesceKey?: string }
}

View file

@ -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'] }

View file

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

View file

@ -30,6 +30,7 @@ export type EditorCommandId =
| 'selection.outlineText'
| 'selection.outlineStroke'
| 'selection.moveToPage'
| 'selection.setOpacity'
| 'view.zoom100'
| 'view.zoomFit'
| 'view.zoomSelection'

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@
"bringToFront": "最前面へ移動",
"sendToBack": "最背面へ移動",
"moveToPage": "ページへ移動",
"setOpacity": "不透明度を設定",
"zoomTo100": "100%にズーム",
"zoomToFit": "画面に合わせる",
"zoomToSelection": "選択範囲にズーム",

View file

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

View file

@ -15,6 +15,7 @@
"bringToFront": "На передний план",
"sendToBack": "На задний план",
"moveToPage": "Переместить на страницу",
"setOpacity": "Задать непрозрачность",
"zoomTo100": "Масштаб 100%",
"zoomToFit": "Вписать в экран",
"zoomToSelection": "Масштаб по выделению",

View file

@ -15,6 +15,7 @@
"bringToFront": "置于顶层",
"sendToBack": "置于底层",
"moveToPage": "移动到页面",
"setOpacity": "设置不透明度",
"zoomTo100": "缩放到 100%",
"zoomToFit": "适应屏幕",
"zoomToSelection": "适应所选内容",

View file

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

View file

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

View file

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

View file

@ -12,6 +12,7 @@ export type KeyboardShortcutActions = {
toggleUI: () => void
toggleAI: () => void
exportSelectionPng: () => void
opacityDigit: (digit: string) => void
}
export type KeyboardShortcutOptions = {

View file

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

View file

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

View 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)
})
})