1.0.2: persist editor-session UI state with the project

Canvas zoom + pan, crop / mask-preview / stage-preview / centre-
crosshair overlays, and timeline snap / show-locked / displayMode
/ px-per-sec / auto-fit now ride along in the saved project under
a new optional `editor` block. Reopening a project restores the
workbench exactly how the user left it — zoom level, pan offset,
HUD toggles, timeline density — instead of snapping back to
defaults.

Editor state lives on the store as a sibling of `this.project`
rather than inside it, so `JSON.stringify(store.project)` (the
undo/redo snapshot) still only captures content. The new
`projectForSave` getter stitches the two on the way to disk;
`loadProject` reads them back apart on the way in. Saves through
App.vue, the rename flow, and IDB recents all go through
`projectForSave`.

CanvasStage skips its first auto-fit when a non-default
canvas zoom/pan was restored so the user's exact view survives
mount. Frame-based export options pick up the same source of
truth for a consistent starting point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 19:47:44 +02:00
1 parent cd9cf91ef0
commit 647f09b287
9 files changed
+285 -19

No files matched your search

+119
View File
@@ -1733,6 +1733,125 @@ export function registerAllTests(): void {
})
})
suite('editor state — save / load roundtrip', () => {
// The store's editor block carries canvas zoom/pan, overlay
// toggles and timeline display settings. It rides along with the
// project file via `projectForSave` and comes back via
// `loadProject`, so reopening restores the full workbench.
test('projectForSave embeds the current editor state', () => {
const s = makeFreshStore()
s.editor.canvasZoom = 2.5
s.editor.canvasViewX = 40
s.editor.canvasViewY = -80
s.editor.cropObjects = true
s.editor.maskPreview = true
s.editor.centerCrosshair = true
s.editor.timelineSnap = false
s.editor.timelineDisplayMode = 'frames'
const payload = s.projectForSave
expect(payload.editor).toBeTruthy()
expect(payload.editor!.canvasZoom).toBe(2.5)
expect(payload.editor!.canvasViewX).toBe(40)
expect(payload.editor!.canvasViewY).toBe(-80)
expect(payload.editor!.cropObjects).toBe(true)
expect(payload.editor!.maskPreview).toBe(true)
expect(payload.editor!.centerCrosshair).toBe(true)
expect(payload.editor!.timelineSnap).toBe(false)
expect(payload.editor!.timelineDisplayMode).toBe('frames')
})
test('loadProject restores editor state from the payload', () => {
const s = makeFreshStore()
s.editor.canvasZoom = 3
const payload = JSON.parse(JSON.stringify(s.projectForSave))
// Mutate live state so the load is observable.
s.editor.canvasZoom = 1
s.editor.cropObjects = false
// Pre-bake a specific editor block into the payload.
payload.editor = {
canvasZoom: 1.75,
canvasViewX: 12,
canvasViewY: 34,
cropObjects: true,
maskPreview: false,
stagePreview: true,
centerCrosshair: true,
timelineSnap: false,
timelineShowLocked: true,
timelineDisplayMode: 'frames',
timelinePxPerSec: 80,
timelineAutoFit: false,
}
s.loadProject(payload)
expect(s.editor.canvasZoom).toBe(1.75)
expect(s.editor.canvasViewX).toBe(12)
expect(s.editor.canvasViewY).toBe(34)
expect(s.editor.cropObjects).toBe(true)
expect(s.editor.stagePreview).toBe(true)
expect(s.editor.centerCrosshair).toBe(true)
expect(s.editor.timelineSnap).toBe(false)
expect(s.editor.timelineShowLocked).toBe(true)
expect(s.editor.timelineDisplayMode).toBe('frames')
expect(s.editor.timelinePxPerSec).toBe(80)
expect(s.editor.timelineAutoFit).toBe(false)
})
test('loadProject without an editor block falls back to defaults', () => {
const s = makeFreshStore()
// Pretend the user tweaked everything before loading an older
// file that has no editor metadata.
s.editor.canvasZoom = 4
s.editor.cropObjects = true
s.editor.timelineSnap = false
const payload = JSON.parse(JSON.stringify(s.projectForSave))
delete payload.editor
s.loadProject(payload)
// Back to defaults regardless of what was set before the load.
expect(s.editor.canvasZoom).toBe(1)
expect(s.editor.cropObjects).toBe(false)
expect(s.editor.timelineSnap).toBe(true)
})
test('applying a partial editor patch leaves untouched fields at default', () => {
const s = makeFreshStore()
s.applyEditorState({ canvasZoom: 2, cropObjects: true })
expect(s.editor.canvasZoom).toBe(2)
expect(s.editor.cropObjects).toBe(true)
// Not in the patch → should be back to the default value (the
// action rebuilds from scratch so stale flags don't leak).
expect(s.editor.maskPreview).toBe(false)
expect(s.editor.timelineSnap).toBe(true)
})
test('undo/redo snapshots do not include editor state', async () => {
// History only snapshots `store.project`; UI toggles must not
// show up in the undo stack or a ctrl-Z would revert a pan,
// zoom or snap-toggle — explicitly unwanted.
const s = makeFreshStore()
const history = createHistory(s)
history.start()
try {
s.addAsset({ kind: 'rect', name: 'Before', width: 10, height: 10, fill: '#fff' })
history.commitNow()
// Now tweak editor state, then make a content change and
// commit. Undo should revert the asset but leave the zoom
// the user set in between intact.
s.editor.canvasZoom = 3
s.addAsset({ kind: 'rect', name: 'After', width: 10, height: 10, fill: '#000' })
history.commitNow()
history.undo()
// Asset 'After' is gone, asset 'Before' is back — but the
// canvas zoom stays at 3 because editor isn't snapshotted.
expect(s.scene.assets.some((a) => a.name === 'After')).toBe(false)
expect(s.scene.assets.some((a) => a.name === 'Before')).toBe(true)
expect(s.editor.canvasZoom).toBe(3)
} finally {
history.stop()
}
})
})
suite('scene store — project & scenes', () => {
test('merged `scene` getter mirrors the active page\'s arrays by reference', () => {
// Reads through store.scene must share the SAME array references