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
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "animatr",
|
"name": "animatr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.7",
|
"version": "0.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+23
-18
@@ -21,6 +21,7 @@ import AboutDialog from './components/AboutDialog.vue'
|
|||||||
import AssetSidebar from './components/AssetSidebar.vue'
|
import AssetSidebar from './components/AssetSidebar.vue'
|
||||||
import CanvasStage from './components/CanvasStage.vue'
|
import CanvasStage from './components/CanvasStage.vue'
|
||||||
import ClipartDrawer from './components/ClipartDrawer.vue'
|
import ClipartDrawer from './components/ClipartDrawer.vue'
|
||||||
|
import SceneDrawer from './components/SceneDrawer.vue'
|
||||||
import DocumentSettings from './components/DocumentSettings.vue'
|
import DocumentSettings from './components/DocumentSettings.vue'
|
||||||
import ExportDialog from './components/ExportDialog.vue'
|
import ExportDialog from './components/ExportDialog.vue'
|
||||||
import FullscreenPreview from './components/FullscreenPreview.vue'
|
import FullscreenPreview from './components/FullscreenPreview.vue'
|
||||||
@@ -49,7 +50,7 @@ import {
|
|||||||
} from './lib/projectStore'
|
} from './lib/projectStore'
|
||||||
import { importImageFile } from './lib/fileLoader'
|
import { importImageFile } from './lib/fileLoader'
|
||||||
import { useClipartStore } from './stores/clipart'
|
import { useClipartStore } from './stores/clipart'
|
||||||
import { createBlankScene, useSceneStore } from './stores/scene'
|
import { createBlankProject, useSceneStore } from './stores/scene'
|
||||||
|
|
||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
const exportOpen = ref(false)
|
const exportOpen = ref(false)
|
||||||
@@ -165,7 +166,7 @@ function stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveProject() {
|
async function saveProject() {
|
||||||
const scene = JSON.parse(JSON.stringify(store.scene))
|
const project = JSON.parse(JSON.stringify(store.project))
|
||||||
let name = currentProjectName.value
|
let name = currentProjectName.value
|
||||||
if (!name) {
|
if (!name) {
|
||||||
const suggested = t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() })
|
const suggested = t('app.saveDialog.defaultName', { date: new Date().toLocaleDateString() })
|
||||||
@@ -178,7 +179,7 @@ async function saveProject() {
|
|||||||
if (entered === null) return
|
if (entered === null) return
|
||||||
name = entered.trim() || suggested
|
name = entered.trim() || suggested
|
||||||
}
|
}
|
||||||
const rec = await saveProjectToStore(scene, {
|
const rec = await saveProjectToStore(project, {
|
||||||
id: currentProjectId.value ?? undefined,
|
id: currentProjectId.value ?? undefined,
|
||||||
name,
|
name,
|
||||||
})
|
})
|
||||||
@@ -208,8 +209,8 @@ async function renameProject() {
|
|||||||
// it. Unsaved projects just get the in-memory name — the next save
|
// it. Unsaved projects just get the in-memory name — the next save
|
||||||
// picks it up.
|
// picks it up.
|
||||||
if (currentProjectId.value) {
|
if (currentProjectId.value) {
|
||||||
const scene = JSON.parse(JSON.stringify(store.scene))
|
const project = JSON.parse(JSON.stringify(store.project))
|
||||||
const rec = await saveProjectToStore(scene, {
|
const rec = await saveProjectToStore(project, {
|
||||||
id: currentProjectId.value,
|
id: currentProjectId.value,
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
})
|
})
|
||||||
@@ -218,10 +219,10 @@ async function renameProject() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function exportProjectFile() {
|
function exportProjectFile() {
|
||||||
const scene = JSON.parse(JSON.stringify(store.scene))
|
const project = JSON.parse(JSON.stringify(store.project))
|
||||||
const base = currentProjectName.value ?? 'ruwio'
|
const base = currentProjectName.value ?? 'ruwio'
|
||||||
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'ruwio'
|
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'ruwio'
|
||||||
downloadProject(scene, `${safe}.json`)
|
downloadProject(project, `${safe}.json`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importFromFile() {
|
async function importFromFile() {
|
||||||
@@ -238,9 +239,9 @@ async function importFromFile() {
|
|||||||
if (!ok) return
|
if (!ok) return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const scene = await readProjectFile(file)
|
const project = await readProjectFile(file)
|
||||||
store.loadScene(scene)
|
store.loadProject(project)
|
||||||
history.resetTo(scene)
|
history.resetTo(project)
|
||||||
currentProjectId.value = null
|
currentProjectId.value = null
|
||||||
currentProjectName.value = file.name.replace(/\.[^.]+$/, '')
|
currentProjectName.value = file.name.replace(/\.[^.]+$/, '')
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
@@ -255,20 +256,23 @@ async function importFromFile() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openFromRecent(project: StoredProject) {
|
async function openFromRecent(stored: StoredProject) {
|
||||||
const cloned = JSON.parse(JSON.stringify(project.scene))
|
// `stored.project` is guaranteed by projectStore's migration (legacy
|
||||||
store.loadScene(cloned)
|
// `scene`-only records get lifted to Project shape on read).
|
||||||
|
if (!stored.project) return
|
||||||
|
const cloned = JSON.parse(JSON.stringify(stored.project))
|
||||||
|
store.loadProject(cloned)
|
||||||
history.resetTo(cloned)
|
history.resetTo(cloned)
|
||||||
currentProjectId.value = project.id
|
currentProjectId.value = stored.id
|
||||||
currentProjectName.value = project.name
|
currentProjectName.value = stored.name
|
||||||
setLastOpenedId(project.id)
|
setLastOpenedId(stored.id)
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
await markClean()
|
await markClean()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startNewProject() {
|
async function startNewProject() {
|
||||||
const blank = createBlankScene()
|
const blank = createBlankProject()
|
||||||
store.loadScene(blank)
|
store.loadProject(blank)
|
||||||
history.resetTo(blank)
|
history.resetTo(blank)
|
||||||
currentProjectId.value = null
|
currentProjectId.value = null
|
||||||
currentProjectName.value = null
|
currentProjectName.value = null
|
||||||
@@ -563,6 +567,7 @@ onBeforeUnmount(() => {
|
|||||||
</Toast>
|
</Toast>
|
||||||
<Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" />
|
<Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" />
|
||||||
</div>
|
</div>
|
||||||
|
<SceneDrawer />
|
||||||
<TooltipHost />
|
<TooltipHost />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -188,8 +188,7 @@ async function deleteButtonAsset(id: string) {
|
|||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
})
|
})
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
// Remove the asset; any layers referencing it will render as nothing.
|
store.removeAsset(id)
|
||||||
store.scene.assets = store.scene.assets.filter((a) => a.id !== id)
|
|
||||||
if (store.activeButtonAssetId === id) store.exitButtonAssetEdit()
|
if (store.activeButtonAssetId === id) store.exitButtonAssetEdit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref, watch } from 'vue'
|
||||||
|
import { PhCaretLeft, PhCaretRight, PhCopy, PhFilmSlate, PhPlus, PhTrash } from '@phosphor-icons/vue'
|
||||||
|
import { useSceneStore } from '@/stores/scene'
|
||||||
|
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
const store = useSceneStore()
|
||||||
|
const open = ref(false)
|
||||||
|
|
||||||
|
interface MenuState {
|
||||||
|
pageId: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
const menu = ref<MenuState | null>(null)
|
||||||
|
const menuRef = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open.value = !open.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addScene() {
|
||||||
|
const page = store.addScene()
|
||||||
|
// Let the new row mount before renaming so a canceled rename still
|
||||||
|
// leaves the user on the freshly-created (and now active) page.
|
||||||
|
await nextTick()
|
||||||
|
renameScene(page.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameScene(id: string) {
|
||||||
|
const page = store.project.scenes.find((s) => s.id === id)
|
||||||
|
if (!page) return
|
||||||
|
const next = await promptDialog({
|
||||||
|
title: t('scenes.renameDialog.title'),
|
||||||
|
defaultValue: page.name,
|
||||||
|
confirmLabel: t('scenes.renameDialog.confirm'),
|
||||||
|
})
|
||||||
|
if (next === null) return
|
||||||
|
store.renameScene(id, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateScene(id: string) {
|
||||||
|
store.duplicateScene(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteScene(id: string) {
|
||||||
|
const page = store.project.scenes.find((s) => s.id === id)
|
||||||
|
if (!page) return
|
||||||
|
if (store.project.scenes.length <= 1) return
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t('scenes.deleteDialog.title'),
|
||||||
|
message: t('scenes.deleteDialog.message', { name: page.name }),
|
||||||
|
confirmLabel: t('scenes.deleteDialog.confirm'),
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
store.removeScene(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowClick(id: string) {
|
||||||
|
if (store.project.activeSceneId === id) return
|
||||||
|
store.switchScene(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowContextMenu(e: MouseEvent, id: string) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
menu.value = { pageId: id, x: e.clientX, y: e.clientY }
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
menu.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocMouseDown(e: MouseEvent) {
|
||||||
|
if (!menu.value) return
|
||||||
|
if (menuRef.value && e.target instanceof Node && menuRef.value.contains(e.target)) return
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(menu, (m) => {
|
||||||
|
if (m) document.addEventListener('mousedown', onDocMouseDown)
|
||||||
|
else document.removeEventListener('mousedown', onDocMouseDown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="scene-drawer" :class="{ open }">
|
||||||
|
<button
|
||||||
|
class="drawer-tab"
|
||||||
|
v-tooltip="open ? t('scenes.close') : t('scenes.open')"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<component :is="open ? PhCaretRight : PhCaretLeft" :size="14" />
|
||||||
|
</button>
|
||||||
|
<div class="drawer-panel">
|
||||||
|
<header class="drawer-header">
|
||||||
|
<PhFilmSlate :size="14" class="title-icon" />
|
||||||
|
<h3>{{ t('scenes.title') }}</h3>
|
||||||
|
<button class="add-btn" v-tooltip="t('scenes.add')" @click="addScene">
|
||||||
|
<PhPlus :size="13" weight="bold" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="scene-list">
|
||||||
|
<button
|
||||||
|
v-for="page in store.project.scenes"
|
||||||
|
:key="page.id"
|
||||||
|
class="scene-row"
|
||||||
|
:class="{ active: page.id === store.project.activeSceneId }"
|
||||||
|
@click="onRowClick(page.id)"
|
||||||
|
@dblclick="renameScene(page.id)"
|
||||||
|
@contextmenu="onRowContextMenu($event, page.id)"
|
||||||
|
>
|
||||||
|
<span class="scene-name">{{ page.name }}</span>
|
||||||
|
<span class="scene-meta">{{ page.layers.length }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="menu"
|
||||||
|
ref="menuRef"
|
||||||
|
class="scene-menu"
|
||||||
|
:style="{ left: menu.x + 'px', top: menu.y + 'px' }"
|
||||||
|
>
|
||||||
|
<button class="menu-item" @click="renameScene(menu.pageId); closeMenu()">
|
||||||
|
{{ t('scenes.rename') }}
|
||||||
|
</button>
|
||||||
|
<button class="menu-item" @click="duplicateScene(menu.pageId); closeMenu()">
|
||||||
|
<PhCopy :size="12" class="menu-icon" />
|
||||||
|
{{ t('scenes.duplicate') }}
|
||||||
|
</button>
|
||||||
|
<div class="menu-divider"></div>
|
||||||
|
<button
|
||||||
|
class="menu-item danger"
|
||||||
|
:disabled="store.project.scenes.length <= 1"
|
||||||
|
:title="store.project.scenes.length <= 1 ? t('scenes.locked') : undefined"
|
||||||
|
@click="deleteScene(menu.pageId); closeMenu()"
|
||||||
|
>
|
||||||
|
<PhTrash :size="12" class="menu-icon" />
|
||||||
|
{{ t('scenes.delete') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scene-drawer {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 5px;
|
||||||
|
transition: width 220ms var(--ease);
|
||||||
|
background: var(--border);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Mirror of ClipartDrawer: tab on the screen-edge side, panel
|
||||||
|
alongside it. Here the edge is the right-hand side, so the tab
|
||||||
|
flex order is reversed (panel first, tab second in DOM). */
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-drawer:hover {
|
||||||
|
width: 24px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-drawer.open {
|
||||||
|
width: 324px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-tab {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: background 140ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-tab :deep(svg) {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 140ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-drawer:hover .drawer-tab :deep(svg),
|
||||||
|
.scene-drawer.open .drawer-tab :deep(svg) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-drawer:hover .drawer-tab {
|
||||||
|
background: var(--panel-alt);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-drawer.open .drawer-tab {
|
||||||
|
flex: 0 0 24px;
|
||||||
|
width: 24px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-panel {
|
||||||
|
/* Rigid 300px so the list layout doesn't reflow mid-animation. */
|
||||||
|
flex: 0 0 300px;
|
||||||
|
width: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-icon {
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header h3 {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel-alt);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--panel-alt);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-row:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-row.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-meta {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-row.active .scene-meta {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2000;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 4px;
|
||||||
|
min-width: 160px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover:not(:disabled) {
|
||||||
|
background: var(--panel-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item.danger {
|
||||||
|
color: var(--danger, #c55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -69,7 +69,10 @@ function formatDate(ts: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function layerCount(p: StoredProject): number {
|
function layerCount(p: StoredProject): number {
|
||||||
return p.scene?.layers?.length ?? 0
|
// Sum across all pages. Legacy `scene`-only records are migrated to
|
||||||
|
// `project` on read, so we only need to look at `project.scenes`.
|
||||||
|
if (!p.project) return 0
|
||||||
|
return p.project.scenes.reduce((n, s) => n + s.layers.length, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openProject(p: StoredProject) {
|
function openProject(p: StoredProject) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type ComputedRef } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch, type ComputedRef } from 'vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
import { projectFromLegacyScene, useSceneStore } from '@/stores/scene'
|
||||||
import type { Scene } from '@/types/scene'
|
import type { Project, Scene } from '@/types/scene'
|
||||||
|
|
||||||
export const HISTORY_MAX = 100
|
export const HISTORY_MAX = 100
|
||||||
export const HISTORY_DEBOUNCE_MS = 400
|
export const HISTORY_DEBOUNCE_MS = 400
|
||||||
@@ -12,7 +12,9 @@ export interface HistoryApi {
|
|||||||
redo: () => void
|
redo: () => void
|
||||||
canUndo: ComputedRef<boolean>
|
canUndo: ComputedRef<boolean>
|
||||||
canRedo: ComputedRef<boolean>
|
canRedo: ComputedRef<boolean>
|
||||||
resetTo: (scene?: Scene) => void
|
/** Re-seed the stack from a fresh state. Accepts either a Project
|
||||||
|
* (new format) or a legacy Scene (auto-wrapped) for caller convenience. */
|
||||||
|
resetTo: (state?: Project | Scene) => void
|
||||||
commitNow: () => void
|
commitNow: () => void
|
||||||
start: () => void
|
start: () => void
|
||||||
stop: () => void
|
stop: () => void
|
||||||
@@ -33,7 +35,7 @@ export function createHistory(store: SceneStore, opts: CreateHistoryOptions = {}
|
|||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let unsub: (() => void) | null = null
|
let unsub: (() => void) | null = null
|
||||||
|
|
||||||
const snap = () => JSON.stringify(store.scene)
|
const snap = () => JSON.stringify(store.project)
|
||||||
|
|
||||||
function pushNow() {
|
function pushNow() {
|
||||||
if (suspend) return
|
if (suspend) return
|
||||||
@@ -60,7 +62,11 @@ export function createHistory(store: SceneStore, opts: CreateHistoryOptions = {}
|
|||||||
|
|
||||||
function apply(json: string) {
|
function apply(json: string) {
|
||||||
suspend = true
|
suspend = true
|
||||||
store.scene = JSON.parse(json)
|
// Snapshot replacement preserves transient editor state (playhead,
|
||||||
|
// selection, activeClipId, …). setPlayhead immediately re-clamps it
|
||||||
|
// into the swapped project's range — so undoing back to a shorter
|
||||||
|
// duration brings the playhead inside the new bounds.
|
||||||
|
store.applyProjectSnapshot(JSON.parse(json) as Project)
|
||||||
store.setPlayhead(store.playhead)
|
store.setPlayhead(store.playhead)
|
||||||
if (store.selectedLayerId && !store.scene.layers.some((l) => l.id === store.selectedLayerId)) {
|
if (store.selectedLayerId && !store.scene.layers.some((l) => l.id === store.selectedLayerId)) {
|
||||||
store.selectLayer(null)
|
store.selectLayer(null)
|
||||||
@@ -91,12 +97,21 @@ export function createHistory(store: SceneStore, opts: CreateHistoryOptions = {}
|
|||||||
apply(stack.value[index.value])
|
apply(stack.value[index.value])
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetTo(scene?: Scene) {
|
function resetTo(input?: Project | Scene) {
|
||||||
if (debounceTimer) {
|
if (debounceTimer) {
|
||||||
clearTimeout(debounceTimer)
|
clearTimeout(debounceTimer)
|
||||||
debounceTimer = null
|
debounceTimer = null
|
||||||
}
|
}
|
||||||
const json = scene ? JSON.stringify(scene) : snap()
|
let json: string
|
||||||
|
if (!input) {
|
||||||
|
json = snap()
|
||||||
|
} else if ('scenes' in input && Array.isArray((input as Project).scenes)) {
|
||||||
|
json = JSON.stringify(input)
|
||||||
|
} else {
|
||||||
|
// Legacy Scene shape — wrap into a one-page Project so the snapshot
|
||||||
|
// format stays homogeneous across the stack.
|
||||||
|
json = JSON.stringify(projectFromLegacyScene(input as Scene))
|
||||||
|
}
|
||||||
stack.value = [json]
|
stack.value = [json]
|
||||||
index.value = 0
|
index.value = 0
|
||||||
}
|
}
|
||||||
@@ -108,7 +123,7 @@ export function createHistory(store: SceneStore, opts: CreateHistoryOptions = {}
|
|||||||
stack.value = [snap()]
|
stack.value = [snap()]
|
||||||
index.value = 0
|
index.value = 0
|
||||||
unsub = watch(
|
unsub = watch(
|
||||||
() => store.scene,
|
() => store.project,
|
||||||
() => schedulePush(),
|
() => schedulePush(),
|
||||||
{ deep: true, flush: 'post' },
|
{ deep: true, flush: 'post' },
|
||||||
)
|
)
|
||||||
|
|||||||
+283
-20
@@ -1,5 +1,10 @@
|
|||||||
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
|
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 {
|
import {
|
||||||
buildMarkerMap,
|
buildMarkerMap,
|
||||||
evalAction,
|
evalAction,
|
||||||
@@ -1113,23 +1118,24 @@ export function registerAllTests(): void {
|
|||||||
s.upsertKeyframe(l.id, 1, { x: 400, rotation: 30 })
|
s.upsertKeyframe(l.id, 1, { x: 400, rotation: 30 })
|
||||||
s.setDuration(7)
|
s.setDuration(7)
|
||||||
await tick()
|
await tick()
|
||||||
const text = serializeProject(s.scene)
|
const text = serializeProject(s.project)
|
||||||
const parsed = parseProject(text)
|
const parsed = parseProject(text)
|
||||||
expect(parsed.duration).toBe(7)
|
expect(parsed.duration).toBe(7)
|
||||||
expect(parsed.layers).toHaveLength(1)
|
|
||||||
expect(parsed.assets).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 s = makeFreshStore()
|
||||||
const a = s.addAsset({ kind: 'rect', name: 'L1', width: 80, height: 60, fill: '#aa6666' })
|
const a = s.addAsset({ kind: 'rect', name: 'L1', width: 80, height: 60, fill: '#aa6666' })
|
||||||
s.addLayer(a.id)!
|
s.addLayer(a.id)!
|
||||||
await tick()
|
await tick()
|
||||||
const dump = serializeProject(s.scene)
|
const dump = serializeProject(s.project)
|
||||||
s.$reset()
|
s.$reset()
|
||||||
await tick()
|
await tick()
|
||||||
s.loadScene(parseProject(dump))
|
s.loadProject(parseProject(dump))
|
||||||
await tick()
|
await tick()
|
||||||
expect(s.scene.layers).toHaveLength(1)
|
expect(s.scene.layers).toHaveLength(1)
|
||||||
expect(s.scene.assets).toHaveLength(1)
|
expect(s.scene.assets).toHaveLength(1)
|
||||||
@@ -1175,11 +1181,11 @@ export function registerAllTests(): void {
|
|||||||
// files on disk.
|
// files on disk.
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
s.addAsset({ kind: 'rect', name: 'Legacy', width: 10, height: 10, fill: '#123' })
|
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')
|
expect(legacyPayload.magic).toBe('animatr-project')
|
||||||
// And a fully-formed legacy file parses without error.
|
// And a fully-formed legacy file parses without error.
|
||||||
const scene = parseProject(JSON.stringify(legacyPayload))
|
const parsed = parseProject(JSON.stringify(legacyPayload))
|
||||||
expect(scene.assets.some((a) => a.name === 'Legacy')).toBe(true)
|
expect(parsed.assets.some((a) => a.name === 'Legacy')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('parseProject error mentions RUWIO, not Animatr', () => {
|
test('parseProject error mentions RUWIO, not Animatr', () => {
|
||||||
@@ -1195,6 +1201,202 @@ export function registerAllTests(): void {
|
|||||||
expect(msg).toContain('RUWIO')
|
expect(msg).toContain('RUWIO')
|
||||||
expect(msg.toLowerCase().includes('animatr')).toBe(false)
|
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', () => {
|
suite('history — undo/redo', () => {
|
||||||
@@ -1291,6 +1493,67 @@ export function registerAllTests(): void {
|
|||||||
h.stop()
|
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 () => {
|
test('max history cap drops oldest entries', async () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
const h = createHistory(s, { debounceMs: 10, max: 3 })
|
const h = createHistory(s, { debounceMs: 10, max: 3 })
|
||||||
@@ -1533,13 +1796,13 @@ export function registerAllTests(): void {
|
|||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
const a = s.addAsset({ kind: 'rect', name: 'RS', width: 42, height: 42, fill: '#123456' })
|
const a = s.addAsset({ kind: 'rect', name: 'RS', width: 42, height: 42, fill: '#123456' })
|
||||||
s.addLayer(a.id)!
|
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 {
|
try {
|
||||||
const back = await getStoredProject(rec.id)
|
const back = await getStoredProject(rec.id)
|
||||||
expect(back).toBeTruthy()
|
expect(back).toBeTruthy()
|
||||||
expect(back!.name).toBe('test-save-get')
|
expect(back!.name).toBe('test-save-get')
|
||||||
expect(back!.scene.assets).toHaveLength(1)
|
expect(back!.project!.assets).toHaveLength(1)
|
||||||
expect(back!.scene.layers).toHaveLength(1)
|
expect(back!.project!.scenes[0].layers).toHaveLength(1)
|
||||||
} finally {
|
} finally {
|
||||||
await deleteStoredProject(rec.id)
|
await deleteStoredProject(rec.id)
|
||||||
}
|
}
|
||||||
@@ -1549,9 +1812,9 @@ export function registerAllTests(): void {
|
|||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
const olderId = `test-older-${Date.now()}`
|
const olderId = `test-older-${Date.now()}`
|
||||||
const newerId = `test-newer-${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)
|
await tick(5)
|
||||||
const newer = await saveProjectToStore(s.scene, { id: newerId, name: 'newer' })
|
const newer = await saveProjectToStore(s.project, { id: newerId, name: 'newer' })
|
||||||
try {
|
try {
|
||||||
const list = await listRecentProjects()
|
const list = await listRecentProjects()
|
||||||
const newerIdx = list.findIndex((p) => p.id === newer.id)
|
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 () => {
|
test('save with same id updates existing record instead of adding', async () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
const id = `test-update-${Date.now()}`
|
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
|
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 {
|
try {
|
||||||
const list = await listRecentProjects()
|
const list = await listRecentProjects()
|
||||||
const matches = list.filter((p) => p.id === id)
|
const matches = list.filter((p) => p.id === id)
|
||||||
@@ -1584,7 +1847,7 @@ export function registerAllTests(): void {
|
|||||||
|
|
||||||
test('deleteStoredProject removes entry', async () => {
|
test('deleteStoredProject removes entry', async () => {
|
||||||
const s = makeFreshStore()
|
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)
|
await deleteStoredProject(rec.id)
|
||||||
const back = await getStoredProject(rec.id)
|
const back = await getStoredProject(rec.id)
|
||||||
expect(back).toBeNull()
|
expect(back).toBeNull()
|
||||||
@@ -1853,7 +2116,7 @@ export function registerAllTests(): void {
|
|||||||
s.scene.canvas.width = 100
|
s.scene.canvas.width = 100
|
||||||
s.scene.canvas.height = 100
|
s.scene.canvas.height = 100
|
||||||
s.scene.canvas.background = 'transparent'
|
s.scene.canvas.background = 'transparent'
|
||||||
s.scene.duration = 5
|
s.setDuration(5)
|
||||||
|
|
||||||
const rect = s.addAsset({
|
const rect = s.addAsset({
|
||||||
kind: 'rect',
|
kind: 'rect',
|
||||||
@@ -1918,7 +2181,7 @@ export function registerAllTests(): void {
|
|||||||
s.scene.canvas.width = 100
|
s.scene.canvas.width = 100
|
||||||
s.scene.canvas.height = 100
|
s.scene.canvas.height = 100
|
||||||
s.scene.canvas.background = 'transparent'
|
s.scene.canvas.background = 'transparent'
|
||||||
s.scene.duration = 5
|
s.setDuration(5)
|
||||||
|
|
||||||
const rect = s.addAsset({
|
const rect = s.addAsset({
|
||||||
kind: 'rect',
|
kind: 'rect',
|
||||||
|
|||||||
@@ -406,6 +406,27 @@ export const en = {
|
|||||||
'Point to a local folder with your SVG/PNG/JPG clipart. The app remembers the folder; permissions may be re-asked after browser restart.',
|
'Point to a local folder with your SVG/PNG/JPG clipart. The app remembers the folder; permissions may be re-asked after browser restart.',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
scenes: {
|
||||||
|
title: 'Scenes',
|
||||||
|
open: 'Open scenes',
|
||||||
|
close: 'Close scenes',
|
||||||
|
add: 'Add scene',
|
||||||
|
rename: 'Rename scene',
|
||||||
|
renameDialog: {
|
||||||
|
title: 'Rename scene',
|
||||||
|
confirm: 'Rename',
|
||||||
|
},
|
||||||
|
duplicate: 'Duplicate',
|
||||||
|
delete: 'Delete',
|
||||||
|
deleteDialog: {
|
||||||
|
title: 'Delete scene?',
|
||||||
|
message: 'Remove "{name}" from this project. This cannot be undone.',
|
||||||
|
confirm: 'Delete',
|
||||||
|
},
|
||||||
|
count: '{n} scenes | {n} scene | {n} scenes',
|
||||||
|
locked: 'A project must have at least one scene.',
|
||||||
|
},
|
||||||
|
|
||||||
dialog: {
|
dialog: {
|
||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
input: 'Input',
|
input: 'Input',
|
||||||
|
|||||||
+35
-11
@@ -1,6 +1,7 @@
|
|||||||
import type { Scene } from '@/types/scene'
|
import type { Project, Scene } from '@/types/scene'
|
||||||
|
import { projectFromLegacyScene } from '@/stores/scene'
|
||||||
|
|
||||||
const FILE_VERSION = 1
|
const FILE_VERSION = 2
|
||||||
// The on-disk magic stays `animatr-project` so older exports keep loading.
|
// The on-disk magic stays `animatr-project` so older exports keep loading.
|
||||||
// Only user-visible filenames & strings have rebranded to RUWIO.
|
// Only user-visible filenames & strings have rebranded to RUWIO.
|
||||||
const MAGIC = 'animatr-project'
|
const MAGIC = 'animatr-project'
|
||||||
@@ -9,15 +10,19 @@ export interface ProjectFile {
|
|||||||
magic: typeof MAGIC
|
magic: typeof MAGIC
|
||||||
fileVersion: number
|
fileVersion: number
|
||||||
savedAt: string
|
savedAt: string
|
||||||
scene: Scene
|
/** v2+: the new top-level container. Always present in current saves. */
|
||||||
|
project?: Project
|
||||||
|
/** v1 (legacy): a single Scene was the project. Kept around so old
|
||||||
|
* files keep loading; new saves never set this. */
|
||||||
|
scene?: Scene
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeProject(scene: Scene): string {
|
export function serializeProject(project: Project): string {
|
||||||
const payload: ProjectFile = {
|
const payload: ProjectFile = {
|
||||||
magic: MAGIC,
|
magic: MAGIC,
|
||||||
fileVersion: FILE_VERSION,
|
fileVersion: FILE_VERSION,
|
||||||
savedAt: new Date().toISOString(),
|
savedAt: new Date().toISOString(),
|
||||||
scene,
|
project,
|
||||||
}
|
}
|
||||||
return JSON.stringify(payload, null, 2)
|
return JSON.stringify(payload, null, 2)
|
||||||
}
|
}
|
||||||
@@ -29,7 +34,9 @@ export class ProjectFileError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseProject(text: string): Scene {
|
/** Parse a project file. Returns a Project regardless of whether the file
|
||||||
|
* was saved in the v1 (single Scene) or v2+ (Project) format. */
|
||||||
|
export function parseProject(text: string): Project {
|
||||||
let data: unknown
|
let data: unknown
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(text)
|
data = JSON.parse(text)
|
||||||
@@ -43,9 +50,26 @@ export function parseProject(text: string): Scene {
|
|||||||
if (obj.magic !== MAGIC) {
|
if (obj.magic !== MAGIC) {
|
||||||
throw new ProjectFileError('Not a RUWIO project file (magic missing).')
|
throw new ProjectFileError('Not a RUWIO project file (magic missing).')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2+ payload: project field present.
|
||||||
|
if (obj.project && typeof obj.project === 'object') {
|
||||||
|
const p = obj.project as Record<string, unknown>
|
||||||
|
if (!p.canvas || typeof p.canvas !== 'object') {
|
||||||
|
throw new ProjectFileError('Project data incomplete: canvas missing.')
|
||||||
|
}
|
||||||
|
if (!Array.isArray(p.scenes)) {
|
||||||
|
throw new ProjectFileError('Project data incomplete: scenes missing.')
|
||||||
|
}
|
||||||
|
if (!Array.isArray(p.assets)) {
|
||||||
|
throw new ProjectFileError('Project data incomplete: assets missing.')
|
||||||
|
}
|
||||||
|
return obj.project as Project
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1 payload: lift scene into a single-page project.
|
||||||
const scene = obj.scene
|
const scene = obj.scene
|
||||||
if (!scene || typeof scene !== 'object') {
|
if (!scene || typeof scene !== 'object') {
|
||||||
throw new ProjectFileError('Project file has no scene.')
|
throw new ProjectFileError('Project file has no project or scene.')
|
||||||
}
|
}
|
||||||
const s = scene as Record<string, unknown>
|
const s = scene as Record<string, unknown>
|
||||||
if (!s.canvas || typeof s.canvas !== 'object') {
|
if (!s.canvas || typeof s.canvas !== 'object') {
|
||||||
@@ -57,11 +81,11 @@ export function parseProject(text: string): Scene {
|
|||||||
if (!Array.isArray(s.assets)) {
|
if (!Array.isArray(s.assets)) {
|
||||||
throw new ProjectFileError('Scene data incomplete: assets missing.')
|
throw new ProjectFileError('Scene data incomplete: assets missing.')
|
||||||
}
|
}
|
||||||
return scene as Scene
|
return projectFromLegacyScene(scene as Scene)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadProject(scene: Scene, filename = 'projekt.ruwio.json') {
|
export function downloadProject(project: Project, filename = 'projekt.ruwio.json') {
|
||||||
const source = serializeProject(scene)
|
const source = serializeProject(project)
|
||||||
const blob = new Blob([source], { type: 'application/json' })
|
const blob = new Blob([source], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
@@ -87,7 +111,7 @@ export function pickProjectFile(): Promise<File | null> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readProjectFile(file: File): Promise<Scene> {
|
export async function readProjectFile(file: File): Promise<Project> {
|
||||||
const text = await file.text()
|
const text = await file.text()
|
||||||
return parseProject(text)
|
return parseProject(text)
|
||||||
}
|
}
|
||||||
+21
-5
@@ -1,4 +1,5 @@
|
|||||||
import type { Scene } from '@/types/scene'
|
import type { Project, Scene } from '@/types/scene'
|
||||||
|
import { projectFromLegacyScene } from '@/stores/scene'
|
||||||
|
|
||||||
const DB_NAME = 'animatr'
|
const DB_NAME = 'animatr'
|
||||||
const DB_VERSION = 1
|
const DB_VERSION = 1
|
||||||
@@ -8,7 +9,11 @@ const MAX_RECENT = 8
|
|||||||
export interface StoredProject {
|
export interface StoredProject {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
scene: Scene
|
/** v2+: the full project. Always populated for new saves. */
|
||||||
|
project?: Project
|
||||||
|
/** v1 (legacy): older records held a single Scene. Loader migrates
|
||||||
|
* these into a one-page Project on read; new saves never write this. */
|
||||||
|
scene?: Scene
|
||||||
savedAt: number
|
savedAt: number
|
||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
}
|
}
|
||||||
@@ -48,11 +53,22 @@ function uid(): string {
|
|||||||
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4)
|
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** In-place migration: older records stored `scene`; newer ones hold
|
||||||
|
* `project`. Normalise to `project` so callers don't have to branch. */
|
||||||
|
function migrate(record: StoredProject): StoredProject {
|
||||||
|
if (!record.project && record.scene) {
|
||||||
|
record.project = projectFromLegacyScene(record.scene, record.name || 'Scene 1')
|
||||||
|
delete record.scene
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
export async function listRecentProjects(): Promise<StoredProject[]> {
|
export async function listRecentProjects(): Promise<StoredProject[]> {
|
||||||
try {
|
try {
|
||||||
const all = await tx<StoredProject[]>('readonly', (s) => s.getAll() as IDBRequest<StoredProject[]>)
|
const all = await tx<StoredProject[]>('readonly', (s) => s.getAll() as IDBRequest<StoredProject[]>)
|
||||||
return (all ?? [])
|
return (all ?? [])
|
||||||
.slice()
|
.slice()
|
||||||
|
.map(migrate)
|
||||||
.sort((a, b) => b.savedAt - a.savedAt)
|
.sort((a, b) => b.savedAt - a.savedAt)
|
||||||
.slice(0, MAX_RECENT)
|
.slice(0, MAX_RECENT)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -67,13 +83,13 @@ export interface SaveOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveProjectToStore(
|
export async function saveProjectToStore(
|
||||||
scene: Scene,
|
project: Project,
|
||||||
opts: SaveOptions = {},
|
opts: SaveOptions = {},
|
||||||
): Promise<StoredProject> {
|
): Promise<StoredProject> {
|
||||||
const record: StoredProject = {
|
const record: StoredProject = {
|
||||||
id: opts.id ?? uid(),
|
id: opts.id ?? uid(),
|
||||||
name: opts.name?.trim() || defaultName(),
|
name: opts.name?.trim() || defaultName(),
|
||||||
scene: JSON.parse(JSON.stringify(scene)),
|
project: JSON.parse(JSON.stringify(project)),
|
||||||
savedAt: Date.now(),
|
savedAt: Date.now(),
|
||||||
thumbnail: opts.thumbnail,
|
thumbnail: opts.thumbnail,
|
||||||
}
|
}
|
||||||
@@ -90,7 +106,7 @@ export async function getStoredProject(id: string): Promise<StoredProject | null
|
|||||||
const rec = await tx<StoredProject | undefined>('readonly', (s) =>
|
const rec = await tx<StoredProject | undefined>('readonly', (s) =>
|
||||||
s.get(id) as IDBRequest<StoredProject | undefined>,
|
s.get(id) as IDBRequest<StoredProject | undefined>,
|
||||||
)
|
)
|
||||||
return rec ?? null
|
return rec ? migrate(rec) : null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
+369
-110
@@ -16,7 +16,9 @@ import type {
|
|||||||
Layer,
|
Layer,
|
||||||
Marker,
|
Marker,
|
||||||
MaskMode,
|
MaskMode,
|
||||||
|
Project,
|
||||||
Scene,
|
Scene,
|
||||||
|
ScenePage,
|
||||||
ScriptFrame,
|
ScriptFrame,
|
||||||
ScriptTrack,
|
ScriptTrack,
|
||||||
TrackGroup,
|
TrackGroup,
|
||||||
@@ -64,7 +66,15 @@ function snapToButtonStateTime(t: number): number {
|
|||||||
export type ButtonEditMode = 'states' | 'hit'
|
export type ButtonEditMode = 'states' | 'hit'
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
scene: Scene
|
/** The persisted top-level container. Holds shared fields (canvas,
|
||||||
|
* duration, fps, in/out, asset library, clip library) plus the list
|
||||||
|
* of scene pages and which one is active. The `scene` getter assembles
|
||||||
|
* a Scene-shaped merged view (shared fields + active page's arrays)
|
||||||
|
* so existing read sites — `store.scene.canvas`, `store.scene.layers`,
|
||||||
|
* `store.scene.assets`, … — keep working unchanged. Mutations go
|
||||||
|
* through actions that target either `this.project.X` (shared) or
|
||||||
|
* `this.activeScene.X` (per-page) directly. */
|
||||||
|
project: Project
|
||||||
/** Primary (anchor) selection — drives the Properties panel and
|
/** Primary (anchor) selection — drives the Properties panel and
|
||||||
* any code that expects a single focused layer. Always matches
|
* any code that expects a single focused layer. Always matches
|
||||||
* the last entry of `selectedLayerIds`. */
|
* the last entry of `selectedLayerIds`. */
|
||||||
@@ -184,32 +194,33 @@ function findLayerAnywhere(scene: Scene, layerId: string): Layer | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeScene(scene: Scene): Scene {
|
/** Per-page housekeeping: ensure all arrays exist, clean up orphan
|
||||||
scene.clips ||= []
|
* childIds in groups, dedupe, build trackOrder for any pristine track,
|
||||||
scene.clipTracks ||= []
|
* and migrate pre-`colorOverridden` color flags. */
|
||||||
scene.clipInstances ||= []
|
function normalizeScenePage(page: ScenePage): ScenePage {
|
||||||
scene.groups ||= []
|
page.layers ||= []
|
||||||
scene.scriptTracks ||= []
|
page.clipInstances ||= []
|
||||||
scene.scriptFrames ||= []
|
page.clipTracks ||= []
|
||||||
scene.markers ||= []
|
page.scriptTracks ||= []
|
||||||
if (!scene.canvas.background) scene.canvas.background = '#ffffff'
|
page.scriptFrames ||= []
|
||||||
|
page.markers ||= []
|
||||||
|
page.groups ||= []
|
||||||
|
page.trackOrder ||= []
|
||||||
|
|
||||||
const known = new Set<string>()
|
const known = new Set<string>()
|
||||||
for (const l of scene.layers) known.add(l.id)
|
for (const l of page.layers) known.add(l.id)
|
||||||
for (const t of scene.clipTracks) known.add(t.id)
|
for (const t of page.clipTracks) known.add(t.id)
|
||||||
for (const g of scene.groups) known.add(g.id)
|
for (const g of page.groups) known.add(g.id)
|
||||||
for (const st of scene.scriptTracks) known.add(st.id)
|
for (const st of page.scriptTracks) known.add(st.id)
|
||||||
|
|
||||||
// Cleanup: group childIds should only reference existing layers/clipTracks
|
|
||||||
const layerAndClipIds = new Set<string>()
|
const layerAndClipIds = new Set<string>()
|
||||||
for (const l of scene.layers) layerAndClipIds.add(l.id)
|
for (const l of page.layers) layerAndClipIds.add(l.id)
|
||||||
for (const t of scene.clipTracks) layerAndClipIds.add(t.id)
|
for (const t of page.clipTracks) layerAndClipIds.add(t.id)
|
||||||
for (const g of scene.groups) {
|
for (const g of page.groups) {
|
||||||
g.childIds = g.childIds.filter((id) => layerAndClipIds.has(id))
|
g.childIds = g.childIds.filter((id) => layerAndClipIds.has(id))
|
||||||
}
|
}
|
||||||
// Dedupe: any given track can only be in one group
|
|
||||||
const inGroup = new Set<string>()
|
const inGroup = new Set<string>()
|
||||||
for (const g of scene.groups) {
|
for (const g of page.groups) {
|
||||||
g.childIds = g.childIds.filter((id) => {
|
g.childIds = g.childIds.filter((id) => {
|
||||||
if (inGroup.has(id)) return false
|
if (inGroup.has(id)) return false
|
||||||
inGroup.add(id)
|
inGroup.add(id)
|
||||||
@@ -217,60 +228,147 @@ function normalizeScene(scene: Scene): Scene {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!scene.trackOrder) scene.trackOrder = []
|
page.trackOrder = page.trackOrder.filter((id) => known.has(id))
|
||||||
scene.trackOrder = scene.trackOrder.filter((id) => known.has(id))
|
for (const l of page.layers) {
|
||||||
|
if (!inGroup.has(l.id) && !page.trackOrder.includes(l.id)) {
|
||||||
for (const l of scene.layers) {
|
page.trackOrder.unshift(l.id)
|
||||||
if (!inGroup.has(l.id) && !scene.trackOrder.includes(l.id)) {
|
|
||||||
scene.trackOrder.unshift(l.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const t of scene.clipTracks) {
|
for (const t of page.clipTracks) {
|
||||||
if (!inGroup.has(t.id) && !scene.trackOrder.includes(t.id)) {
|
if (!inGroup.has(t.id) && !page.trackOrder.includes(t.id)) {
|
||||||
scene.trackOrder.push(t.id)
|
page.trackOrder.push(t.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const g of scene.groups) {
|
for (const g of page.groups) {
|
||||||
if (!scene.trackOrder.includes(g.id)) scene.trackOrder.push(g.id)
|
if (!page.trackOrder.includes(g.id)) page.trackOrder.push(g.id)
|
||||||
}
|
}
|
||||||
for (const st of scene.scriptTracks) {
|
for (const st of page.scriptTracks) {
|
||||||
if (!scene.trackOrder.includes(st.id)) scene.trackOrder.push(st.id)
|
if (!page.trackOrder.includes(st.id)) page.trackOrder.push(st.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migration: scenes saved before `colorOverridden` existed must treat
|
for (const l of page.layers) {
|
||||||
// any track that already carries a `color` as user-set. Without this,
|
|
||||||
// the first subsequent group-color change would silently overwrite
|
|
||||||
// pre-existing per-track colors.
|
|
||||||
for (const l of scene.layers) {
|
|
||||||
if (l.color != null && l.colorOverridden === undefined) l.colorOverridden = true
|
if (l.color != null && l.colorOverridden === undefined) l.colorOverridden = true
|
||||||
}
|
}
|
||||||
for (const t of scene.clipTracks) {
|
for (const t of page.clipTracks) {
|
||||||
if (t.color != null && t.colorOverridden === undefined) t.colorOverridden = true
|
if (t.color != null && t.colorOverridden === undefined) t.colorOverridden = true
|
||||||
}
|
}
|
||||||
for (const st of scene.scriptTracks) {
|
for (const st of page.scriptTracks) {
|
||||||
if (st.color != null && st.colorOverridden === undefined) st.colorOverridden = true
|
if (st.color != null && st.colorOverridden === undefined) st.colorOverridden = true
|
||||||
}
|
}
|
||||||
for (const g of scene.groups) {
|
for (const g of page.groups) {
|
||||||
if (g.color != null && g.colorOverridden === undefined) g.colorOverridden = true
|
if (g.color != null && g.colorOverridden === undefined) g.colorOverridden = true
|
||||||
}
|
}
|
||||||
return scene
|
return page
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Project-level housekeeping. Ensures shared arrays exist and that
|
||||||
|
* `activeSceneId` actually references one of the pages — falls back to
|
||||||
|
* the first page (or a freshly minted one if none exist). */
|
||||||
|
function normalizeProject(project: Project): Project {
|
||||||
|
project.assets ||= []
|
||||||
|
project.clips ||= []
|
||||||
|
project.scenes ||= []
|
||||||
|
if (!project.canvas.background) project.canvas.background = '#ffffff'
|
||||||
|
if (project.scenes.length === 0) {
|
||||||
|
project.scenes.push(createBlankScenePage('Scene 1'))
|
||||||
|
}
|
||||||
|
for (const page of project.scenes) normalizeScenePage(page)
|
||||||
|
if (!project.scenes.find((s) => s.id === project.activeSceneId)) {
|
||||||
|
project.activeSceneId = project.scenes[0].id
|
||||||
|
}
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
export function createBlankScene(): Scene {
|
export function createBlankScenePage(name: string, id?: string): ScenePage {
|
||||||
return normalizeScene({
|
return {
|
||||||
|
id: id ?? uid(),
|
||||||
|
name,
|
||||||
|
layers: [],
|
||||||
|
clipInstances: [],
|
||||||
|
clipTracks: [],
|
||||||
|
scriptTracks: [],
|
||||||
|
scriptFrames: [],
|
||||||
|
markers: [],
|
||||||
|
groups: [],
|
||||||
|
trackOrder: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBlankProject(): Project {
|
||||||
|
const initial = createBlankScenePage('Scene 1')
|
||||||
|
return normalizeProject({
|
||||||
version: 1,
|
version: 1,
|
||||||
canvas: { width: 1280, height: 720, background: '#ffffff' },
|
canvas: { width: 1280, height: 720, background: '#ffffff' },
|
||||||
duration: 10,
|
duration: 10,
|
||||||
fps: 60,
|
fps: 60,
|
||||||
assets: [],
|
assets: [],
|
||||||
layers: [],
|
clips: [],
|
||||||
|
scenes: [initial],
|
||||||
|
activeSceneId: initial.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wrap an old-format Scene (v0 file: { scene }) into a one-page Project.
|
||||||
|
* All shared fields lift up to the project; per-page fields stay on the
|
||||||
|
* one synthesized page. */
|
||||||
|
export function projectFromLegacyScene(scene: Scene, sceneName = 'Scene 1'): Project {
|
||||||
|
const page: ScenePage = {
|
||||||
|
id: uid(),
|
||||||
|
name: sceneName,
|
||||||
|
layers: scene.layers ?? [],
|
||||||
|
clipInstances: scene.clipInstances ?? [],
|
||||||
|
clipTracks: scene.clipTracks ?? [],
|
||||||
|
scriptTracks: scene.scriptTracks ?? [],
|
||||||
|
scriptFrames: scene.scriptFrames ?? [],
|
||||||
|
markers: scene.markers ?? [],
|
||||||
|
groups: scene.groups ?? [],
|
||||||
|
trackOrder: scene.trackOrder ?? [],
|
||||||
|
backgroundVideo: scene.backgroundVideo,
|
||||||
|
}
|
||||||
|
return normalizeProject({
|
||||||
|
version: 1,
|
||||||
|
canvas: { ...scene.canvas },
|
||||||
|
duration: scene.duration,
|
||||||
|
fps: scene.fps,
|
||||||
|
inTime: scene.inTime,
|
||||||
|
outTime: scene.outTime,
|
||||||
|
assets: scene.assets ?? [],
|
||||||
|
clips: scene.clips ?? [],
|
||||||
|
scenes: [page],
|
||||||
|
activeSceneId: page.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backward-compat shim. Older callers (legacy save format, tests) still
|
||||||
|
* reach for `createBlankScene()` to seed a fresh editor. Hand back a
|
||||||
|
* Scene-shaped view of a fresh blank project's first page. */
|
||||||
|
export function createBlankScene(): Scene {
|
||||||
|
const project = createBlankProject()
|
||||||
|
const page = project.scenes[0]
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
canvas: project.canvas,
|
||||||
|
duration: project.duration,
|
||||||
|
fps: project.fps,
|
||||||
|
inTime: project.inTime,
|
||||||
|
outTime: project.outTime,
|
||||||
|
assets: project.assets,
|
||||||
|
clips: project.clips,
|
||||||
|
layers: page.layers,
|
||||||
|
clipInstances: page.clipInstances,
|
||||||
|
clipTracks: page.clipTracks,
|
||||||
|
scriptTracks: page.scriptTracks,
|
||||||
|
scriptFrames: page.scriptFrames,
|
||||||
|
markers: page.markers,
|
||||||
|
groups: page.groups,
|
||||||
|
trackOrder: page.trackOrder,
|
||||||
|
backgroundVideo: page.backgroundVideo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useSceneStore = defineStore('scene', {
|
export const useSceneStore = defineStore('scene', {
|
||||||
state: (): State => ({
|
state: (): State => ({
|
||||||
scene: createBlankScene(),
|
project: createBlankProject(),
|
||||||
selectedLayerId: null,
|
selectedLayerId: null,
|
||||||
selectedLayerIds: [],
|
selectedLayerIds: [],
|
||||||
selectedClipInstanceId: null,
|
selectedClipInstanceId: null,
|
||||||
@@ -285,14 +383,48 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
drawingLayerId: null,
|
drawingLayerId: null,
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
activeClip(state): Clip | null {
|
/** The active page from `project.scenes`. Use this when you need to
|
||||||
if (!state.activeClipId) return null
|
* mutate per-page fields (e.g., reassign `activeScene.layers`). */
|
||||||
const clips = state.scene.clips ?? []
|
activeScene(state): ScenePage {
|
||||||
return clips.find((c) => c.id === state.activeClipId) ?? null
|
const p = state.project
|
||||||
|
return p.scenes.find((s) => s.id === p.activeSceneId) ?? p.scenes[0]
|
||||||
},
|
},
|
||||||
activeButtonAsset(state): ButtonAsset | null {
|
/** Merged Scene-shaped view: shared project fields combined with the
|
||||||
if (!state.activeButtonAssetId) return null
|
* active page's arrays. Read-only by convention — array references
|
||||||
const asset = state.scene.assets.find((a) => a.id === state.activeButtonAssetId)
|
* are live (so `store.scene.layers.push(x)` still hits the active
|
||||||
|
* page) but assigning a primitive (`store.scene.duration = 5`) on
|
||||||
|
* this view does NOT reach the project. Mutate via store actions
|
||||||
|
* (setDuration, setFps, …) instead. */
|
||||||
|
scene(): Scene {
|
||||||
|
const p = this.project
|
||||||
|
const page = this.activeScene
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
canvas: p.canvas,
|
||||||
|
duration: p.duration,
|
||||||
|
fps: p.fps,
|
||||||
|
inTime: p.inTime,
|
||||||
|
outTime: p.outTime,
|
||||||
|
assets: p.assets,
|
||||||
|
clips: p.clips,
|
||||||
|
layers: page.layers,
|
||||||
|
clipInstances: page.clipInstances,
|
||||||
|
clipTracks: page.clipTracks,
|
||||||
|
scriptTracks: page.scriptTracks,
|
||||||
|
scriptFrames: page.scriptFrames,
|
||||||
|
markers: page.markers,
|
||||||
|
groups: page.groups,
|
||||||
|
trackOrder: page.trackOrder,
|
||||||
|
backgroundVideo: page.backgroundVideo,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
activeClip(): Clip | null {
|
||||||
|
if (!this.activeClipId) return null
|
||||||
|
return this.project.clips.find((c) => c.id === this.activeClipId) ?? null
|
||||||
|
},
|
||||||
|
activeButtonAsset(): ButtonAsset | null {
|
||||||
|
if (!this.activeButtonAssetId) return null
|
||||||
|
const asset = this.project.assets.find((a) => a.id === this.activeButtonAssetId)
|
||||||
return asset && asset.kind === 'button' ? asset : null
|
return asset && asset.kind === 'button' ? asset : null
|
||||||
},
|
},
|
||||||
activeLayers(): Layer[] {
|
activeLayers(): Layer[] {
|
||||||
@@ -429,8 +561,8 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
const layers = this.activeLayers
|
const layers = this.activeLayers
|
||||||
const idx = layers.findIndex((l) => l.id === id)
|
const idx = layers.findIndex((l) => l.id === id)
|
||||||
if (idx >= 0) layers.splice(idx, 1)
|
if (idx >= 0) layers.splice(idx, 1)
|
||||||
if (!this.activeClipId && this.scene.trackOrder) {
|
if (!this.activeClipId) {
|
||||||
this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id)
|
this.activeScene.trackOrder = this.activeScene.trackOrder.filter((x) => x !== id)
|
||||||
}
|
}
|
||||||
if (this.selectedLayerId === id) {
|
if (this.selectedLayerId === id) {
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
@@ -500,41 +632,41 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
clip.duration = Math.max(0.1, seconds)
|
clip.duration = Math.max(0.1, seconds)
|
||||||
if (this.playhead > clip.duration) this.playhead = clip.duration
|
if (this.playhead > clip.duration) this.playhead = clip.duration
|
||||||
} else {
|
} else {
|
||||||
this.scene.duration = Math.max(0.1, seconds)
|
this.project.duration = Math.max(0.1, seconds)
|
||||||
if (this.playhead > this.scene.duration) this.playhead = this.scene.duration
|
if (this.playhead > this.project.duration) this.playhead = this.project.duration
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setFps(fps: number) {
|
setFps(fps: number) {
|
||||||
this.scene.fps = Math.max(1, Math.round(fps))
|
this.project.fps = Math.max(1, Math.round(fps))
|
||||||
},
|
},
|
||||||
setCanvasSize(width: number, height: number) {
|
setCanvasSize(width: number, height: number) {
|
||||||
this.scene.canvas.width = Math.max(1, Math.round(width))
|
this.project.canvas.width = Math.max(1, Math.round(width))
|
||||||
this.scene.canvas.height = Math.max(1, Math.round(height))
|
this.project.canvas.height = Math.max(1, Math.round(height))
|
||||||
},
|
},
|
||||||
setCanvasBackground(color: string) {
|
setCanvasBackground(color: string) {
|
||||||
this.scene.canvas.background = color
|
this.project.canvas.background = color
|
||||||
},
|
},
|
||||||
setBackgroundVideo(video: BackgroundVideo | null) {
|
setBackgroundVideo(video: BackgroundVideo | null) {
|
||||||
if (video && video.src) this.scene.backgroundVideo = { ...video }
|
if (video && video.src) this.activeScene.backgroundVideo = { ...video }
|
||||||
else delete this.scene.backgroundVideo
|
else delete this.activeScene.backgroundVideo
|
||||||
},
|
},
|
||||||
setPlaybackRange(inTime: number | null, outTime: number | null) {
|
setPlaybackRange(inTime: number | null, outTime: number | null) {
|
||||||
const dur = this.scene.duration
|
const dur = this.project.duration
|
||||||
const hasIn = inTime !== null && inTime !== undefined
|
const hasIn = inTime !== null && inTime !== undefined
|
||||||
const hasOut = outTime !== null && outTime !== undefined
|
const hasOut = outTime !== null && outTime !== undefined
|
||||||
if (hasIn) {
|
if (hasIn) {
|
||||||
const v = Math.max(0, Math.min(dur, inTime!))
|
const v = Math.max(0, Math.min(dur, inTime!))
|
||||||
if (v === 0 && this.scene.outTime === undefined) delete this.scene.inTime
|
if (v === 0 && this.project.outTime === undefined) delete this.project.inTime
|
||||||
else this.scene.inTime = v
|
else this.project.inTime = v
|
||||||
}
|
}
|
||||||
if (hasOut) {
|
if (hasOut) {
|
||||||
const v = Math.max(this.scene.inTime ?? 0, Math.min(dur, outTime!))
|
const v = Math.max(this.project.inTime ?? 0, Math.min(dur, outTime!))
|
||||||
if (v === dur && this.scene.inTime === undefined) delete this.scene.outTime
|
if (v === dur && this.project.inTime === undefined) delete this.project.outTime
|
||||||
else this.scene.outTime = v
|
else this.project.outTime = v
|
||||||
}
|
}
|
||||||
// Re-clamp playhead into the new range
|
// Re-clamp playhead into the new range
|
||||||
const lo = this.scene.inTime ?? 0
|
const lo = this.project.inTime ?? 0
|
||||||
const hi = this.scene.outTime ?? dur
|
const hi = this.project.outTime ?? dur
|
||||||
if (this.playhead < lo) this.playhead = lo
|
if (this.playhead < lo) this.playhead = lo
|
||||||
else if (this.playhead > hi) this.playhead = hi
|
else if (this.playhead > hi) this.playhead = hi
|
||||||
},
|
},
|
||||||
@@ -1043,7 +1175,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
if (!asset || asset.kind !== 'polygon') return
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
if (asset.points.length < 2) {
|
if (asset.points.length < 2) {
|
||||||
this.removeLayer(layerId)
|
this.removeLayer(layerId)
|
||||||
this.scene.assets = this.scene.assets.filter((a) => a.id !== assetId)
|
this.project.assets = this.project.assets.filter((a) => a.id !== assetId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cancelDrawing() {
|
cancelDrawing() {
|
||||||
@@ -1053,7 +1185,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
this.drawingAssetId = null
|
this.drawingAssetId = null
|
||||||
this.drawingLayerId = null
|
this.drawingLayerId = null
|
||||||
if (layerId) this.removeLayer(layerId)
|
if (layerId) this.removeLayer(layerId)
|
||||||
if (assetId) this.scene.assets = this.scene.assets.filter((a) => a.id !== assetId)
|
if (assetId) this.project.assets = this.project.assets.filter((a) => a.id !== assetId)
|
||||||
},
|
},
|
||||||
setPolygonVertexType(
|
setPolygonVertexType(
|
||||||
assetId: string,
|
assetId: string,
|
||||||
@@ -1361,8 +1493,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
return m
|
return m
|
||||||
},
|
},
|
||||||
removeMarker(id: string) {
|
removeMarker(id: string) {
|
||||||
if (!this.scene.markers) return
|
this.activeScene.markers = this.activeScene.markers.filter((m) => m.id !== id)
|
||||||
this.scene.markers = this.scene.markers.filter((m) => m.id !== id)
|
|
||||||
},
|
},
|
||||||
renameMarker(id: string, name: string): boolean {
|
renameMarker(id: string, name: string): boolean {
|
||||||
const markers = this.scene.markers
|
const markers = this.scene.markers
|
||||||
@@ -1398,15 +1529,10 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
return track
|
return track
|
||||||
},
|
},
|
||||||
removeScriptTrack(id: string) {
|
removeScriptTrack(id: string) {
|
||||||
if (this.scene.scriptTracks) {
|
const page = this.activeScene
|
||||||
this.scene.scriptTracks = this.scene.scriptTracks.filter((t) => t.id !== id)
|
page.scriptTracks = page.scriptTracks.filter((t) => t.id !== id)
|
||||||
}
|
page.scriptFrames = page.scriptFrames.filter((f) => f.trackId !== id)
|
||||||
if (this.scene.scriptFrames) {
|
page.trackOrder = page.trackOrder.filter((tid) => tid !== id)
|
||||||
this.scene.scriptFrames = this.scene.scriptFrames.filter((f) => f.trackId !== id)
|
|
||||||
}
|
|
||||||
if (this.scene.trackOrder) {
|
|
||||||
this.scene.trackOrder = this.scene.trackOrder.filter((tid) => tid !== id)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
setScriptTrackName(id: string, name: string) {
|
setScriptTrackName(id: string, name: string) {
|
||||||
const track = this.scene.scriptTracks?.find((t) => t.id === id)
|
const track = this.scene.scriptTracks?.find((t) => t.id === id)
|
||||||
@@ -1433,8 +1559,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
return frame
|
return frame
|
||||||
},
|
},
|
||||||
removeScriptFrame(id: string) {
|
removeScriptFrame(id: string) {
|
||||||
if (!this.scene.scriptFrames) return
|
this.activeScene.scriptFrames = this.activeScene.scriptFrames.filter((f) => f.id !== id)
|
||||||
this.scene.scriptFrames = this.scene.scriptFrames.filter((f) => f.id !== id)
|
|
||||||
},
|
},
|
||||||
setScriptFrameActions(id: string, actions: Action[]) {
|
setScriptFrameActions(id: string, actions: Action[]) {
|
||||||
const frame = this.scene.scriptFrames?.find((f) => f.id === id)
|
const frame = this.scene.scriptFrames?.find((f) => f.id === id)
|
||||||
@@ -1627,17 +1752,155 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
layer.separated = false
|
layer.separated = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
loadScene(scene: Scene) {
|
/** Replace the entire project. Resets editor context (selection,
|
||||||
this.scene = normalizeScene(scene)
|
* playback, clip / button-edit drill-down). */
|
||||||
|
loadProject(project: Project) {
|
||||||
|
this.project = normalizeProject(project)
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
this.selectedLayerIds = []
|
this.selectedLayerIds = []
|
||||||
|
this.selectedClipInstanceId = null
|
||||||
this.playhead = 0
|
this.playhead = 0
|
||||||
this.playing = false
|
this.playing = false
|
||||||
this.activeClipId = null
|
this.activeClipId = null
|
||||||
this.activeButtonAssetId = null
|
this.activeButtonAssetId = null
|
||||||
this.activeButtonEditMode = null
|
this.activeButtonEditMode = null
|
||||||
this.activeButtonState = null
|
this.activeButtonState = null
|
||||||
|
this.drawingMode = null
|
||||||
|
this.drawingAssetId = null
|
||||||
|
this.drawingLayerId = null
|
||||||
},
|
},
|
||||||
|
/** Backward-compat shim: legacy callers (older project files, recents
|
||||||
|
* stored as Scene, tests) still pass a Scene. Wrap into a one-page
|
||||||
|
* Project and delegate. */
|
||||||
|
loadScene(scene: Scene) {
|
||||||
|
this.loadProject(projectFromLegacyScene(scene))
|
||||||
|
},
|
||||||
|
/** Replace the project data only — for use by the undo/redo layer.
|
||||||
|
* Unlike `loadProject`, transient editor state (playhead, selection,
|
||||||
|
* activeClipId, activeButtonAssetId, drawing) is left untouched so
|
||||||
|
* undoing a content change doesn't kick the user out of clip-edit
|
||||||
|
* or reset where they were scrubbing. The caller is responsible for
|
||||||
|
* pruning any selection that now points at a vanished id. */
|
||||||
|
applyProjectSnapshot(project: Project) {
|
||||||
|
this.project = normalizeProject(project)
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------- Scene pages ----------------------------------------------
|
||||||
|
|
||||||
|
/** Append a fresh, empty page and activate it. The default name is
|
||||||
|
* "Scene N" with N counting up from the current page count. */
|
||||||
|
addScene(name?: string): ScenePage {
|
||||||
|
const final = name?.trim() || `Scene ${this.project.scenes.length + 1}`
|
||||||
|
const page = createBlankScenePage(final)
|
||||||
|
this.project.scenes.push(page)
|
||||||
|
this.switchScene(page.id)
|
||||||
|
return page
|
||||||
|
},
|
||||||
|
/** Activate a page by id. Clears any drill-down state (clip-edit,
|
||||||
|
* button-edit, selections, drawing) so the new page isn't shown
|
||||||
|
* through the lens of a context that belongs to another page. */
|
||||||
|
switchScene(id: string): boolean {
|
||||||
|
if (!this.project.scenes.find((s) => s.id === id)) return false
|
||||||
|
this.project.activeSceneId = id
|
||||||
|
this.activeClipId = null
|
||||||
|
this.activeButtonAssetId = null
|
||||||
|
this.activeButtonEditMode = null
|
||||||
|
this.activeButtonState = null
|
||||||
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
|
this.selectedClipInstanceId = null
|
||||||
|
this.drawingMode = null
|
||||||
|
this.drawingAssetId = null
|
||||||
|
this.drawingLayerId = null
|
||||||
|
this.playing = false
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
renameScene(id: string, name: string): boolean {
|
||||||
|
const page = this.project.scenes.find((s) => s.id === id)
|
||||||
|
if (!page) return false
|
||||||
|
const next = name.trim()
|
||||||
|
if (!next) return false
|
||||||
|
page.name = next
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
/** Remove a page. Refuses to delete the last remaining page (a
|
||||||
|
* project must always have at least one). If the active page is
|
||||||
|
* removed, activates the neighbour. */
|
||||||
|
removeScene(id: string): boolean {
|
||||||
|
const scenes = this.project.scenes
|
||||||
|
if (scenes.length <= 1) return false
|
||||||
|
const idx = scenes.findIndex((s) => s.id === id)
|
||||||
|
if (idx < 0) return false
|
||||||
|
const wasActive = this.project.activeSceneId === id
|
||||||
|
scenes.splice(idx, 1)
|
||||||
|
if (wasActive) {
|
||||||
|
const neighbour = scenes[Math.min(idx, scenes.length - 1)]
|
||||||
|
this.switchScene(neighbour.id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
/** Deep-clone the named page with fresh ids on every layer, keyframe,
|
||||||
|
* clip-instance, script-frame, marker, group, and trackOrder entry.
|
||||||
|
* Inserted directly after the original; activated. */
|
||||||
|
duplicateScene(id: string): ScenePage | null {
|
||||||
|
const orig = this.project.scenes.find((s) => s.id === id)
|
||||||
|
if (!orig) return null
|
||||||
|
const cloned = structuredClone(toRaw(orig)) as ScenePage
|
||||||
|
// Build a complete id-remap (every layer, group, clipTrack, scriptTrack)
|
||||||
|
// so trackOrder/group childIds/scriptFrames stay internally consistent.
|
||||||
|
const remap = new Map<string, string>()
|
||||||
|
const remapId = (oldId: string) => {
|
||||||
|
let next = remap.get(oldId)
|
||||||
|
if (!next) {
|
||||||
|
next = uid()
|
||||||
|
remap.set(oldId, next)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
cloned.id = uid()
|
||||||
|
cloned.name = `${orig.name} copy`
|
||||||
|
for (const l of cloned.layers) {
|
||||||
|
l.id = remapId(l.id)
|
||||||
|
l.keyframes = l.keyframes.map((k) => ({ ...k, id: uid() }))
|
||||||
|
if (l.positionKeyframes) {
|
||||||
|
l.positionKeyframes = l.positionKeyframes.map((k) => ({ ...k, id: uid() }))
|
||||||
|
}
|
||||||
|
if (l.rotationKeyframes) {
|
||||||
|
l.rotationKeyframes = l.rotationKeyframes.map((k) => ({ ...k, id: uid() }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const t of cloned.clipTracks) t.id = remapId(t.id)
|
||||||
|
for (const st of cloned.scriptTracks) st.id = remapId(st.id)
|
||||||
|
for (const g of cloned.groups) {
|
||||||
|
g.id = remapId(g.id)
|
||||||
|
g.childIds = g.childIds.map((cid) => remap.get(cid) ?? cid)
|
||||||
|
}
|
||||||
|
cloned.trackOrder = cloned.trackOrder.map((oid) => remap.get(oid) ?? oid)
|
||||||
|
cloned.clipInstances = cloned.clipInstances.map((ci) => ({
|
||||||
|
...ci,
|
||||||
|
id: uid(),
|
||||||
|
clipTrackId: remap.get(ci.clipTrackId) ?? ci.clipTrackId,
|
||||||
|
}))
|
||||||
|
cloned.scriptFrames = cloned.scriptFrames.map((f) => ({
|
||||||
|
...f,
|
||||||
|
id: uid(),
|
||||||
|
trackId: remap.get(f.trackId) ?? f.trackId,
|
||||||
|
}))
|
||||||
|
cloned.markers = cloned.markers.map((m) => ({ ...m, id: uid() }))
|
||||||
|
|
||||||
|
const idx = this.project.scenes.findIndex((s) => s.id === id)
|
||||||
|
this.project.scenes.splice(idx + 1, 0, cloned)
|
||||||
|
this.switchScene(cloned.id)
|
||||||
|
return cloned
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Remove an asset from the project library. Layers that referenced
|
||||||
|
* it remain — they'll just render as nothing. Use this from UI
|
||||||
|
* components instead of mutating `project.assets` inline. */
|
||||||
|
removeAsset(id: string) {
|
||||||
|
this.project.assets = this.project.assets.filter((a) => a.id !== id)
|
||||||
|
},
|
||||||
|
|
||||||
addClip(name?: string): Clip {
|
addClip(name?: string): Clip {
|
||||||
const clip: Clip = {
|
const clip: Clip = {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
@@ -1702,9 +1965,10 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
// group they were part of. Clearing in one pass keeps Pinia's
|
// group they were part of. Clearing in one pass keeps Pinia's
|
||||||
// reactivity from thrashing on intermediate states.
|
// reactivity from thrashing on intermediate states.
|
||||||
const idSet = new Set(ids)
|
const idSet = new Set(ids)
|
||||||
this.scene.layers = this.scene.layers.filter((l) => !idSet.has(l.id))
|
const page = this.activeScene
|
||||||
this.scene.trackOrder = order.filter((x) => !idSet.has(x))
|
page.layers = page.layers.filter((l) => !idSet.has(l.id))
|
||||||
for (const g of this.scene.groups ?? []) {
|
page.trackOrder = order.filter((x) => !idSet.has(x))
|
||||||
|
for (const g of page.groups) {
|
||||||
g.childIds = g.childIds.filter((x) => !idSet.has(x))
|
g.childIds = g.childIds.filter((x) => !idSet.has(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1733,16 +1997,17 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
return clip.id
|
return clip.id
|
||||||
},
|
},
|
||||||
removeClip(id: string) {
|
removeClip(id: string) {
|
||||||
if (this.scene.clips) {
|
this.project.clips = this.project.clips.filter((c) => c.id !== id)
|
||||||
this.scene.clips = this.scene.clips.filter((c) => c.id !== id)
|
// Clip-instances of the deleted clip live on every scene page that
|
||||||
}
|
// happened to place it — purge from all of them so we don't leave
|
||||||
if (this.scene.clipInstances) {
|
// dangling references in non-active pages.
|
||||||
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.clipId !== id)
|
for (const page of this.project.scenes) {
|
||||||
|
page.clipInstances = page.clipInstances.filter((ci) => ci.clipId !== id)
|
||||||
}
|
}
|
||||||
if (this.activeClipId === id) {
|
if (this.activeClipId === id) {
|
||||||
this.activeClipId = null
|
this.activeClipId = null
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
this.selectedLayerIds = []
|
this.selectedLayerIds = []
|
||||||
this.playhead = 0
|
this.playhead = 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1851,15 +2116,10 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
removeClipTrack(id: string) {
|
removeClipTrack(id: string) {
|
||||||
if (this.scene.clipTracks) {
|
const page = this.activeScene
|
||||||
this.scene.clipTracks = this.scene.clipTracks.filter((t) => t.id !== id)
|
page.clipTracks = page.clipTracks.filter((t) => t.id !== id)
|
||||||
}
|
page.clipInstances = page.clipInstances.filter((ci) => ci.clipTrackId !== id)
|
||||||
if (this.scene.clipInstances) {
|
page.trackOrder = page.trackOrder.filter((x) => x !== id)
|
||||||
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.clipTrackId !== id)
|
|
||||||
}
|
|
||||||
if (this.scene.trackOrder) {
|
|
||||||
this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
addClipInstance(clipId: string, startTime: number, clipTrackId: string): ClipInstance | null {
|
addClipInstance(clipId: string, startTime: number, clipTrackId: string): ClipInstance | null {
|
||||||
const clip = this.scene.clips?.find((c) => c.id === clipId)
|
const clip = this.scene.clips?.find((c) => c.id === clipId)
|
||||||
@@ -1914,8 +2174,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
inst.startTime = Math.max(0, startTime)
|
inst.startTime = Math.max(0, startTime)
|
||||||
},
|
},
|
||||||
removeClipInstance(id: string) {
|
removeClipInstance(id: string) {
|
||||||
if (!this.scene.clipInstances) return
|
this.activeScene.clipInstances = this.activeScene.clipInstances.filter((ci) => ci.id !== id)
|
||||||
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.id !== id)
|
|
||||||
if (this.selectedClipInstanceId === id) this.selectedClipInstanceId = null
|
if (this.selectedClipInstanceId === id) this.selectedClipInstanceId = null
|
||||||
},
|
},
|
||||||
/** Update the per-instance x / y / scaleX / scaleY. Missing fields
|
/** Update the per-instance x / y / scaleX / scaleY. Missing fields
|
||||||
@@ -2020,10 +2279,10 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
removeTrackGroup(id: string) {
|
removeTrackGroup(id: string) {
|
||||||
const group = this.scene.groups?.find((g) => g.id === id)
|
const group = this.scene.groups?.find((g) => g.id === id)
|
||||||
if (!group) return
|
if (!group) return
|
||||||
const order = this.scene.trackOrder ?? []
|
const order = this.activeScene.trackOrder
|
||||||
const idx = order.indexOf(id)
|
const idx = order.indexOf(id)
|
||||||
if (idx >= 0) order.splice(idx, 1, ...group.childIds)
|
if (idx >= 0) order.splice(idx, 1, ...group.childIds)
|
||||||
this.scene.groups = (this.scene.groups ?? []).filter((g) => g.id !== id)
|
this.activeScene.groups = this.activeScene.groups.filter((g) => g.id !== id)
|
||||||
},
|
},
|
||||||
renameGroup(id: string, name: string) {
|
renameGroup(id: string, name: string) {
|
||||||
const group = this.scene.groups?.find((g) => g.id === id)
|
const group = this.scene.groups?.find((g) => g.id === id)
|
||||||
|
|||||||
@@ -299,6 +299,51 @@ export interface Scene {
|
|||||||
markers?: Marker[]
|
markers?: Marker[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page-like sub-scene within a Project. Holds only what differs between
|
||||||
|
* pages (layer stack, tracks, clip placements, scripts, markers, optional
|
||||||
|
* background video). Canvas, duration, fps, in/out, asset library and
|
||||||
|
* clip library live one level up on the Project so all pages stay in sync.
|
||||||
|
*/
|
||||||
|
export interface ScenePage {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
layers: Layer[]
|
||||||
|
clipInstances: ClipInstance[]
|
||||||
|
clipTracks: ClipTrack[]
|
||||||
|
scriptTracks: ScriptTrack[]
|
||||||
|
scriptFrames: ScriptFrame[]
|
||||||
|
markers: Marker[]
|
||||||
|
groups: TrackGroup[]
|
||||||
|
trackOrder: string[]
|
||||||
|
backgroundVideo?: BackgroundVideo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top-level container persisted in a `.ruwio` file. Holds shared
|
||||||
|
* project-wide settings + a list of `ScenePage`s. The store exposes a
|
||||||
|
* `scene` getter that returns a merged `Scene`-shaped view of the active
|
||||||
|
* page + project-wide fields, so legacy read sites (`store.scene.canvas`,
|
||||||
|
* `store.scene.layers`, …) keep working unchanged.
|
||||||
|
*/
|
||||||
|
export interface Project {
|
||||||
|
version: 1
|
||||||
|
name?: string
|
||||||
|
canvas: {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
background: string
|
||||||
|
}
|
||||||
|
duration: number
|
||||||
|
fps: number
|
||||||
|
inTime?: number
|
||||||
|
outTime?: number
|
||||||
|
assets: Asset[]
|
||||||
|
clips: Clip[]
|
||||||
|
scenes: ScenePage[]
|
||||||
|
activeSceneId: string
|
||||||
|
}
|
||||||
|
|
||||||
export const DEFAULT_BASE_PROPS: AnimatableProps = {
|
export const DEFAULT_BASE_PROPS: AnimatableProps = {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
|||||||
Reference in new issue
Block a user