Add arrow key nudge and fix undo/redo bugs
Nudge: Arrow keys move selection by 1px, Shift+Arrow by 10px. Consecutive nudges within 300ms are batched into a single undo entry so Cmd+Z undoes the entire sequence at once. Fix duplicate redo creating nodes with new IDs, breaking subsequent undo entries that reference the original IDs. Now snapshotSubtree/restoreSubtree preserve node IDs across redo. Fix delete undo not restoring children — deleteSelected only snapshotted top-level nodes, so undoing a frame delete lost all its children. Now snapshots the full subtree. Fix draw tool not committing resize to undo — shapes created via draw had their final dimensions lost on redo because handleDrawUp never recorded the resize. Now uses beginBatch/commitBatch to combine create+resize into a single undo entry. Clean up use-keyboard.ts — remove shouldPreventDefault and opaque PREVENT_* sets, move tool switch and nudge to dedicated keydown listener, add shift() helper. Closes #182
This commit is contained in:
parent
a130d9ce1d
commit
b3053601f7
|
|
@ -45,13 +45,38 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
return failed
|
||||
}
|
||||
|
||||
function snapshotSubtree(rootId: string): Map<string, SceneNode> {
|
||||
const index = new Map<string, SceneNode>()
|
||||
const walk = (id: string) => {
|
||||
const node = ctx.graph.getNode(id)
|
||||
if (!node) return
|
||||
index.set(id, structuredClone(node))
|
||||
for (const childId of node.childIds) walk(childId)
|
||||
}
|
||||
walk(rootId)
|
||||
return index
|
||||
}
|
||||
|
||||
function restoreSubtree(
|
||||
snapshot: SceneNode,
|
||||
parentId: string,
|
||||
index: Map<string, SceneNode>
|
||||
): void {
|
||||
const { parentId: _p, childIds, ...rest } = snapshot
|
||||
ctx.graph.createNode(snapshot.type, parentId, { ...rest, id: snapshot.id })
|
||||
for (const childId of childIds) {
|
||||
const child = index.get(childId)
|
||||
if (child) restoreSubtree(child, snapshot.id, index)
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateSelected(selectedNodes: SceneNode[]) {
|
||||
const prevSelection = new Set(ctx.state.selectedIds)
|
||||
const selectedSet = new Set(selectedNodes.map((n) => n.id))
|
||||
const topLevel = selectedNodes.filter((n) => !n.parentId || !selectedSet.has(n.parentId))
|
||||
|
||||
const newRootIds: string[] = []
|
||||
const rootSnapshots: SceneNode[] = []
|
||||
const allSnapshots = new Map<string, SceneNode>()
|
||||
|
||||
for (const node of topLevel) {
|
||||
const parentId = node.parentId ?? ctx.state.currentPageId
|
||||
|
|
@ -62,38 +87,8 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
})
|
||||
if (!clone) continue
|
||||
newRootIds.push(clone.id)
|
||||
const snapshot = ctx.graph.getNode(clone.id)
|
||||
if (snapshot) rootSnapshots.push(structuredClone(snapshot))
|
||||
}
|
||||
|
||||
const cloneSnapshotTree = (snapshot: SceneNode): SceneNode => {
|
||||
const cloned = structuredClone(snapshot)
|
||||
cloned.childIds = snapshot.childIds
|
||||
.map((childId) => ctx.graph.getNode(childId))
|
||||
.filter((child): child is SceneNode => child != null)
|
||||
.map((child) => cloneSnapshotTree(child))
|
||||
.map((child) => child.id)
|
||||
return cloned
|
||||
}
|
||||
|
||||
for (let i = 0; i < rootSnapshots.length; i++) {
|
||||
rootSnapshots[i] = cloneSnapshotTree(rootSnapshots[i])
|
||||
}
|
||||
|
||||
const snapshotIndex = new Map<string, SceneNode>()
|
||||
for (const snapshot of rootSnapshots) {
|
||||
snapshotIndex.set(snapshot.id, snapshot)
|
||||
}
|
||||
|
||||
const restoreTree = (snapshot: SceneNode, parentId: string) => {
|
||||
const { id: _snapshotId, parentId: _snapshotParentId, childIds, ...rest } = snapshot
|
||||
const created = ctx.graph.createNode(snapshot.type, parentId, rest)
|
||||
for (const childId of childIds) {
|
||||
const child = snapshotIndex.get(childId)
|
||||
if (!child) continue
|
||||
restoreTree(child, created.id)
|
||||
}
|
||||
return created.id
|
||||
const subtree = snapshotSubtree(clone.id)
|
||||
for (const [id, snap] of subtree) allSnapshots.set(id, snap)
|
||||
}
|
||||
|
||||
if (newRootIds.length > 0) {
|
||||
|
|
@ -101,15 +96,16 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
ctx.undo.push({
|
||||
label: 'Duplicate',
|
||||
forward: () => {
|
||||
const restoredRootIds: string[] = []
|
||||
for (const snapshot of rootSnapshots) {
|
||||
for (const rootId of newRootIds) {
|
||||
const snapshot = allSnapshots.get(rootId)
|
||||
if (!snapshot) continue
|
||||
const parentId = snapshot.parentId ?? ctx.state.currentPageId
|
||||
restoredRootIds.push(restoreTree(snapshot, parentId))
|
||||
restoreSubtree(snapshot, parentId, allSnapshots)
|
||||
}
|
||||
ctx.state.selectedIds = new Set(restoredRootIds)
|
||||
ctx.state.selectedIds = new Set(newRootIds)
|
||||
},
|
||||
inverse: () => {
|
||||
for (const id of [...ctx.state.selectedIds].reverse()) ctx.graph.deleteNode(id)
|
||||
for (const id of newRootIds.slice().reverse()) ctx.graph.deleteNode(id)
|
||||
ctx.state.selectedIds = prevSelection
|
||||
}
|
||||
})
|
||||
|
|
@ -180,14 +176,19 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
}
|
||||
|
||||
function deleteSelected() {
|
||||
const entries: Array<{ id: string; parentId: string; snapshot: SceneNode; index: number }> = []
|
||||
const entries: Array<{
|
||||
id: string
|
||||
parentId: string
|
||||
index: number
|
||||
subtree: Map<string, SceneNode>
|
||||
}> = []
|
||||
for (const id of ctx.state.selectedIds) {
|
||||
const node = ctx.graph.getNode(id)
|
||||
if (!node || node.locked) continue
|
||||
const parentId = node.parentId ?? ctx.state.currentPageId
|
||||
const parent = ctx.graph.getNode(parentId)
|
||||
const index = parent?.childIds.indexOf(id) ?? -1
|
||||
entries.push({ id, parentId, snapshot: { ...node }, index })
|
||||
entries.push({ id, parentId, index, subtree: snapshotSubtree(id) })
|
||||
}
|
||||
if (entries.length === 0) return
|
||||
|
||||
|
|
@ -201,11 +202,10 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
ctx.state.selectedIds = new Set()
|
||||
},
|
||||
inverse: () => {
|
||||
for (const { snapshot, parentId, index } of [...entries].reverse()) {
|
||||
ctx.graph.createNode(snapshot.type, parentId, snapshot)
|
||||
if (index >= 0) {
|
||||
ctx.graph.reorderChild(snapshot.id, parentId, index)
|
||||
}
|
||||
for (const { id, parentId, index, subtree } of [...entries].reverse()) {
|
||||
const rootSnap = subtree.get(id)
|
||||
if (rootSnap) restoreSubtree(rootSnap, parentId, subtree)
|
||||
if (index >= 0) ctx.graph.reorderChild(id, parentId, index)
|
||||
}
|
||||
ctx.state.selectedIds = prevSelection
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { computeLayout } from '../layout'
|
||||
|
||||
import type { LayoutMode, SceneNode } from '../scene-graph'
|
||||
import type { Vector } from '../types'
|
||||
import type { EditorContext } from './types'
|
||||
|
||||
const NUDGE_COMMIT_DELAY = 300
|
||||
|
||||
export function createNodeActions(ctx: EditorContext) {
|
||||
function updateNode(id: string, changes: Partial<SceneNode>) {
|
||||
ctx.graph.updateNode(id, changes)
|
||||
|
|
@ -157,5 +160,83 @@ export function createNodeActions(ctx: EditorContext) {
|
|||
ctx.requestRender()
|
||||
}
|
||||
|
||||
return { updateNode, updateNodeWithUndo, setLayoutMode, bindVariable, unbindVariable }
|
||||
let nudgeOriginals: Map<string, Vector> | null = null
|
||||
let nudgeCommitTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function commitNudge() {
|
||||
if (!nudgeOriginals) return
|
||||
const originals = nudgeOriginals
|
||||
nudgeOriginals = null
|
||||
nudgeCommitTimer = null
|
||||
|
||||
const finals = new Map<string, Vector>()
|
||||
for (const [id] of originals) {
|
||||
const node = ctx.graph.getNode(id)
|
||||
if (node) finals.set(id, { x: node.x, y: node.y })
|
||||
}
|
||||
ctx.undo.push({
|
||||
label: 'Nudge',
|
||||
forward: () => {
|
||||
for (const [id, pos] of finals) {
|
||||
ctx.graph.updateNode(id, pos)
|
||||
ctx.runLayoutForNode(id)
|
||||
}
|
||||
},
|
||||
inverse: () => {
|
||||
for (const [id, pos] of originals) {
|
||||
ctx.graph.updateNode(id, pos)
|
||||
ctx.runLayoutForNode(id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function nudgeSelected(dx: number, dy: number) {
|
||||
const ids = [...ctx.state.selectedIds]
|
||||
if (ids.length === 0) return
|
||||
|
||||
const movable: string[] = []
|
||||
for (const id of ids) {
|
||||
const node = ctx.graph.getNode(id)
|
||||
if (node && !node.locked) movable.push(id)
|
||||
}
|
||||
if (movable.length === 0) return
|
||||
|
||||
if (!nudgeOriginals) {
|
||||
nudgeOriginals = new Map()
|
||||
for (const id of movable) {
|
||||
const node = ctx.graph.getNode(id)!
|
||||
nudgeOriginals.set(id, { x: node.x, y: node.y })
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of movable) {
|
||||
const node = ctx.graph.getNode(id)
|
||||
if (!node) continue
|
||||
ctx.graph.updateNode(id, { x: node.x + dx, y: node.y + dy })
|
||||
ctx.runLayoutForNode(id)
|
||||
}
|
||||
|
||||
if (nudgeCommitTimer) clearTimeout(nudgeCommitTimer)
|
||||
nudgeCommitTimer = setTimeout(commitNudge, NUDGE_COMMIT_DELAY)
|
||||
|
||||
ctx.requestRender()
|
||||
}
|
||||
|
||||
function flushNudge() {
|
||||
if (nudgeCommitTimer) {
|
||||
clearTimeout(nudgeCommitTimer)
|
||||
commitNudge()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updateNode,
|
||||
updateNodeWithUndo,
|
||||
setLayoutMode,
|
||||
bindVariable,
|
||||
unbindVariable,
|
||||
nudgeSelected,
|
||||
flushNudge
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -460,6 +460,7 @@ export function useCanvasInput(
|
|||
const nodeType = TOOL_TO_NODE[tool]
|
||||
if (!nodeType) return
|
||||
|
||||
editor.undo.beginBatch('Create shape')
|
||||
const nodeId = editor.createShape(nodeType, cx, cy, 0, 0)
|
||||
editor.select([nodeId])
|
||||
|
||||
|
|
|
|||
|
|
@ -33,5 +33,7 @@ export function handleDrawUp(d: DragDraw, editor: Editor) {
|
|||
if (node?.type === 'SECTION') {
|
||||
editor.adoptNodesIntoSection(node.id)
|
||||
}
|
||||
editor.commitResize(d.nodeId, { x: d.startX, y: d.startY, width: 0, height: 0 })
|
||||
editor.undo.commitBatch()
|
||||
editor.setTool('SELECT')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,42 +18,11 @@ function isEditing(e: Event) {
|
|||
return e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement
|
||||
}
|
||||
|
||||
const PREVENT_MOD_ALT = new Set(['KeyK', 'KeyB'])
|
||||
const PREVENT_MOD_SHIFT = new Set(['KeyK', 'KeyH', 'KeyL', 'KeyE', 'KeyS', 'KeyG', 'KeyZ'])
|
||||
const PREVENT_MOD_ONLY = new Set([
|
||||
'Backslash',
|
||||
'KeyJ',
|
||||
'KeyW',
|
||||
'KeyN',
|
||||
'KeyT',
|
||||
'KeyZ',
|
||||
'KeyY',
|
||||
'Digit0',
|
||||
'Digit1',
|
||||
'Digit2',
|
||||
'KeyD',
|
||||
'KeyA',
|
||||
'KeyS',
|
||||
'KeyO',
|
||||
'KeyG'
|
||||
])
|
||||
const PREVENT_SHIFT_ONLY = new Set(['Digit1', 'Digit2', 'KeyA'])
|
||||
const PREVENT_PLAIN_KEY = new Set(['BracketLeft', 'BracketRight'])
|
||||
const PREVENT_DELETE_KEY = new Set(['Backspace', 'Delete'])
|
||||
|
||||
function shouldPreventDefault(e: KeyboardEvent, hasPenState: boolean): boolean {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
|
||||
if (mod) {
|
||||
if (e.altKey && PREVENT_MOD_ALT.has(e.code)) return true
|
||||
if (e.shiftKey && PREVENT_MOD_SHIFT.has(e.code)) return true
|
||||
if (!e.shiftKey && !e.altKey && PREVENT_MOD_ONLY.has(e.code)) return true
|
||||
} else {
|
||||
if (e.shiftKey && PREVENT_SHIFT_ONLY.has(e.code)) return true
|
||||
if (!e.shiftKey && PREVENT_PLAIN_KEY.has(e.code)) return true
|
||||
}
|
||||
|
||||
return PREVENT_DELETE_KEY.has(e.code) || (e.code === 'Enter' && hasPenState)
|
||||
const NUDGE_DELTAS: Partial<Record<string, [number, number]>> = {
|
||||
ArrowUp: [0, -1],
|
||||
ArrowDown: [0, 1],
|
||||
ArrowLeft: [-1, 0],
|
||||
ArrowRight: [1, 0]
|
||||
}
|
||||
|
||||
export function useKeyboard() {
|
||||
|
|
@ -62,6 +31,8 @@ export function useKeyboard() {
|
|||
const { isMobile } = useViewportKind()
|
||||
const { runCommand } = useEditorCommands()
|
||||
|
||||
// ─── Clipboard ──────────────────────────────────────────────
|
||||
|
||||
useEventListener(window, 'copy', (e: ClipboardEvent) => {
|
||||
if (isEditing(e)) return
|
||||
e.preventDefault()
|
||||
|
|
@ -94,7 +65,8 @@ export function useKeyboard() {
|
|||
if (html) store.pasteFromHTML(html, cursorPos)
|
||||
})
|
||||
|
||||
// Spacebar hold → temporary Hand tool (Figma-style canvas pan)
|
||||
// ─── Space hold → temporary Hand tool ──────────────────────
|
||||
|
||||
let toolBeforeSpace: typeof store.state.activeTool | null = null
|
||||
|
||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
|
|
@ -123,31 +95,43 @@ export function useKeyboard() {
|
|||
}
|
||||
})
|
||||
|
||||
// ─── Raw keydown: tool switch, nudge, preventDefault ───────
|
||||
|
||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
if (isEditing(e) || store.state.editingTextId) return
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return
|
||||
|
||||
if (e.code === 'Space') return
|
||||
|
||||
const tool = TOOL_SHORTCUTS[e.code]
|
||||
if (tool && !e.shiftKey) {
|
||||
toolBeforeSpace = null
|
||||
store.setTool(tool)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const delta = NUDGE_DELTAS[e.code]
|
||||
if (delta && store.state.selectedIds.size > 0) {
|
||||
const step = e.shiftKey ? 10 : 1
|
||||
store.nudgeSelected(delta[0] * step, delta[1] * step)
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
// ─── useMagicKeys for modifier combos ──────────────────────
|
||||
|
||||
const keys = useMagicKeys({
|
||||
passive: false,
|
||||
onEventFired(e) {
|
||||
if (e.type !== 'keydown') return
|
||||
if (isEditing(e)) return
|
||||
if (store.state.editingTextId) return
|
||||
|
||||
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
// Space is handled by hold-to-pan above
|
||||
if (e.code === 'Space') return
|
||||
const tool = TOOL_SHORTCUTS[e.code]
|
||||
if (tool) {
|
||||
// Permanent tool switch cancels space-hold
|
||||
toolBeforeSpace = null
|
||||
store.setTool(tool)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldPreventDefault(e, !!store.state.penState)) e.preventDefault()
|
||||
if (isEditing(e) || store.state.editingTextId) return
|
||||
if (e.code === 'Backspace' || e.code === 'Delete') e.preventDefault()
|
||||
if (e.code === 'BracketLeft' || e.code === 'BracketRight') e.preventDefault()
|
||||
if (e.code === 'Enter' && store.state.penState) e.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
// Cross-platform mod: true when Meta (Mac) or Control (Win/Linux) is pressed with the combo.
|
||||
// Checks that no extra modifiers are held beyond what the combo specifies.
|
||||
function mod(combo: string): ComputedRef<boolean> {
|
||||
const hasShift = combo.includes('shift')
|
||||
const hasAlt = combo.includes('alt')
|
||||
|
|
@ -158,11 +142,32 @@ export function useKeyboard() {
|
|||
return computed(() => base.value && !keys['shift'].value && !keys['alt'].value)
|
||||
}
|
||||
|
||||
// --- Mod + Alt ---
|
||||
function shift(key: string): ComputedRef<boolean> {
|
||||
return computed(
|
||||
() => keys[`shift+${key}`].value && !keys['meta'].value && !keys['control'].value
|
||||
)
|
||||
}
|
||||
|
||||
function plain(key: string, options?: { allowAlt?: boolean }): ComputedRef<boolean> {
|
||||
const allowAlt = options?.allowAlt ?? false
|
||||
return computed(
|
||||
() =>
|
||||
keys[key].value &&
|
||||
!keys['meta'].value &&
|
||||
!keys['control'].value &&
|
||||
!keys['shift'].value &&
|
||||
(allowAlt || !keys['alt'].value) &&
|
||||
!store.state.editingTextId
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Mod + Alt ─────────────────────────────────────────────
|
||||
|
||||
whenever(mod('alt+keyk'), () => runCommand('selection.createComponent'))
|
||||
whenever(mod('alt+keyb'), () => runCommand('selection.detachInstance'))
|
||||
|
||||
// --- Mod + Shift ---
|
||||
// ─── Mod + Shift ───────────────────────────────────────────
|
||||
|
||||
whenever(mod('shift+keyk'), () => runCommand('selection.createComponentSet'))
|
||||
whenever(mod('shift+keyh'), () => runCommand('selection.toggleVisibility'))
|
||||
whenever(mod('shift+keyl'), () => runCommand('selection.toggleLock'))
|
||||
|
|
@ -173,7 +178,8 @@ export function useKeyboard() {
|
|||
whenever(mod('shift+keyg'), () => runCommand('selection.ungroup'))
|
||||
whenever(mod('shift+keyz'), () => runCommand('edit.redo'))
|
||||
|
||||
// --- Mod + Key ---
|
||||
// ─── Mod ───────────────────────────────────────────────────
|
||||
|
||||
whenever(mod('backslash'), () => {
|
||||
store.state.showUI = !store.state.showUI
|
||||
})
|
||||
|
|
@ -203,40 +209,20 @@ export function useKeyboard() {
|
|||
whenever(mod('keyo'), () => openFileDialog())
|
||||
whenever(mod('keyg'), () => runCommand('selection.group'))
|
||||
|
||||
// --- Shift (no mod) ---
|
||||
whenever(
|
||||
computed(() => keys['shift+digit1'].value && !keys['meta'].value && !keys['control'].value),
|
||||
() => runCommand('view.zoomFit')
|
||||
)
|
||||
whenever(
|
||||
computed(() => keys['shift+digit2'].value && !keys['meta'].value && !keys['control'].value),
|
||||
() => runCommand('view.zoomSelection')
|
||||
)
|
||||
whenever(
|
||||
computed(() => keys['shift+keya'].value && !keys['meta'].value && !keys['control'].value),
|
||||
() => {
|
||||
const node = store.selectedNode.value
|
||||
if (node?.type === 'FRAME' && store.selectedNodes.value.length === 1) {
|
||||
store.setLayoutMode(node.id, node.layoutMode === 'NONE' ? 'VERTICAL' : 'NONE')
|
||||
} else if (store.selectedNodes.value.length > 0) {
|
||||
runCommand('selection.wrapInAutoLayout')
|
||||
}
|
||||
}
|
||||
)
|
||||
// ─── Shift (no mod) ────────────────────────────────────────
|
||||
|
||||
// --- Plain keys (no modifiers) ---
|
||||
function plain(key: string, options?: { allowAlt?: boolean }): ComputedRef<boolean> {
|
||||
const allowAlt = options?.allowAlt ?? false
|
||||
return computed(
|
||||
() =>
|
||||
keys[key].value &&
|
||||
!keys['meta'].value &&
|
||||
!keys['control'].value &&
|
||||
!keys['shift'].value &&
|
||||
(allowAlt || !keys['alt'].value) &&
|
||||
!store.state.editingTextId
|
||||
)
|
||||
}
|
||||
whenever(shift('digit1'), () => runCommand('view.zoomFit'))
|
||||
whenever(shift('digit2'), () => runCommand('view.zoomSelection'))
|
||||
whenever(shift('keya'), () => {
|
||||
const node = store.selectedNode.value
|
||||
if (node?.type === 'FRAME' && store.selectedNodes.value.length === 1) {
|
||||
store.setLayoutMode(node.id, node.layoutMode === 'NONE' ? 'VERTICAL' : 'NONE')
|
||||
} else if (store.selectedNodes.value.length > 0) {
|
||||
runCommand('selection.wrapInAutoLayout')
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Plain keys ────────────────────────────────────────────
|
||||
|
||||
whenever(plain('BracketRight'), () => runCommand('selection.bringToFront'))
|
||||
whenever(plain('BracketLeft'), () => runCommand('selection.sendToBack'))
|
||||
|
|
|
|||
122
tests/engine/nudge.test.ts
Normal file
122
tests/engine/nudge.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
import { createEditor } from '@open-pencil/core/editor'
|
||||
|
||||
describe('nudgeSelected', () => {
|
||||
function setup() {
|
||||
const editor = createEditor()
|
||||
const pageId = editor.graph.getPages()[0].id
|
||||
const rect = editor.graph.createNode('RECTANGLE', pageId, {
|
||||
name: 'Rect',
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 50,
|
||||
height: 50,
|
||||
})
|
||||
editor.select([rect.id])
|
||||
return { editor, rect }
|
||||
}
|
||||
|
||||
test('nudge moves selected node by 1px', () => {
|
||||
const { editor, rect } = setup()
|
||||
|
||||
editor.nudgeSelected(1, 0)
|
||||
expect(editor.graph.getNode(rect.id)!.x).toBe(101)
|
||||
expect(editor.graph.getNode(rect.id)!.y).toBe(200)
|
||||
|
||||
editor.nudgeSelected(0, -1)
|
||||
expect(editor.graph.getNode(rect.id)!.x).toBe(101)
|
||||
expect(editor.graph.getNode(rect.id)!.y).toBe(199)
|
||||
})
|
||||
|
||||
test('shift nudge moves by 10px', () => {
|
||||
const { editor, rect } = setup()
|
||||
|
||||
editor.nudgeSelected(10, 0)
|
||||
expect(editor.graph.getNode(rect.id)!.x).toBe(110)
|
||||
|
||||
editor.nudgeSelected(0, 10)
|
||||
expect(editor.graph.getNode(rect.id)!.y).toBe(210)
|
||||
})
|
||||
|
||||
test('nudge is undoable as a single entry', () => {
|
||||
const { editor, rect } = setup()
|
||||
|
||||
editor.nudgeSelected(1, 0)
|
||||
editor.nudgeSelected(1, 0)
|
||||
editor.nudgeSelected(1, 0)
|
||||
editor.flushNudge()
|
||||
|
||||
expect(editor.graph.getNode(rect.id)!.x).toBe(103)
|
||||
expect(editor.undo.canUndo).toBe(true)
|
||||
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(rect.id)!.x).toBe(100)
|
||||
expect(editor.graph.getNode(rect.id)!.y).toBe(200)
|
||||
|
||||
editor.undo.redo()
|
||||
expect(editor.graph.getNode(rect.id)!.x).toBe(103)
|
||||
})
|
||||
|
||||
test('nudge skips locked nodes', () => {
|
||||
const { editor, rect } = setup()
|
||||
|
||||
editor.graph.updateNode(rect.id, { locked: true })
|
||||
editor.nudgeSelected(10, 10)
|
||||
editor.flushNudge()
|
||||
expect(editor.graph.getNode(rect.id)!.x).toBe(100)
|
||||
expect(editor.graph.getNode(rect.id)!.y).toBe(200)
|
||||
expect(editor.undo.canUndo).toBe(false)
|
||||
})
|
||||
|
||||
test('nudge does nothing with no selection', () => {
|
||||
const { editor } = setup()
|
||||
|
||||
editor.clearSelection()
|
||||
editor.nudgeSelected(10, 10)
|
||||
editor.flushNudge()
|
||||
expect(editor.undo.canUndo).toBe(false)
|
||||
})
|
||||
|
||||
test('nudge moves multiple selected nodes', () => {
|
||||
const { editor, rect } = setup()
|
||||
const pageId = editor.graph.getPages()[0].id
|
||||
const rect2 = editor.graph.createNode('RECTANGLE', pageId, {
|
||||
name: 'Rect2',
|
||||
x: 300,
|
||||
y: 400,
|
||||
width: 50,
|
||||
height: 50,
|
||||
})
|
||||
editor.select([rect.id, rect2.id])
|
||||
|
||||
editor.nudgeSelected(-5, 3)
|
||||
editor.flushNudge()
|
||||
|
||||
expect(editor.graph.getNode(rect.id)!.x).toBe(95)
|
||||
expect(editor.graph.getNode(rect.id)!.y).toBe(203)
|
||||
expect(editor.graph.getNode(rect2.id)!.x).toBe(295)
|
||||
expect(editor.graph.getNode(rect2.id)!.y).toBe(403)
|
||||
})
|
||||
|
||||
test('separate nudge sequences create separate undo entries', () => {
|
||||
const { editor, rect } = setup()
|
||||
|
||||
editor.nudgeSelected(5, 0)
|
||||
editor.flushNudge()
|
||||
|
||||
editor.nudgeSelected(0, 5)
|
||||
editor.flushNudge()
|
||||
|
||||
expect(editor.graph.getNode(rect.id)!.x).toBe(105)
|
||||
expect(editor.graph.getNode(rect.id)!.y).toBe(205)
|
||||
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(rect.id)!.x).toBe(105)
|
||||
expect(editor.graph.getNode(rect.id)!.y).toBe(200)
|
||||
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(rect.id)!.x).toBe(100)
|
||||
expect(editor.graph.getNode(rect.id)!.y).toBe(200)
|
||||
})
|
||||
})
|
||||
63
tests/engine/undo-create-shape.test.ts
Normal file
63
tests/engine/undo-create-shape.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
import { createEditor } from '@open-pencil/core/editor'
|
||||
|
||||
describe('create shape undo/redo', () => {
|
||||
test('batched create+resize undoes in one step', () => {
|
||||
const editor = createEditor()
|
||||
|
||||
// Simulate draw tool: batch(create + resize)
|
||||
editor.undo.beginBatch('Create shape')
|
||||
const id = editor.createShape('RECTANGLE', 100, 100, 0, 0)
|
||||
editor.graph.updateNode(id, { x: 100, y: 100, width: 200, height: 150 })
|
||||
editor.commitResize(id, { x: 100, y: 100, width: 0, height: 0 })
|
||||
editor.undo.commitBatch()
|
||||
|
||||
expect(editor.graph.getNode(id)!.width).toBe(200)
|
||||
expect(editor.graph.getNode(id)!.height).toBe(150)
|
||||
|
||||
// Single undo removes the shape entirely
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(id)).toBeUndefined()
|
||||
|
||||
// Single redo restores with full dimensions
|
||||
editor.undo.redo()
|
||||
expect(editor.graph.getNode(id)).not.toBeUndefined()
|
||||
expect(editor.graph.getNode(id)!.width).toBe(200)
|
||||
expect(editor.graph.getNode(id)!.height).toBe(150)
|
||||
expect(editor.graph.getNode(id)!.x).toBe(100)
|
||||
expect(editor.graph.getNode(id)!.y).toBe(100)
|
||||
})
|
||||
|
||||
test('redo after create+move+duplicate restores correct state', () => {
|
||||
const editor = createEditor()
|
||||
|
||||
// Create shape via draw tool (batched)
|
||||
editor.undo.beginBatch('Create shape')
|
||||
const id = editor.createShape('RECTANGLE', 50, 50, 0, 0)
|
||||
editor.graph.updateNode(id, { x: 50, y: 50, width: 120, height: 80 })
|
||||
editor.commitResize(id, { x: 50, y: 50, width: 0, height: 0 })
|
||||
editor.undo.commitBatch()
|
||||
|
||||
// Move it
|
||||
editor.select([id])
|
||||
editor.graph.updateNode(id, { x: 200, y: 200 })
|
||||
editor.commitMove(new Map([[id, { x: 50, y: 50 }]]))
|
||||
|
||||
// Undo move
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(id)!.x).toBe(50)
|
||||
|
||||
// Undo create (single step)
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(id)).toBeUndefined()
|
||||
|
||||
// Redo create (single step, full dimensions)
|
||||
editor.undo.redo()
|
||||
expect(editor.graph.getNode(id)!.width).toBe(120)
|
||||
|
||||
// Redo move
|
||||
editor.undo.redo()
|
||||
expect(editor.graph.getNode(id)!.x).toBe(200)
|
||||
})
|
||||
})
|
||||
153
tests/engine/undo-redo-sequence.test.ts
Normal file
153
tests/engine/undo-redo-sequence.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
import { createEditor } from '@open-pencil/core/editor'
|
||||
|
||||
describe('undo/redo multi-step sequences', () => {
|
||||
function setup() {
|
||||
const editor = createEditor()
|
||||
const pageId = editor.graph.getPages()[0].id
|
||||
return { editor, pageId }
|
||||
}
|
||||
|
||||
test('create → move → duplicate → move copy → undo all → redo all', () => {
|
||||
const { editor, pageId } = setup()
|
||||
|
||||
const frame = editor.graph.createNode('FRAME', pageId, {
|
||||
name: 'Card',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 150,
|
||||
})
|
||||
editor.select([frame.id])
|
||||
const createSnapshot = structuredClone(editor.graph.getNode(frame.id)!)
|
||||
editor.pushUndoEntry({
|
||||
label: 'Create',
|
||||
forward: () => {
|
||||
const { parentId: _p, childIds: _c, ...rest } = createSnapshot
|
||||
editor.graph.createNode('FRAME', pageId, rest)
|
||||
},
|
||||
inverse: () => editor.graph.deleteNode(frame.id),
|
||||
})
|
||||
|
||||
editor.graph.updateNode(frame.id, { x: 300, y: 50 })
|
||||
editor.commitMove(new Map([[frame.id, { x: 100, y: 100 }]]))
|
||||
|
||||
expect(editor.graph.getNode(frame.id)!.x).toBe(300)
|
||||
expect(editor.graph.getNode(frame.id)!.y).toBe(50)
|
||||
|
||||
editor.duplicateSelected()
|
||||
const dupIds = [...editor.state.selectedIds]
|
||||
expect(dupIds).toHaveLength(1)
|
||||
const dupId = dupIds[0]
|
||||
expect(dupId).not.toBe(frame.id)
|
||||
|
||||
editor.graph.updateNode(dupId, { x: 500, y: 200 })
|
||||
editor.commitMove(new Map([[dupId, { x: 320, y: 70 }]]))
|
||||
|
||||
// Undo move copy
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(dupId)!.x).toBe(320)
|
||||
|
||||
// Undo duplicate
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(dupId)).toBeUndefined()
|
||||
|
||||
// Undo move
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(frame.id)!.x).toBe(100)
|
||||
|
||||
// Undo create
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(frame.id)).toBeUndefined()
|
||||
|
||||
// Redo create
|
||||
editor.undo.redo()
|
||||
expect(editor.graph.getNode(frame.id)).not.toBeUndefined()
|
||||
expect(editor.graph.getNode(frame.id)!.x).toBe(100)
|
||||
|
||||
// Redo move
|
||||
editor.undo.redo()
|
||||
expect(editor.graph.getNode(frame.id)!.x).toBe(300)
|
||||
|
||||
// Redo duplicate — must recreate with SAME ID
|
||||
editor.undo.redo()
|
||||
expect(editor.graph.getNode(dupId)).not.toBeUndefined()
|
||||
|
||||
// Redo move copy — must find the node by same ID
|
||||
editor.undo.redo()
|
||||
expect(editor.graph.getNode(dupId)!.x).toBe(500)
|
||||
expect(editor.graph.getNode(dupId)!.y).toBe(200)
|
||||
})
|
||||
|
||||
test('duplicate with children preserves subtree on redo', () => {
|
||||
const { editor, pageId } = setup()
|
||||
|
||||
const frame = editor.graph.createNode('FRAME', pageId, {
|
||||
name: 'Card',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 150,
|
||||
})
|
||||
editor.graph.createNode('TEXT', frame.id, {
|
||||
name: 'Title',
|
||||
text: 'Hello',
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 100,
|
||||
height: 20,
|
||||
})
|
||||
|
||||
editor.select([frame.id])
|
||||
editor.duplicateSelected()
|
||||
|
||||
const dupFrameId = [...editor.state.selectedIds][0]
|
||||
const dupChildren = editor.graph.getNode(dupFrameId)!.childIds
|
||||
expect(dupChildren).toHaveLength(1)
|
||||
const dupTextId = dupChildren[0]
|
||||
expect(editor.graph.getNode(dupTextId)!.text).toBe('Hello')
|
||||
|
||||
// Undo
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(dupFrameId)).toBeUndefined()
|
||||
expect(editor.graph.getNode(dupTextId)).toBeUndefined()
|
||||
|
||||
// Redo — must recreate with same IDs
|
||||
editor.undo.redo()
|
||||
expect(editor.graph.getNode(dupFrameId)).not.toBeUndefined()
|
||||
expect(editor.graph.getNode(dupTextId)).not.toBeUndefined()
|
||||
expect(editor.graph.getNode(dupFrameId)!.childIds).toContain(dupTextId)
|
||||
expect(editor.graph.getNode(dupTextId)!.text).toBe('Hello')
|
||||
})
|
||||
|
||||
test('delete frame with children → undo restores subtree', () => {
|
||||
const { editor, pageId } = setup()
|
||||
|
||||
const frame = editor.graph.createNode('FRAME', pageId, {
|
||||
name: 'Card',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 150,
|
||||
})
|
||||
const child = editor.graph.createNode('RECTANGLE', frame.id, {
|
||||
name: 'Bg',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 150,
|
||||
})
|
||||
|
||||
editor.select([frame.id])
|
||||
editor.deleteSelected()
|
||||
|
||||
expect(editor.graph.getNode(frame.id)).toBeUndefined()
|
||||
expect(editor.graph.getNode(child.id)).toBeUndefined()
|
||||
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(frame.id)).not.toBeUndefined()
|
||||
expect(editor.graph.getNode(child.id)).not.toBeUndefined()
|
||||
expect(editor.graph.getNode(frame.id)!.childIds).toContain(child.id)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue