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:
1 parent
517705bf55
commit
b57994c754
12 files changed
+1193
-176
No files matched your search
+283
-20
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user