Merge pull request #695 from open-pencil/browser-history-contracts
fix: refresh Undo and Redo command availability
This commit is contained in:
commit
5cc6fa70ed
|
|
@ -58,6 +58,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