0.9.0: scenes — multi-page projects with right-side drawer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-22 07:17:06 +02:00
1 parent 517705bf55
commit b57994c754
12 files changed
+1193 -176

No files matched your search

+283 -20
View File
@@ -1,5 +1,10 @@
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
import { createBlankScene, useSceneStore } from '@/stores/scene'
import {
createBlankProject,
createBlankScene,
projectFromLegacyScene,
useSceneStore,
} from '@/stores/scene'
import {
buildMarkerMap,
evalAction,
@@ -1113,23 +1118,24 @@ export function registerAllTests(): void {
s.upsertKeyframe(l.id, 1, { x: 400, rotation: 30 })
s.setDuration(7)
await tick()
const text = serializeProject(s.scene)
const text = serializeProject(s.project)
const parsed = parseProject(text)
expect(parsed.duration).toBe(7)
expect(parsed.layers).toHaveLength(1)
expect(parsed.assets).toHaveLength(1)
expect(parsed.layers[0].keyframes.find((k) => k.time === 1)?.props.x).toBe(400)
const page = parsed.scenes[0]
expect(page.layers).toHaveLength(1)
expect(page.layers[0].keyframes.find((k) => k.time === 1)?.props.x).toBe(400)
})
test('loadScene replaces store state visibly', async () => {
test('loadProject replaces store state visibly', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'L1', width: 80, height: 60, fill: '#aa6666' })
s.addLayer(a.id)!
await tick()
const dump = serializeProject(s.scene)
const dump = serializeProject(s.project)
s.$reset()
await tick()
s.loadScene(parseProject(dump))
s.loadProject(parseProject(dump))
await tick()
expect(s.scene.layers).toHaveLength(1)
expect(s.scene.assets).toHaveLength(1)
@@ -1175,11 +1181,11 @@ export function registerAllTests(): void {
// files on disk.
const s = makeFreshStore()
s.addAsset({ kind: 'rect', name: 'Legacy', width: 10, height: 10, fill: '#123' })
const legacyPayload = JSON.parse(serializeProject(s.scene))
const legacyPayload = JSON.parse(serializeProject(s.project))
expect(legacyPayload.magic).toBe('animatr-project')
// And a fully-formed legacy file parses without error.
const scene = parseProject(JSON.stringify(legacyPayload))
expect(scene.assets.some((a) => a.name === 'Legacy')).toBe(true)
const parsed = parseProject(JSON.stringify(legacyPayload))
expect(parsed.assets.some((a) => a.name === 'Legacy')).toBe(true)
})
test('parseProject error mentions RUWIO, not Animatr', () => {
@@ -1195,6 +1201,202 @@ export function registerAllTests(): void {
expect(msg).toContain('RUWIO')
expect(msg.toLowerCase().includes('animatr')).toBe(false)
})
test('parseProject wraps legacy { scene } payload into Project', () => {
// Pre-v0.9 files had `{ magic, fileVersion: 1, scene }`. The new
// parser lifts those into a one-page Project so users don't have
// to re-save. Canvas + assets + layers must all land intact.
const s = makeFreshStore()
s.addAsset({ kind: 'rect', name: 'LegacyRect', width: 10, height: 10, fill: '#123' })
const legacy = {
magic: 'animatr-project',
fileVersion: 1,
scene: JSON.parse(JSON.stringify(s.scene)),
}
const parsed = parseProject(JSON.stringify(legacy))
expect(Array.isArray(parsed.scenes)).toBe(true)
expect(parsed.scenes).toHaveLength(1)
expect(parsed.activeSceneId).toBe(parsed.scenes[0].id)
expect(parsed.assets.some((a) => a.name === 'LegacyRect')).toBe(true)
expect(parsed.canvas.width).toBe(s.project.canvas.width)
})
})
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
// as the active ScenePage so `store.scene.layers.push(x)` hits
// `activeScene.layers`. If they drift, mutations silently vanish.
const s = makeFreshStore()
expect(s.scene.layers).toBe(s.activeScene.layers)
expect(s.scene.clipInstances).toBe(s.activeScene.clipInstances)
expect(s.scene.trackOrder).toBe(s.activeScene.trackOrder)
// Shared fields (asset library, canvas) come from the project.
expect(s.scene.assets).toBe(s.project.assets)
expect(s.scene.canvas).toBe(s.project.canvas)
expect(s.scene.clips).toBe(s.project.clips)
})
test('createBlankProject seeds exactly one page named "Scene 1"', () => {
const p = createBlankProject()
expect(p.scenes).toHaveLength(1)
expect(p.scenes[0].name).toBe('Scene 1')
expect(p.activeSceneId).toBe(p.scenes[0].id)
})
test('addScene appends, activates, and picks a sensible default name', () => {
const s = makeFreshStore()
expect(s.project.scenes).toHaveLength(1)
const two = s.addScene()
expect(s.project.scenes).toHaveLength(2)
expect(two.name).toBe('Scene 2')
expect(s.project.activeSceneId).toBe(two.id)
const three = s.addScene()
expect(three.name).toBe('Scene 3')
})
test('addScene respects an explicit trimmed name', () => {
const s = makeFreshStore()
const intro = s.addScene(' Intro ')
expect(intro.name).toBe('Intro')
})
test('switching scenes swaps layer stacks without touching the shared library', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Shared', width: 10, height: 10, fill: '#abc' })
const l1 = s.addLayer(a.id, 'in-scene-1')!
const two = s.addScene('Two')
// After switch: active page has no layers; the asset library stays.
expect(s.scene.layers).toHaveLength(0)
expect(s.scene.assets.some((x) => x.id === a.id)).toBe(true)
const l2 = s.addLayer(a.id, 'in-scene-2')!
// Flip back to scene 1 and confirm its layer is intact.
s.switchScene(s.project.scenes[0].id)
expect(s.scene.layers).toHaveLength(1)
expect(s.scene.layers[0].id).toBe(l1.id)
// And scene 2 still owns its own layer.
s.switchScene(two.id)
expect(s.scene.layers).toHaveLength(1)
expect(s.scene.layers[0].id).toBe(l2.id)
})
test('switchScene clears editor context (selection, clip-edit, playback, drawing)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'SW', width: 10, height: 10, fill: '#111' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
s.play()
const clip = s.addClip('C')
s.activeClipId = clip.id
const two = s.addScene()
// addScene already routed through switchScene — cross-check.
expect(s.selectedLayerId).toBeNull()
expect(s.selectedLayerIds).toHaveLength(0)
expect(s.playing).toBe(false)
expect(s.activeClipId).toBeNull()
expect(s.activeButtonAssetId).toBeNull()
expect(s.project.activeSceneId).toBe(two.id)
})
test('switchScene returns false for an unknown id and leaves state alone', () => {
const s = makeFreshStore()
const before = s.project.activeSceneId
const ok = s.switchScene('nope-no-such-scene')
expect(ok).toBe(false)
expect(s.project.activeSceneId).toBe(before)
})
test('renameScene trims and rejects empty names', () => {
const s = makeFreshStore()
const id = s.project.activeSceneId
expect(s.renameScene(id, ' Hello ')).toBe(true)
expect(s.project.scenes[0].name).toBe('Hello')
expect(s.renameScene(id, ' ')).toBe(false)
expect(s.project.scenes[0].name).toBe('Hello')
})
test('removeScene refuses to delete the last remaining page', () => {
const s = makeFreshStore()
const only = s.project.activeSceneId
expect(s.removeScene(only)).toBe(false)
expect(s.project.scenes).toHaveLength(1)
})
test('removeScene on the last page activates its predecessor', () => {
// [A, B, C] active=C → remove C → [A, B] active=B.
// The "neighbour" policy slides the active marker back one slot
// when there's nothing further down the list.
const s = makeFreshStore()
const a = s.project.scenes[0].id
const b = s.addScene('B').id
const c = s.addScene('C').id
expect(s.project.activeSceneId).toBe(c)
expect(s.removeScene(c)).toBe(true)
expect(s.project.activeSceneId).toBe(b)
expect(s.project.scenes.map((p) => p.id)).toEqual([a, b])
})
test('removeScene on a middle page activates the next one', () => {
// [A, B, C] active=B → remove B → [A, C] active=C.
// Forward is the default direction; previous is only used when
// there is no next.
const s = makeFreshStore()
const a = s.project.scenes[0].id
const b = s.addScene('B').id
const c = s.addScene('C').id
s.switchScene(b)
expect(s.project.activeSceneId).toBe(b)
expect(s.removeScene(b)).toBe(true)
expect(s.project.activeSceneId).toBe(c)
expect(s.project.scenes.map((p) => p.id)).toEqual([a, c])
})
test('removeScene on an inactive page leaves the active one untouched', () => {
const s = makeFreshStore()
const a = s.project.scenes[0].id
const b = s.addScene('B').id
const c = s.addScene('C').id
expect(s.project.activeSceneId).toBe(c)
expect(s.removeScene(b)).toBe(true)
expect(s.project.activeSceneId).toBe(c)
expect(s.project.scenes.map((p) => p.id)).toEqual([a, c])
})
test('duplicateScene deep-clones with fresh ids and activates the copy', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'D', width: 10, height: 10, fill: '#abc' })
const l = s.addLayer(a.id, 'Orig')!
s.upsertKeyframe(l.id, 1, { x: 20 })
const origId = s.project.activeSceneId
const origLayerId = l.id
const origKfIds = new Set(l.keyframes.map((k) => k.id))
const copy = s.duplicateScene(origId)!
expect(copy.id === origId).toBe(false)
expect(copy.name).toBe(`${s.project.scenes[0].name} copy`)
expect(s.project.activeSceneId).toBe(copy.id)
// Clone sits directly after the original.
const idx = s.project.scenes.findIndex((p) => p.id === origId)
expect(s.project.scenes[idx + 1].id).toBe(copy.id)
// Fresh layer + keyframe ids.
const clonedLayer = copy.layers[0]
expect(clonedLayer.id === origLayerId).toBe(false)
for (const k of clonedLayer.keyframes) expect(origKfIds.has(k.id)).toBe(false)
// Deep-cloned — mutating the clone leaves original untouched.
clonedLayer.base.x = 999
const origPage = s.project.scenes.find((p) => p.id === origId)!
expect(origPage.layers[0].base.x === 999).toBe(false)
})
test('projectFromLegacyScene wraps a single Scene into a one-page Project', () => {
const s = makeFreshStore()
s.addAsset({ kind: 'rect', name: 'LG', width: 10, height: 10, fill: '#abc' })
const project = projectFromLegacyScene(JSON.parse(JSON.stringify(s.scene)), 'My Page')
expect(project.scenes).toHaveLength(1)
expect(project.scenes[0].name).toBe('My Page')
expect(project.activeSceneId).toBe(project.scenes[0].id)
expect(project.assets.some((a) => a.name === 'LG')).toBe(true)
})
})
suite('history — undo/redo', () => {
@@ -1291,6 +1493,67 @@ export function registerAllTests(): void {
h.stop()
})
test('undo after switchScene restores the previous activeSceneId', async () => {
// History snapshots the whole project including activeSceneId, so
// a scene switch is itself an undoable step. If this regresses the
// user will notice a sudden loss of "undo back to where I was".
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
const a = s.project.scenes[0].id
const b = s.addScene('B').id
await tick(40)
// Now switch back to A; that's a fresh snapshottable step.
s.switchScene(a)
await tick(40)
expect(s.project.activeSceneId).toBe(a)
h.undo()
await tick(20)
expect(s.project.activeSceneId).toBe(b)
h.stop()
})
test('undo after addScene removes the just-added page', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
expect(s.project.scenes).toHaveLength(1)
const origId = s.project.activeSceneId
s.addScene('B')
await tick(40)
expect(s.project.scenes).toHaveLength(2)
h.undo()
await tick(20)
expect(s.project.scenes).toHaveLength(1)
expect(s.project.activeSceneId).toBe(origId)
h.stop()
})
test('undo after removeScene restores the deleted page content', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'U', width: 10, height: 10, fill: '#abc' })
const two = s.addScene('Two')
s.addLayer(a.id, 'only-in-two')
await tick(40)
expect(s.scene.layers).toHaveLength(1)
const twoId = two.id
// Flip away, then delete. Both are undoable steps.
s.switchScene(s.project.scenes[0].id)
await tick(40)
s.removeScene(twoId)
await tick(40)
expect(s.project.scenes.some((p) => p.id === twoId)).toBe(false)
h.undo()
await tick(20)
const restored = s.project.scenes.find((p) => p.id === twoId)
expect(restored).toBeTruthy()
expect(restored!.layers).toHaveLength(1)
expect(restored!.layers[0].name).toBe('only-in-two')
h.stop()
})
test('max history cap drops oldest entries', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10, max: 3 })
@@ -1533,13 +1796,13 @@ export function registerAllTests(): void {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'RS', width: 42, height: 42, fill: '#123456' })
s.addLayer(a.id)!
const rec = await saveProjectToStore(s.scene, { name: 'test-save-get' })
const rec = await saveProjectToStore(s.project, { name: 'test-save-get' })
try {
const back = await getStoredProject(rec.id)
expect(back).toBeTruthy()
expect(back!.name).toBe('test-save-get')
expect(back!.scene.assets).toHaveLength(1)
expect(back!.scene.layers).toHaveLength(1)
expect(back!.project!.assets).toHaveLength(1)
expect(back!.project!.scenes[0].layers).toHaveLength(1)
} finally {
await deleteStoredProject(rec.id)
}
@@ -1549,9 +1812,9 @@ export function registerAllTests(): void {
const s = makeFreshStore()
const olderId = `test-older-${Date.now()}`
const newerId = `test-newer-${Date.now()}`
const older = await saveProjectToStore(s.scene, { id: olderId, name: 'older' })
const older = await saveProjectToStore(s.project, { id: olderId, name: 'older' })
await tick(5)
const newer = await saveProjectToStore(s.scene, { id: newerId, name: 'newer' })
const newer = await saveProjectToStore(s.project, { id: newerId, name: 'newer' })
try {
const list = await listRecentProjects()
const newerIdx = list.findIndex((p) => p.id === newer.id)
@@ -1568,9 +1831,9 @@ export function registerAllTests(): void {
test('save with same id updates existing record instead of adding', async () => {
const s = makeFreshStore()
const id = `test-update-${Date.now()}`
await saveProjectToStore(s.scene, { id, name: 'v1' })
await saveProjectToStore(s.project, { id, name: 'v1' })
const before = (await listRecentProjects()).filter((p) => p.id === id).length
await saveProjectToStore(s.scene, { id, name: 'v2' })
await saveProjectToStore(s.project, { id, name: 'v2' })
try {
const list = await listRecentProjects()
const matches = list.filter((p) => p.id === id)
@@ -1584,7 +1847,7 @@ export function registerAllTests(): void {
test('deleteStoredProject removes entry', async () => {
const s = makeFreshStore()
const rec = await saveProjectToStore(s.scene, { name: 'to-delete' })
const rec = await saveProjectToStore(s.project, { name: 'to-delete' })
await deleteStoredProject(rec.id)
const back = await getStoredProject(rec.id)
expect(back).toBeNull()
@@ -1853,7 +2116,7 @@ export function registerAllTests(): void {
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
s.scene.duration = 5
s.setDuration(5)
const rect = s.addAsset({
kind: 'rect',
@@ -1918,7 +2181,7 @@ export function registerAllTests(): void {
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
s.scene.duration = 5
s.setDuration(5)
const rect = s.addAsset({
kind: 'rect',