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
+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.
|
||||
// Only user-visible filenames & strings have rebranded to RUWIO.
|
||||
const MAGIC = 'animatr-project'
|
||||
@@ -9,15 +10,19 @@ export interface ProjectFile {
|
||||
magic: typeof MAGIC
|
||||
fileVersion: number
|
||||
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 = {
|
||||
magic: MAGIC,
|
||||
fileVersion: FILE_VERSION,
|
||||
savedAt: new Date().toISOString(),
|
||||
scene,
|
||||
project,
|
||||
}
|
||||
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
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
@@ -43,9 +50,26 @@ export function parseProject(text: string): Scene {
|
||||
if (obj.magic !== MAGIC) {
|
||||
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
|
||||
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>
|
||||
if (!s.canvas || typeof s.canvas !== 'object') {
|
||||
@@ -57,11 +81,11 @@ export function parseProject(text: string): Scene {
|
||||
if (!Array.isArray(s.assets)) {
|
||||
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') {
|
||||
const source = serializeProject(scene)
|
||||
export function downloadProject(project: Project, filename = 'projekt.ruwio.json') {
|
||||
const source = serializeProject(project)
|
||||
const blob = new Blob([source], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
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()
|
||||
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_VERSION = 1
|
||||
@@ -8,7 +9,11 @@ const MAX_RECENT = 8
|
||||
export interface StoredProject {
|
||||
id: 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
|
||||
thumbnail?: string
|
||||
}
|
||||
@@ -48,11 +53,22 @@ function uid(): string {
|
||||
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[]> {
|
||||
try {
|
||||
const all = await tx<StoredProject[]>('readonly', (s) => s.getAll() as IDBRequest<StoredProject[]>)
|
||||
return (all ?? [])
|
||||
.slice()
|
||||
.map(migrate)
|
||||
.sort((a, b) => b.savedAt - a.savedAt)
|
||||
.slice(0, MAX_RECENT)
|
||||
} catch {
|
||||
@@ -67,13 +83,13 @@ export interface SaveOptions {
|
||||
}
|
||||
|
||||
export async function saveProjectToStore(
|
||||
scene: Scene,
|
||||
project: Project,
|
||||
opts: SaveOptions = {},
|
||||
): Promise<StoredProject> {
|
||||
const record: StoredProject = {
|
||||
id: opts.id ?? uid(),
|
||||
name: opts.name?.trim() || defaultName(),
|
||||
scene: JSON.parse(JSON.stringify(scene)),
|
||||
project: JSON.parse(JSON.stringify(project)),
|
||||
savedAt: Date.now(),
|
||||
thumbnail: opts.thumbnail,
|
||||
}
|
||||
@@ -90,7 +106,7 @@ export async function getStoredProject(id: string): Promise<StoredProject | null
|
||||
const rec = await tx<StoredProject | undefined>('readonly', (s) =>
|
||||
s.get(id) as IDBRequest<StoredProject | undefined>,
|
||||
)
|
||||
return rec ?? null
|
||||
return rec ? migrate(rec) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user