bak
This commit is contained in:
1 parent
94e9846c13
commit
e4aa0dce86
11 files changed
+2227
-32
No files matched your search
@@ -0,0 +1,91 @@
|
||||
import type { Scene } from '@/types/scene'
|
||||
|
||||
const FILE_VERSION = 1
|
||||
const MAGIC = 'animatr-project'
|
||||
|
||||
export interface ProjectFile {
|
||||
magic: typeof MAGIC
|
||||
fileVersion: number
|
||||
savedAt: string
|
||||
scene: Scene
|
||||
}
|
||||
|
||||
export function serializeProject(scene: Scene): string {
|
||||
const payload: ProjectFile = {
|
||||
magic: MAGIC,
|
||||
fileVersion: FILE_VERSION,
|
||||
savedAt: new Date().toISOString(),
|
||||
scene,
|
||||
}
|
||||
return JSON.stringify(payload, null, 2)
|
||||
}
|
||||
|
||||
export class ProjectFileError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ProjectFileError'
|
||||
}
|
||||
}
|
||||
|
||||
export function parseProject(text: string): Scene {
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
throw new ProjectFileError('Datei ist kein gültiges JSON.')
|
||||
}
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new ProjectFileError('Datei ist leer oder kein Objekt.')
|
||||
}
|
||||
const obj = data as Record<string, unknown>
|
||||
if (obj.magic !== MAGIC) {
|
||||
throw new ProjectFileError('Keine Animatr-Projektdatei (magic fehlt).')
|
||||
}
|
||||
const scene = obj.scene
|
||||
if (!scene || typeof scene !== 'object') {
|
||||
throw new ProjectFileError('Projektdatei enthält keine Szene.')
|
||||
}
|
||||
const s = scene as Record<string, unknown>
|
||||
if (!s.canvas || typeof s.canvas !== 'object') {
|
||||
throw new ProjectFileError('Szenendaten unvollständig: canvas fehlt.')
|
||||
}
|
||||
if (!Array.isArray(s.layers)) {
|
||||
throw new ProjectFileError('Szenendaten unvollständig: layers fehlt.')
|
||||
}
|
||||
if (!Array.isArray(s.assets)) {
|
||||
throw new ProjectFileError('Szenendaten unvollständig: assets fehlt.')
|
||||
}
|
||||
return scene as Scene
|
||||
}
|
||||
|
||||
export function downloadProject(scene: Scene, filename = 'projekt.animatr.json') {
|
||||
const source = serializeProject(scene)
|
||||
const blob = new Blob([source], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
export function pickProjectFile(): Promise<File | null> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.json,application/json'
|
||||
input.onchange = () => {
|
||||
const f = input.files?.[0]
|
||||
resolve(f ?? null)
|
||||
}
|
||||
input.oncancel = () => resolve(null)
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
export async function readProjectFile(file: File): Promise<Scene> {
|
||||
const text = await file.text()
|
||||
return parseProject(text)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Scene } from '@/types/scene'
|
||||
|
||||
const DB_NAME = 'animatr'
|
||||
const DB_VERSION = 1
|
||||
const STORE = 'projects'
|
||||
const MAX_RECENT = 8
|
||||
|
||||
export interface StoredProject {
|
||||
id: string
|
||||
name: string
|
||||
scene: Scene
|
||||
savedAt: number
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE, { keyPath: 'id' })
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB open failed'))
|
||||
})
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
function tx<T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
||||
return openDb().then(
|
||||
(db) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const t = db.transaction(STORE, mode)
|
||||
const s = t.objectStore(STORE)
|
||||
const req = fn(s)
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed'))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4)
|
||||
}
|
||||
|
||||
export async function listRecentProjects(): Promise<StoredProject[]> {
|
||||
try {
|
||||
const all = await tx<StoredProject[]>('readonly', (s) => s.getAll() as IDBRequest<StoredProject[]>)
|
||||
return (all ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => b.savedAt - a.savedAt)
|
||||
.slice(0, MAX_RECENT)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export interface SaveOptions {
|
||||
id?: string
|
||||
name?: string
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
export async function saveProjectToStore(
|
||||
scene: Scene,
|
||||
opts: SaveOptions = {},
|
||||
): Promise<StoredProject> {
|
||||
const record: StoredProject = {
|
||||
id: opts.id ?? uid(),
|
||||
name: opts.name?.trim() || defaultName(),
|
||||
scene: JSON.parse(JSON.stringify(scene)),
|
||||
savedAt: Date.now(),
|
||||
thumbnail: opts.thumbnail,
|
||||
}
|
||||
await tx('readwrite', (s) => s.put(record))
|
||||
return record
|
||||
}
|
||||
|
||||
export async function deleteStoredProject(id: string): Promise<void> {
|
||||
await tx('readwrite', (s) => s.delete(id))
|
||||
}
|
||||
|
||||
export async function getStoredProject(id: string): Promise<StoredProject | null> {
|
||||
try {
|
||||
const rec = await tx<StoredProject | undefined>('readonly', (s) =>
|
||||
s.get(id) as IDBRequest<StoredProject | undefined>,
|
||||
)
|
||||
return rec ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function defaultName(): string {
|
||||
const d = new Date()
|
||||
return `Projekt ${d.toLocaleDateString()} ${d.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
}
|
||||
|
||||
const LAST_OPEN_KEY = 'animatr:lastOpenedId'
|
||||
|
||||
export function setLastOpenedId(id: string | null): void {
|
||||
try {
|
||||
if (id === null) localStorage.removeItem(LAST_OPEN_KEY)
|
||||
else localStorage.setItem(LAST_OPEN_KEY, id)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastOpenedId(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LAST_OPEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface SnapOptions {
|
||||
enabled: boolean
|
||||
pxPerSec: number
|
||||
pixelThreshold: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
export function snapToCandidates(
|
||||
t: number,
|
||||
candidates: readonly number[],
|
||||
opts: SnapOptions,
|
||||
): number {
|
||||
if (!opts.enabled) return t
|
||||
if (opts.pxPerSec <= 0) return t
|
||||
const thresholdTime = opts.pixelThreshold / opts.pxPerSec
|
||||
let best = t
|
||||
let bestDist = thresholdTime
|
||||
for (const kt of candidates) {
|
||||
const d = Math.abs(kt - t)
|
||||
if (d <= bestDist) {
|
||||
bestDist = d
|
||||
best = kt
|
||||
}
|
||||
}
|
||||
return Math.max(0, Math.min(opts.duration, best))
|
||||
}
|
||||
Reference in new issue
Block a user