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:
parent
25cc522f12
commit
3ea63ad09f
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ??
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
54
tests/engine/editor/undo/history-events.test.ts
Normal file
54
tests/engine/editor/undo/history-events.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue