refactor(keyboard): address review feedback on opacity shortcuts
- Move setOpacity to core editor action (packages/core/src/editor/nodes.ts) so it is testable directly without duplicating logic in tests - Replace external window listener with tinykeys bindings in registerKeyboardShortcuts, reusing shared input/focus guards - Add 20 bindings for Digit0-9 and Numpad0-9 with modifier guards - Implement Figma-style multi-digit buffer: 2→20%, 28→28%, 00→0%, 100→100% with 400ms timeout and auto-apply at 3 digits - Simplify selection.setOpacity command to delegate to editor.setOpacity - Rewrite unit tests to call editor.setOpacity directly - Add ofetch to dependencies (was imported but missing from package.json) - Update CHANGELOG
This commit is contained in:
parent
bec41bd0d4
commit
0300cb521b
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
### Changed
|
||||
|
||||
- Add Figma-style opacity keyboard shortcuts (`1`-`9` for 10%-90%, `0` for 100%) on the selected layers, with undo-batched multi-selection support.
|
||||
- Add Figma-style opacity keyboard shortcuts (`1`-`9` for 10%-90%, `0` for 100%) with multi-digit buffer support (`28` → 28%, `00` → 0%, `100` → 100%), undo-batched multi-selection, and both top-row and numpad digit support.
|
||||
- Add Figma-style page management in the Pages panel, including rename/delete actions and drag-and-drop page reordering.
|
||||
- Add DOM/CSS import and authoring support so HTML, CSS, Tailwind, and JSX can be converted into editable OpenPencil documents from the app, CLI, and SDK.
|
||||
- Add Tailwind class serialization for DOM/CSS HTML export in the SDK and CLI.
|
||||
|
|
|
|||
1
bun.lock
1
bun.lock
|
|
@ -44,6 +44,7 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -51,9 +51,24 @@ export function createNodeActions(ctx: EditorContext) {
|
|||
ctx.requestRender()
|
||||
}
|
||||
|
||||
function setOpacity(opacity: number) {
|
||||
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')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
updateNode,
|
||||
updateNodeWithUndo,
|
||||
setOpacity,
|
||||
...layoutModeActions,
|
||||
...variableBindingActions,
|
||||
...nudgeActions
|
||||
|
|
|
|||
|
|
@ -269,17 +269,7 @@ export function createSelectionCommands({
|
|||
return t.value.setOpacity
|
||||
},
|
||||
enabled: capabilities.canSetOpacity,
|
||||
run: () => {
|
||||
const opacity = getOpacityTarget()
|
||||
const targets = editor.getSelectedNodes()
|
||||
if (targets.length === 0) return
|
||||
editor.undo.runBatch('Set opacity', () => {
|
||||
for (const target of targets) {
|
||||
if (target.opacity === opacity) continue
|
||||
editor.updateNodeWithUndo(target.id, { opacity }, 'Set opacity')
|
||||
}
|
||||
})
|
||||
}
|
||||
run: () => editor.setOpacity(getOpacityTarget())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,13 +9,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 +100,31 @@ export function createKeyboardActions({
|
|||
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'png')
|
||||
}
|
||||
|
||||
let opacityBuffer = ''
|
||||
let opacityTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function applyOpacityBuffer() {
|
||||
clearTimeout(opacityTimer)
|
||||
if (!opacityBuffer) return
|
||||
const n = Number.parseInt(opacityBuffer, 10)
|
||||
const percent = opacityBuffer.length === 1 ? n * 10 : n
|
||||
const clamped = Math.min(100, Math.max(0, percent))
|
||||
opacityBuffer = ''
|
||||
setOpacityTarget(clamped / 100)
|
||||
runCommand('selection.setOpacity')
|
||||
}
|
||||
|
||||
function opacityDigit(digit: string) {
|
||||
if (store.state.selectedIds.size === 0) return
|
||||
opacityBuffer += digit
|
||||
clearTimeout(opacityTimer)
|
||||
if (opacityBuffer.length >= 3) {
|
||||
applyOpacityBuffer()
|
||||
} else {
|
||||
opacityTimer = setTimeout(applyOpacityBuffer, 400)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
smartDelete,
|
||||
confirmOrEnterText,
|
||||
|
|
@ -105,6 +132,7 @@ export function createKeyboardActions({
|
|||
toggleAutoLayout,
|
||||
toggleUI,
|
||||
toggleAI,
|
||||
exportSelectionPng
|
||||
exportSelectionPng,
|
||||
opacityDigit
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import type { useEditorCommands } from '@open-pencil/vue'
|
||||
|
||||
import type { EditorStore } from '@/app/editor/active-store'
|
||||
import { isEditing } from '@/app/shell/keyboard/focus'
|
||||
|
||||
type OpacityShortcutOptions = {
|
||||
store: EditorStore
|
||||
setOpacityTarget: ReturnType<typeof useEditorCommands>['setOpacityTarget']
|
||||
runCommand: ReturnType<typeof useEditorCommands>['runCommand']
|
||||
}
|
||||
|
||||
const DIGIT_OPACITY = {
|
||||
Digit0: 1,
|
||||
Numpad0: 1,
|
||||
Digit1: 0.1,
|
||||
Numpad1: 0.1,
|
||||
Digit2: 0.2,
|
||||
Numpad2: 0.2,
|
||||
Digit3: 0.3,
|
||||
Numpad3: 0.3,
|
||||
Digit4: 0.4,
|
||||
Numpad4: 0.4,
|
||||
Digit5: 0.5,
|
||||
Numpad5: 0.5,
|
||||
Digit6: 0.6,
|
||||
Numpad6: 0.6,
|
||||
Digit7: 0.7,
|
||||
Numpad7: 0.7,
|
||||
Digit8: 0.8,
|
||||
Numpad8: 0.8,
|
||||
Digit9: 0.9,
|
||||
Numpad9: 0.9
|
||||
} as const
|
||||
|
||||
export function bindOpacityKeys({ store, setOpacityTarget, runCommand }: OpacityShortcutOptions) {
|
||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
if (isEditing(e)) return
|
||||
if (store.state.editingTextId) return
|
||||
if (store.state.numberFieldFocused) return
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return
|
||||
if (store.state.selectedIds.size === 0) return
|
||||
|
||||
if (!(e.code in DIGIT_OPACITY)) return
|
||||
const opacity: number = DIGIT_OPACITY[e.code as keyof typeof DIGIT_OPACITY]
|
||||
|
||||
e.preventDefault()
|
||||
setOpacityTarget(opacity)
|
||||
runCommand('selection.setOpacity')
|
||||
})
|
||||
}
|
||||
|
|
@ -36,6 +36,29 @@ function commandShortcuts(...commands: EditorCommandId[]): ShortcutDefinition[]
|
|||
})
|
||||
}
|
||||
|
||||
const OPACITY_DIGITS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
|
||||
|
||||
function opacityBindings(): ShortcutDefinition[] {
|
||||
return OPACITY_DIGITS.flatMap((digit) => [
|
||||
{
|
||||
id: `opacity-Digit${digit}`,
|
||||
keys: `Digit${digit}`,
|
||||
run: ({ keyEvent, actions }: KeyboardShortcutRunOptions) => {
|
||||
if (keyEvent.metaKey || keyEvent.ctrlKey || keyEvent.altKey || keyEvent.shiftKey) return
|
||||
actions.opacityDigit(digit)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: `opacity-Numpad${digit}`,
|
||||
keys: `Numpad${digit}`,
|
||||
run: ({ keyEvent, actions }: KeyboardShortcutRunOptions) => {
|
||||
if (keyEvent.metaKey || keyEvent.ctrlKey || keyEvent.altKey || keyEvent.shiftKey) return
|
||||
actions.opacityDigit(digit)
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
function shouldIgnoreShortcut(event: KeyboardEvent, options: KeyboardShortcutOptions) {
|
||||
return (
|
||||
(event.target instanceof Element && event.target.closest('[data-picker-content]') !== null) ||
|
||||
|
|
@ -136,7 +159,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 = {}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type KeyboardShortcutActions = {
|
|||
toggleUI: () => void
|
||||
toggleAI: () => void
|
||||
exportSelectionPng: () => void
|
||||
opacityDigit: (digit: string) => void
|
||||
}
|
||||
|
||||
export type KeyboardShortcutOptions = {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { createKeyboardActions } from '@/app/shell/keyboard/actions'
|
|||
import { bindEditorClipboard } from '@/app/shell/keyboard/clipboard'
|
||||
import { isInputElement } from '@/app/shell/keyboard/focus'
|
||||
import { bindNudgeKeys } from '@/app/shell/keyboard/nudging'
|
||||
import { bindOpacityKeys } from '@/app/shell/keyboard/opacity'
|
||||
import { registerKeyboardShortcuts } from '@/app/shell/keyboard/registry'
|
||||
import { openFileDialog } from '@/app/shell/menu/use'
|
||||
import { closeTab, createTab, activeTab as activeTabRef } from '@/app/tabs'
|
||||
|
|
@ -22,11 +21,16 @@ export function useKeyboard() {
|
|||
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)
|
||||
bindOpacityKeys({ store, setOpacityTarget, runCommand })
|
||||
|
||||
registerKeyboardShortcuts({
|
||||
inputFocused,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { createEditor } from '@open-pencil/core/editor'
|
|||
|
||||
import { getNodeOrThrow } from '#tests/helpers/assert'
|
||||
|
||||
describe('opacity via updateNodeWithUndo + runBatch', () => {
|
||||
describe('editor.setOpacity', () => {
|
||||
function setup() {
|
||||
const editor = createEditor()
|
||||
const pageId = editor.graph.getPages()[0].id
|
||||
|
|
@ -19,36 +19,39 @@ describe('opacity via updateNodeWithUndo + runBatch', () => {
|
|||
return { editor, rect }
|
||||
}
|
||||
|
||||
function setOpacity(editor: ReturnType<typeof createEditor>, opacity: number) {
|
||||
const targets = editor.getSelectedNodes()
|
||||
if (targets.length === 0) return
|
||||
editor.undo.runBatch('Set opacity', () => {
|
||||
for (const target of targets) {
|
||||
if (target.opacity === opacity) continue
|
||||
editor.updateNodeWithUndo(target.id, { opacity }, 'Set opacity')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('sets opacity to 50%', () => {
|
||||
const { editor, rect } = setup()
|
||||
|
||||
setOpacity(editor, 0.5)
|
||||
editor.setOpacity(0.5)
|
||||
expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(0.5)
|
||||
})
|
||||
|
||||
test('0 digit sets opacity to 100%', () => {
|
||||
test('sets opacity to 100% (digit 0)', () => {
|
||||
const { editor, rect } = setup()
|
||||
|
||||
setOpacity(editor, 0.5)
|
||||
setOpacity(editor, 1)
|
||||
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()
|
||||
|
||||
setOpacity(editor, 0.3)
|
||||
editor.setOpacity(0.3)
|
||||
expect(editor.undo.canUndo).toBe(true)
|
||||
|
||||
editor.undo.undo()
|
||||
|
|
@ -70,7 +73,7 @@ describe('opacity via updateNodeWithUndo + runBatch', () => {
|
|||
})
|
||||
editor.select([rect.id, rect2.id])
|
||||
|
||||
setOpacity(editor, 0.7)
|
||||
editor.setOpacity(0.7)
|
||||
expect(getNodeOrThrow(editor.graph, rect.id).opacity).toBe(0.7)
|
||||
expect(getNodeOrThrow(editor.graph, rect2.id).opacity).toBe(0.7)
|
||||
|
||||
|
|
@ -82,8 +85,16 @@ describe('opacity via updateNodeWithUndo + runBatch', () => {
|
|||
test('no-op when opacity already matches', () => {
|
||||
const { editor, rect } = setup()
|
||||
|
||||
setOpacity(editor, 1)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue