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:
Danila Poyarkov 2026-04-09 18:22:34 +03:00
parent a130d9ce1d
commit b3053601f7
8 changed files with 545 additions and 137 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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

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