fix: refresh Undo and Redo command availability

Publish committed history changes independently of scene mutations so menus observe history recorded after the final draw. Align the assets regression with the documented top-left default.
This commit is contained in:
Danila Poyarkov 2026-09-14 00:25:36 +03:00
parent 25cc522f12
commit 3ea63ad09f
8 changed files with 86 additions and 12 deletions

View file

@ -57,6 +57,7 @@
### Fixed
- Keep Undo and Redo commands available as edit history changes, without requiring another scene edit.
- Avoid recursive desktop HTTP proxy requests when font downloads intercept Tauri IPC traffic.
- Keep FIT image fills proportional, centered, and fully visible without stretching or cropped edges.
- Preserve edited instance text, including cleared labels, when saving and reopening `.fig` files.

View file

@ -54,7 +54,7 @@ export { createDefaultEditorState } from './state'
export function createEditor(options?: EditorOptions) {
let _graph = options?.graph ?? new SceneGraph()
const skipInitialGraphSetup = options?.skipInitialGraphSetup ?? false
const undo = new UndoManager()
const undo = new UndoManager({ onChange: () => emitEditorEvent('history:changed') })
const _loadFont = options?.loadFont ?? fontManager.loadFont.bind(fontManager)
const _getViewportSize =
options?.getViewportSize ??

View file

@ -122,6 +122,7 @@ export interface EditorEvents extends SceneGraphEvents {
'render:requested': (versions: { renderVersion: number; sceneVersion: number }) => void
'repaint:requested': (versions: { renderVersion: number; sceneVersion: number }) => void
'graph:replaced': (graph: SceneGraph) => void
'history:changed': () => void
'selection:changed': (selectedIds: string[], previousIds: string[]) => void
'tool:changed': (tool: Tool, previousTool: Tool) => void
'page:changed': (pageId: string, previousPageId: string) => void

View file

@ -7,6 +7,8 @@ export interface UndoEntry {
export interface UndoManagerOptions {
limit?: number
/** Called after recording, undoing, redoing, or clearing committed history. */
onChange?: () => void
}
interface UndoBatch {
@ -22,9 +24,11 @@ export class UndoManager {
private redoStack: UndoEntry[] = []
private batches: UndoBatch[] = []
private readonly limit: number
private readonly onChange: (() => void) | undefined
constructor(options: UndoManagerOptions = {}) {
this.limit = options.limit ?? DEFAULT_HISTORY_LIMIT
this.onChange = options.onChange
}
apply(entry: UndoEntry): void {
@ -54,6 +58,7 @@ export class UndoManager {
if (!entry) return null
entry.inverse()
this.redoStack.push(entry)
this.onChange?.()
return entry.label
}
@ -62,6 +67,7 @@ export class UndoManager {
if (!entry) return null
entry.forward()
this.undoStack.push(entry)
this.onChange?.()
return entry.label
}
@ -101,6 +107,7 @@ export class UndoManager {
this.undoStack = []
this.redoStack = []
this.batches = []
this.onChange?.()
}
get isBatching(): boolean {
@ -148,6 +155,7 @@ export class UndoManager {
}
this.redoStack = []
this.trimUndoStack()
this.onChange?.()
}
private trimUndoStack(): void {

View file

@ -1,7 +1,8 @@
import { computed } from 'vue'
import { computed, shallowRef, triggerRef } from 'vue'
import { canMakeBooleanSourceNode, hasVisibleStrokeSourceNode } from '@open-pencil/core/canvas'
import { useEditorEvent } from '#vue/editor/events/use'
import { useSelectionState } from '#vue/editor/selection-state/use'
import { useSceneComputed } from '#vue/internal/scene-computed/use'
@ -15,6 +16,8 @@ import { useSceneComputed } from '#vue/internal/scene-computed/use'
export function useSelectionCapabilities() {
const selection = useSelectionState()
const { editor, selectedIds, selectedNode, selectedCount, hasSelection } = selection
const history = shallowRef(editor.undo)
useEditorEvent('history:changed', () => triggerRef(history))
const selectedNodesCanFlatten = useSceneComputed(() => {
const nodes = editor.getSelectedNodes()
@ -73,8 +76,8 @@ export function useSelectionCapabilities() {
),
// In vector edit mode, undo/redo route to the session-local history —
// keep the commands enabled so the shortcut reaches them.
canUndo: useSceneComputed(() => editor.state.nodeEditState != null || editor.undo.canUndo),
canRedo: useSceneComputed(() => editor.state.nodeEditState != null || editor.undo.canRedo),
canUndo: useSceneComputed(() => editor.state.nodeEditState != null || history.value.canUndo),
canRedo: useSceneComputed(() => editor.state.nodeEditState != null || history.value.canRedo),
canZoomToSelection: computed(() => hasSelection.value)
}
}

View file

@ -112,11 +112,17 @@ test('Undo via Edit menu works', async () => {
expect(beforeUndo).toBe(1)
await editor.page.locator('[role="menubar"] [role="menuitem"]', { hasText: 'Edit' }).click()
await editor.page.locator('[role="menu"] [role="menuitem"]', { hasText: 'Undo' }).click()
const undoItem = editor.page.getByRole('menuitem', { name: /^Undo\b/ })
await expect(undoItem).toBeEnabled()
await undoItem.click()
await editor.canvas.waitForRender()
const afterUndo = await getStoreStateNumber('selectedIds')
expect(afterUndo).toBe(0)
await editor.page.getByRole('menuitem', { name: 'Edit', exact: true }).click()
await expect(editor.page.getByRole('menuitem', { name: /^Redo\b/ })).toBeEnabled()
await editor.page.keyboard.press('Escape')
})
test('Duplicate via Edit menu works', async () => {

View file

@ -169,22 +169,23 @@ test('assets panel groups component sets and inserts the default variant', async
const inserted = await selectedNodeSnapshot(page)
expect(inserted?.type).toBe('INSTANCE')
expect(inserted?.componentId).toBe(ids.secondaryId)
// The spatially first variant is the documented default, regardless of property defaults.
expect(inserted?.componentId).toBe(ids.primaryId)
expect(inserted?.parentId).toBe(inserted?.pageId)
expect(inserted?.width).toBe(132)
expect(inserted?.childTexts).toEqual(['Secondary'])
expect(inserted?.width).toBe(96)
expect(inserted?.childTexts).toEqual(['Primary'])
const variantSection = page.getByRole('region', { name: 'Variants' })
await expect(variantSection).toBeVisible()
await variantSection.getByRole('combobox', { name: 'Type' }).click()
await page.getByRole('option', { name: 'Primary' }).click()
await page.getByRole('option', { name: 'Secondary' }).click()
expectDefined(inserted?.id, 'inserted instance id')
const switched = await selectedNodeSnapshot(page)
expect(switched?.componentId).toBe(ids.primaryId)
expect(switched?.width).toBe(96)
expect(switched?.childTexts).toEqual(['Primary'])
expect(switched?.componentId).toBe(ids.secondaryId)
expect(switched?.width).toBe(132)
expect(switched?.childTexts).toEqual(['Secondary'])
canvas.assertNoErrors()
})

View file

@ -0,0 +1,54 @@
import { expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
const entry = { label: 'Edit', forward: () => undefined, inverse: () => undefined }
test('publishes history changes independently of scene mutations', () => {
const editor = createEditor()
const observed: Array<[boolean, boolean]> = []
const off = editor.onEditorEvent('history:changed', () => {
observed.push([editor.undo.canUndo, editor.undo.canRedo])
})
const sceneVersion = editor.state.sceneVersion
try {
editor.undo.record(entry)
editor.undo.undo()
editor.undo.redo()
editor.undo.clear()
expect(observed).toEqual([
[true, false],
[false, true],
[true, false],
[false, false]
])
expect(editor.state.sceneVersion).toBe(sceneVersion)
} finally {
off()
editor.dispose()
}
})
test('publishes committed batches and coalescing but not pending or rolled-back batches', () => {
const editor = createEditor()
const labels: Array<string | null> = []
const off = editor.onEditorEvent('history:changed', () => labels.push(editor.undo.undoLabel))
try {
editor.undo.beginBatch('Cancelled')
editor.undo.record(entry)
editor.undo.rollbackBatch()
expect(labels).toEqual([])
editor.undo.beginBatch('Batch', 'same-edit')
editor.undo.record(entry)
editor.undo.beginBatch('Nested')
editor.undo.record(entry)
editor.undo.commitBatch()
expect(labels).toEqual([])
editor.undo.commitBatch()
editor.undo.record({ ...entry, label: 'Coalesced', coalesceKey: 'same-edit' })
expect(labels).toEqual(['Batch', 'Coalesced'])
} finally {
off()
editor.dispose()
}
})