Merge pull request #695 from open-pencil/browser-history-contracts

fix: refresh Undo and Redo command availability
This commit is contained in:
Danila Poyarkov 2026-09-14 00:59:01 +03:00 committed by GitHub
commit 5cc6fa70ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 86 additions and 12 deletions

View file

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

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