bak
This commit is contained in:
1 parent
94e9846c13
commit
e4aa0dce86
11 files changed
+2227
-32
No files matched your search
+198
-1
@@ -2,6 +2,8 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
PhExport,
|
||||
PhFolderOpen,
|
||||
PhFloppyDisk,
|
||||
PhGearSix,
|
||||
PhPause,
|
||||
PhPlay,
|
||||
@@ -15,11 +17,32 @@ import DocumentSettings from './components/DocumentSettings.vue'
|
||||
import ExportDialog from './components/ExportDialog.vue'
|
||||
import PropertiesPanel from './components/PropertiesPanel.vue'
|
||||
import Timeline from './components/Timeline.vue'
|
||||
import WelcomeDialog from './components/WelcomeDialog.vue'
|
||||
import TestOverlay from './devtools/TestOverlay.vue'
|
||||
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
||||
import {
|
||||
ProjectFileError,
|
||||
downloadProject,
|
||||
pickProjectFile,
|
||||
readProjectFile,
|
||||
} from './lib/projectFile'
|
||||
import {
|
||||
getLastOpenedId,
|
||||
listRecentProjects,
|
||||
saveProjectToStore,
|
||||
setLastOpenedId,
|
||||
type StoredProject,
|
||||
} from './lib/projectStore'
|
||||
import { useSceneStore } from './stores/scene'
|
||||
|
||||
const settingsOpen = ref(false)
|
||||
const exportOpen = ref(false)
|
||||
const testsOpen = ref(false)
|
||||
const welcomeOpen = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
const savedToast = ref<string | null>(null)
|
||||
const currentProjectId = ref<string | null>(null)
|
||||
const currentProjectName = ref<string | null>(null)
|
||||
|
||||
usePlaybackLoop()
|
||||
const store = useSceneStore()
|
||||
@@ -32,6 +55,87 @@ function stop() {
|
||||
store.pause()
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
const scene = JSON.parse(JSON.stringify(store.scene))
|
||||
let name = currentProjectName.value
|
||||
if (!name) {
|
||||
const suggested = `Projekt ${new Date().toLocaleDateString()}`
|
||||
const entered = prompt('Projektname?', suggested)
|
||||
if (entered === null) return
|
||||
name = entered.trim() || suggested
|
||||
}
|
||||
const rec = await saveProjectToStore(scene, {
|
||||
id: currentProjectId.value ?? undefined,
|
||||
name,
|
||||
})
|
||||
currentProjectId.value = rec.id
|
||||
currentProjectName.value = rec.name
|
||||
setLastOpenedId(rec.id)
|
||||
showSavedToast(`"${rec.name}" gespeichert`)
|
||||
}
|
||||
|
||||
function exportProjectFile() {
|
||||
const scene = JSON.parse(JSON.stringify(store.scene))
|
||||
const base = currentProjectName.value ?? 'animatr'
|
||||
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'animatr'
|
||||
downloadProject(scene, `${safe}.json`)
|
||||
}
|
||||
|
||||
async function importFromFile() {
|
||||
const file = await pickProjectFile()
|
||||
if (!file) return
|
||||
const hasContent = store.scene.layers.length > 0 || store.scene.assets.length > 0
|
||||
if (hasContent) {
|
||||
const ok = confirm(
|
||||
'Aktuelles Projekt wird durch die geladene Datei ersetzt. Ungespeicherte Änderungen gehen verloren. Fortfahren?',
|
||||
)
|
||||
if (!ok) return
|
||||
}
|
||||
try {
|
||||
const scene = await readProjectFile(file)
|
||||
store.loadScene(scene)
|
||||
currentProjectId.value = null
|
||||
currentProjectName.value = file.name.replace(/\.[^.]+$/, '')
|
||||
loadError.value = null
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof ProjectFileError ? err.message : (err as Error).message || String(err)
|
||||
loadError.value = msg
|
||||
setTimeout(() => {
|
||||
loadError.value = null
|
||||
}, 6000)
|
||||
}
|
||||
}
|
||||
|
||||
function openFromRecent(project: StoredProject) {
|
||||
store.loadScene(JSON.parse(JSON.stringify(project.scene)))
|
||||
currentProjectId.value = project.id
|
||||
currentProjectName.value = project.name
|
||||
setLastOpenedId(project.id)
|
||||
loadError.value = null
|
||||
}
|
||||
|
||||
function showSavedToast(msg: string) {
|
||||
savedToast.value = msg
|
||||
setTimeout(() => {
|
||||
if (savedToast.value === msg) savedToast.value = null
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
async function bootWelcome() {
|
||||
const recents = await listRecentProjects()
|
||||
if (recents.length > 0) {
|
||||
welcomeOpen.value = true
|
||||
const lastId = getLastOpenedId()
|
||||
if (lastId) {
|
||||
const last = recents.find((p) => p.id === lastId)
|
||||
if (last) {
|
||||
currentProjectName.value = last.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isEditable(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false
|
||||
const tag = el.tagName
|
||||
@@ -41,13 +145,21 @@ function isEditable(el: EventTarget | null): boolean {
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
|
||||
e.preventDefault()
|
||||
testsOpen.value = true
|
||||
return
|
||||
}
|
||||
if (e.code !== 'Space') return
|
||||
if (isEditable(e.target)) return
|
||||
e.preventDefault()
|
||||
store.togglePlay()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeyDown))
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
bootWelcome()
|
||||
})
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
||||
</script>
|
||||
|
||||
@@ -79,6 +191,15 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
||||
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<span v-if="currentProjectName" class="project-name" :title="currentProjectName">
|
||||
{{ currentProjectName }}
|
||||
</span>
|
||||
<button class="icon-btn" title="Zuletzt verwendete Projekte" @click="welcomeOpen = true">
|
||||
<PhFolderOpen :size="16" />
|
||||
</button>
|
||||
<button class="icon-btn" title="Projekt speichern (in Browser-Speicher)" @click="saveProject">
|
||||
<PhFloppyDisk :size="16" />
|
||||
</button>
|
||||
<button class="icon-btn" title="Document Settings" @click="settingsOpen = true">
|
||||
<PhGearSix :size="16" />
|
||||
</button>
|
||||
@@ -94,6 +215,23 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
||||
<footer class="timeline"><Timeline /></footer>
|
||||
<DocumentSettings v-model="settingsOpen" />
|
||||
<ExportDialog v-model="exportOpen" />
|
||||
<TestOverlay v-model="testsOpen" />
|
||||
<WelcomeDialog
|
||||
v-model="welcomeOpen"
|
||||
@open="openFromRecent"
|
||||
@new="() => { currentProjectId = null; currentProjectName = null }"
|
||||
@import="importFromFile"
|
||||
@export="exportProjectFile"
|
||||
/>
|
||||
<Transition name="fade">
|
||||
<div v-if="loadError" class="load-error" role="alert">
|
||||
<strong>Projekt konnte nicht geladen werden.</strong>
|
||||
<span>{{ loadError }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="fade">
|
||||
<div v-if="savedToast" class="saved-toast" role="status">{{ savedToast }}</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -259,4 +397,63 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
||||
border-top: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.load-error {
|
||||
position: fixed;
|
||||
bottom: 240px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 12px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.saved-toast {
|
||||
position: fixed;
|
||||
bottom: 240px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1500;
|
||||
padding: 8px 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 220px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim-strong);
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 140ms var(--ease);
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
+13
-31
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { PhArrowsSplit, PhCheck, PhCopy, PhMagnet, PhTrash } from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene'
|
||||
|
||||
interface TimelineTrack {
|
||||
@@ -51,44 +52,25 @@ const snapEnabled = ref(true)
|
||||
|
||||
const SNAP_PX = 8
|
||||
|
||||
function allKeyframeTimes(): number[] {
|
||||
function allKeyframeTimes(ignoreKeyframeId?: string): number[] {
|
||||
const out: number[] = []
|
||||
for (const layer of store.scene.layers) {
|
||||
for (const k of layer.keyframes) out.push(k.time)
|
||||
if (layer.positionKeyframes) for (const k of layer.positionKeyframes) out.push(k.time)
|
||||
if (layer.rotationKeyframes) for (const k of layer.rotationKeyframes) out.push(k.time)
|
||||
for (const k of layer.keyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
|
||||
if (layer.positionKeyframes)
|
||||
for (const k of layer.positionKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
|
||||
if (layer.rotationKeyframes)
|
||||
for (const k of layer.rotationKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function snapTime(t: number, ignoreKeyframeId?: string): number {
|
||||
if (!snapEnabled.value) return t
|
||||
const pps = effectivePxPerSec.value
|
||||
if (pps <= 0) return t
|
||||
const thresholdTime = SNAP_PX / pps
|
||||
let best = t
|
||||
let bestDist = thresholdTime
|
||||
const times = ignoreKeyframeId ? keyframeTimesExcept(ignoreKeyframeId) : allKeyframeTimes()
|
||||
for (const kt of times) {
|
||||
const d = Math.abs(kt - t)
|
||||
if (d <= bestDist) {
|
||||
bestDist = d
|
||||
best = kt
|
||||
}
|
||||
}
|
||||
return clamp(best, 0, duration.value)
|
||||
}
|
||||
|
||||
function keyframeTimesExcept(id: string): number[] {
|
||||
const out: number[] = []
|
||||
for (const layer of store.scene.layers) {
|
||||
for (const k of layer.keyframes) if (k.id !== id) out.push(k.time)
|
||||
if (layer.positionKeyframes)
|
||||
for (const k of layer.positionKeyframes) if (k.id !== id) out.push(k.time)
|
||||
if (layer.rotationKeyframes)
|
||||
for (const k of layer.rotationKeyframes) if (k.id !== id) out.push(k.time)
|
||||
}
|
||||
return out
|
||||
return snapToCandidates(t, allKeyframeTimes(ignoreKeyframeId), {
|
||||
enabled: snapEnabled.value,
|
||||
pxPerSec: effectivePxPerSec.value,
|
||||
pixelThreshold: SNAP_PX,
|
||||
duration: duration.value,
|
||||
})
|
||||
}
|
||||
|
||||
const TICK_CANDIDATES_SEC = [0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 300]
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
PhDownloadSimple,
|
||||
PhFileArrowUp,
|
||||
PhFolderOpen,
|
||||
PhPlus,
|
||||
PhTrash,
|
||||
PhX,
|
||||
} from '@phosphor-icons/vue'
|
||||
import {
|
||||
deleteStoredProject,
|
||||
listRecentProjects,
|
||||
type StoredProject,
|
||||
} from '@/lib/projectStore'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'open': [project: StoredProject]
|
||||
'new': []
|
||||
'import': []
|
||||
'export': []
|
||||
}>()
|
||||
|
||||
const recents = ref<StoredProject[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
try {
|
||||
recents.value = await listRecentProjects()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
const now = new Date()
|
||||
const sameDay =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate()
|
||||
if (sameDay) {
|
||||
return `heute ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
||||
}
|
||||
return d.toLocaleString([], {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function layerCount(p: StoredProject): number {
|
||||
return p.scene?.layers?.length ?? 0
|
||||
}
|
||||
|
||||
function openProject(p: StoredProject) {
|
||||
emit('open', p)
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function removeProject(e: MouseEvent, p: StoredProject) {
|
||||
e.stopPropagation()
|
||||
if (!confirm(`"${p.name}" wirklich löschen?`)) return
|
||||
await deleteStoredProject(p.id)
|
||||
await reload()
|
||||
}
|
||||
|
||||
function startFresh() {
|
||||
emit('new')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function importFile() {
|
||||
emit('import')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function exportFile() {
|
||||
emit('export')
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.modelValue) reload()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (v) reload()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="modelValue" class="backdrop" @mousedown.self="close">
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
<header class="hdr">
|
||||
<div>
|
||||
<h2>Willkommen bei Animatr</h2>
|
||||
<p class="sub">Zuletzt bearbeitete Projekte</p>
|
||||
</div>
|
||||
<button class="close" title="Schließen" @click="close">
|
||||
<PhX :size="14" weight="bold" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<div v-if="loading" class="empty">Laden…</div>
|
||||
<div v-else-if="!recents.length" class="empty">
|
||||
Keine gespeicherten Projekte vorhanden.
|
||||
</div>
|
||||
<ul v-else class="recents">
|
||||
<li
|
||||
v-for="p in recents"
|
||||
:key="p.id"
|
||||
class="recent"
|
||||
@click="openProject(p)"
|
||||
>
|
||||
<div class="thumb">
|
||||
<img v-if="p.thumbnail" :src="p.thumbnail" alt="" />
|
||||
<PhFolderOpen v-else :size="18" />
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="name">{{ p.name }}</div>
|
||||
<div class="sub-info">
|
||||
{{ layerCount(p) }} Layer · {{ formatDate(p.savedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="del"
|
||||
title="Projekt löschen"
|
||||
@click="removeProject($event, p)"
|
||||
>
|
||||
<PhTrash :size="13" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<footer class="ftr">
|
||||
<button class="ftr-btn" @click="startFresh">
|
||||
<PhPlus :size="13" weight="bold" />
|
||||
Neues Projekt
|
||||
</button>
|
||||
<button class="ftr-btn" @click="importFile">
|
||||
<PhFileArrowUp :size="13" />
|
||||
Datei importieren…
|
||||
</button>
|
||||
<button class="ftr-btn" @click="exportFile">
|
||||
<PhDownloadSimple :size="13" />
|
||||
Aktuelles exportieren
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(560px, calc(100% - 32px));
|
||||
max-height: calc(100vh - 64px);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hdr {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hdr h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.close {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 8px 12px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 26px 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.recents {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.recent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background 140ms var(--ease), border-color 140ms var(--ease);
|
||||
}
|
||||
|
||||
.recent:hover {
|
||||
background: var(--panel-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sub-info {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.del {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 140ms var(--ease);
|
||||
}
|
||||
|
||||
.recent:hover .del {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.del:hover {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ftr {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel-alt);
|
||||
}
|
||||
|
||||
.ftr-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ftr-btn:hover {
|
||||
background: var(--panel-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 160ms var(--ease);
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,685 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { PhArrowClockwise, PhCheck, PhClipboardText, PhWarning, PhX } from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { runAll, type TestResult } from './testRunner'
|
||||
import { registerAllTests } from './tests'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
const running = ref(false)
|
||||
const done = ref(0)
|
||||
const total = ref(0)
|
||||
const results = ref<TestResult[]>([])
|
||||
const startedAt = ref(0)
|
||||
const finishedAt = ref(0)
|
||||
const clipboardStatus = ref<'idle' | 'copied' | 'failed'>('idle')
|
||||
const currentSuite = ref('')
|
||||
const currentTest = ref('')
|
||||
|
||||
const progressPct = computed(() =>
|
||||
total.value > 0 ? Math.round((done.value / total.value) * 100) : 0,
|
||||
)
|
||||
const passedSoFar = computed(() => results.value.filter((r) => r.ok).length)
|
||||
const failedSoFar = computed(() => results.value.filter((r) => !r.ok).length)
|
||||
|
||||
function buildReport(): string {
|
||||
const failures = results.value.filter((r) => !r.ok)
|
||||
const pass = results.value.filter((r) => r.ok).length
|
||||
const fail = failures.length
|
||||
const durMs = Math.round(finishedAt.value - startedAt.value)
|
||||
const lines: string[] = []
|
||||
lines.push('# Animatr Test Report')
|
||||
lines.push('')
|
||||
lines.push(`**Run at:** ${new Date().toISOString()}`)
|
||||
lines.push(`**Duration:** ${durMs} ms`)
|
||||
lines.push(`**Result:** ${pass} passed, ${fail} failed / ${results.value.length} total`)
|
||||
lines.push('')
|
||||
lines.push('## Environment')
|
||||
lines.push(`- URL: ${location.href}`)
|
||||
lines.push(`- User Agent: ${navigator.userAgent}`)
|
||||
lines.push(`- Viewport: ${window.innerWidth}×${window.innerHeight}`)
|
||||
lines.push(`- DPR: ${window.devicePixelRatio}`)
|
||||
lines.push(`- Platform: ${navigator.platform}`)
|
||||
lines.push(`- Language: ${navigator.language}`)
|
||||
lines.push('')
|
||||
|
||||
if (fail === 0) {
|
||||
lines.push('All tests passed. ✅')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
lines.push('## Failures')
|
||||
lines.push('')
|
||||
for (const f of failures) {
|
||||
lines.push(`### [${f.suite}] › ${f.name}`)
|
||||
lines.push(`_${Math.round(f.durationMs)} ms_`)
|
||||
lines.push('')
|
||||
lines.push('```')
|
||||
lines.push(f.error ?? '(no message)')
|
||||
if (f.stack && f.stack.trim()) {
|
||||
lines.push('')
|
||||
lines.push(f.stack)
|
||||
}
|
||||
lines.push('```')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push('## Passing suites')
|
||||
const suites = new Map<string, { pass: number; total: number }>()
|
||||
for (const r of results.value) {
|
||||
const s = suites.get(r.suite) ?? { pass: 0, total: 0 }
|
||||
s.total++
|
||||
if (r.ok) s.pass++
|
||||
suites.set(r.suite, s)
|
||||
}
|
||||
for (const [name, s] of suites.entries()) {
|
||||
const mark = s.pass === s.total ? '✅' : '⚠️'
|
||||
lines.push(`- ${mark} ${name} — ${s.pass}/${s.total}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function copyReport() {
|
||||
const report = buildReport()
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(report)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = report
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
clipboardStatus.value = 'copied'
|
||||
} catch (err) {
|
||||
console.error('clipboard copy failed', err)
|
||||
clipboardStatus.value = 'failed'
|
||||
}
|
||||
setTimeout(() => {
|
||||
clipboardStatus.value = 'idle'
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const grouped = computed(() => {
|
||||
const map = new Map<string, TestResult[]>()
|
||||
for (const r of results.value) {
|
||||
const arr = map.get(r.suite) ?? []
|
||||
arr.push(r)
|
||||
map.set(r.suite, arr)
|
||||
}
|
||||
return Array.from(map.entries()).map(([suite, tests]) => ({
|
||||
suite,
|
||||
tests,
|
||||
ok: tests.every((t) => t.ok),
|
||||
}))
|
||||
})
|
||||
|
||||
const passed = computed(() => results.value.filter((r) => r.ok).length)
|
||||
const failed = computed(() => results.value.filter((r) => !r.ok).length)
|
||||
const totalMs = computed(() =>
|
||||
Math.round(results.value.reduce((a, r) => a + r.durationMs, 0)),
|
||||
)
|
||||
|
||||
async function run() {
|
||||
if (running.value) return
|
||||
running.value = true
|
||||
results.value = []
|
||||
done.value = 0
|
||||
total.value = 0
|
||||
currentSuite.value = ''
|
||||
currentTest.value = ''
|
||||
clipboardStatus.value = 'idle'
|
||||
startedAt.value = performance.now()
|
||||
const snapshot = JSON.parse(JSON.stringify(store.$state))
|
||||
try {
|
||||
results.value = await runAll(registerAllTests, {
|
||||
onProgress: (d, t) => {
|
||||
done.value = d
|
||||
total.value = t
|
||||
},
|
||||
onTestStart: (suiteName, testName) => {
|
||||
currentSuite.value = suiteName
|
||||
currentTest.value = testName
|
||||
},
|
||||
interTestDelayMs: 80,
|
||||
})
|
||||
} finally {
|
||||
store.$state = snapshot
|
||||
finishedAt.value = performance.now()
|
||||
running.value = false
|
||||
}
|
||||
if (results.value.some((r) => !r.ok)) {
|
||||
await copyReport()
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (!props.modelValue) return
|
||||
if (e.key === 'Escape' && !running.value) close()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
if (props.modelValue) run()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (v && !running.value) run()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="hud-pop">
|
||||
<div v-if="modelValue && running" class="hud" role="status" aria-live="polite">
|
||||
<div class="hud-top">
|
||||
<span class="hud-tag">Tests laufen</span>
|
||||
<span class="hud-counts">
|
||||
<span class="hud-count">{{ done }}/{{ total }}</span>
|
||||
<span class="hud-chip ok" v-if="passedSoFar">{{ passedSoFar }} ✓</span>
|
||||
<span class="hud-chip bad" v-if="failedSoFar">{{ failedSoFar }} ✗</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hud-current">
|
||||
<span class="hud-suite">{{ currentSuite || '…' }}</span>
|
||||
<span class="hud-sep">›</span>
|
||||
<span class="hud-test">{{ currentTest || '…' }}</span>
|
||||
</div>
|
||||
<div class="hud-bar">
|
||||
<div class="hud-bar-fill" :style="{ width: progressPct + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="modelValue && !running" class="overlay" @mousedown.self="close">
|
||||
<div class="panel" role="dialog" aria-modal="true">
|
||||
<header class="hdr">
|
||||
<div class="hdr-title">
|
||||
<span>Test Runner</span>
|
||||
<span v-if="results.length" class="hdr-status">
|
||||
<span class="ok-badge" :class="{ bad: failed > 0 }">
|
||||
{{ passed }}/{{ results.length }}
|
||||
</span>
|
||||
<span class="dim">{{ totalMs }} ms</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hdr-actions">
|
||||
<button
|
||||
class="tbtn"
|
||||
:disabled="!results.length"
|
||||
:title="clipboardStatus === 'copied' ? 'Report kopiert' : 'Report in Zwischenablage kopieren'"
|
||||
:class="{ copied: clipboardStatus === 'copied', failed: clipboardStatus === 'failed' }"
|
||||
@click="copyReport"
|
||||
>
|
||||
<PhClipboardText :size="14" />
|
||||
</button>
|
||||
<button class="tbtn" title="Erneut ausführen" @click="run">
|
||||
<PhArrowClockwise :size="14" />
|
||||
</button>
|
||||
<button class="tbtn" title="Schließen" @click="close">
|
||||
<PhX :size="14" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="clipboardStatus !== 'idle'" class="clip-banner" :class="clipboardStatus">
|
||||
<span v-if="clipboardStatus === 'copied'">
|
||||
Fehlerreport wurde in die Zwischenablage kopiert.
|
||||
</span>
|
||||
<span v-else>
|
||||
Kopieren fehlgeschlagen — Report manuell über das Clipboard-Icon auslösen.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div v-if="!results.length" class="empty">
|
||||
Keine Ergebnisse — „Erneut ausführen“ klicken oder <kbd>⌃⇧⌥T</kbd> drücken.
|
||||
</div>
|
||||
<div v-for="g in grouped" :key="g.suite" class="suite">
|
||||
<div class="suite-hdr" :class="{ bad: !g.ok }">
|
||||
<PhCheck v-if="g.ok" :size="12" weight="bold" class="icon ok" />
|
||||
<PhWarning v-else :size="12" weight="fill" class="icon bad" />
|
||||
<span class="suite-name">{{ g.suite }}</span>
|
||||
<span class="suite-count">
|
||||
{{ g.tests.filter((t) => t.ok).length }}/{{ g.tests.length }}
|
||||
</span>
|
||||
</div>
|
||||
<ul class="tests">
|
||||
<li v-for="t in g.tests" :key="t.name" class="test" :class="{ bad: !t.ok }">
|
||||
<span class="test-dot" :class="{ ok: t.ok, bad: !t.ok }"></span>
|
||||
<span class="test-name">{{ t.name }}</span>
|
||||
<span class="test-dur">{{ Math.round(t.durationMs) }}ms</span>
|
||||
<pre v-if="!t.ok" class="test-err">{{ t.error }}</pre>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="ftr">
|
||||
<span class="ftr-item">{{ results.length }} Tests</span>
|
||||
<span class="ftr-item ok" v-if="passed">{{ passed }} grün</span>
|
||||
<span class="ftr-item bad" v-if="failed">{{ failed }} rot</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: min(640px, 100%);
|
||||
background: var(--panel);
|
||||
border-left: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hdr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hdr-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hdr-status {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hdr-status.running {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ok-badge {
|
||||
padding: 2px 8px;
|
||||
background: rgba(71, 191, 130, 0.18);
|
||||
color: #47bf82;
|
||||
border-radius: 999px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ok-badge.bad {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hdr-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tbtn {
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.tbtn:hover:not(:disabled) {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tbtn.copied {
|
||||
background: rgba(71, 191, 130, 0.18);
|
||||
color: #47bf82;
|
||||
border-color: #47bf82;
|
||||
}
|
||||
|
||||
.tbtn.failed {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.clip-banner {
|
||||
padding: 8px 14px;
|
||||
font-size: 11px;
|
||||
background: rgba(71, 191, 130, 0.14);
|
||||
color: #47bf82;
|
||||
border-bottom: 1px solid rgba(71, 191, 130, 0.3);
|
||||
}
|
||||
|
||||
.clip-banner.failed {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-bottom-color: var(--danger);
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 120ms linear;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px 10px 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
padding: 20px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty kbd {
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.suite {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.suite-hdr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.suite-hdr.bad {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.suite-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.suite-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.icon.ok {
|
||||
color: #47bf82;
|
||||
}
|
||||
|
||||
.icon.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.tests {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px 0 0 14px;
|
||||
}
|
||||
|
||||
.test {
|
||||
display: grid;
|
||||
grid-template-columns: 12px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 3px 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim-strong);
|
||||
}
|
||||
|
||||
.test.bad {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.test-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.test-dot.ok {
|
||||
background: #47bf82;
|
||||
}
|
||||
|
||||
.test-dot.bad {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.test-dur {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.test-err {
|
||||
grid-column: 2 / -1;
|
||||
margin: 2px 0 4px;
|
||||
padding: 6px 8px;
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ftr {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.ftr-item.ok {
|
||||
color: #47bf82;
|
||||
}
|
||||
|
||||
.ftr-item.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 140ms var(--ease);
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hud {
|
||||
position: fixed;
|
||||
bottom: 240px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2000;
|
||||
width: min(520px, calc(100vw - 24px));
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 10px 12px 8px;
|
||||
pointer-events: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hud-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.hud-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.hud-tag::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: hud-pulse 900ms var(--ease) infinite;
|
||||
}
|
||||
|
||||
@keyframes hud-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.45; transform: scale(0.7); }
|
||||
}
|
||||
|
||||
.hud-counts {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hud-count {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hud-chip {
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hud-chip.ok {
|
||||
background: rgba(71, 191, 130, 0.18);
|
||||
color: #47bf82;
|
||||
}
|
||||
|
||||
.hud-chip.bad {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.hud-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
margin-bottom: 6px;
|
||||
min-height: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hud-suite {
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hud-sep {
|
||||
color: var(--text-dim);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hud-test {
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hud-bar {
|
||||
height: 3px;
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hud-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 120ms linear;
|
||||
}
|
||||
|
||||
.hud-pop-enter-active,
|
||||
.hud-pop-leave-active {
|
||||
transition: opacity 140ms var(--ease), transform 160ms var(--ease);
|
||||
}
|
||||
.hud-pop-enter-from,
|
||||
.hud-pop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
export interface TestResult {
|
||||
suite: string
|
||||
name: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
stack?: string
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
interface SuiteDef {
|
||||
name: string
|
||||
tests: { name: string; fn: () => void | Promise<void> }[]
|
||||
beforeEach?: () => void | Promise<void>
|
||||
afterEach?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const suites: SuiteDef[] = []
|
||||
let current: SuiteDef | null = null
|
||||
|
||||
export function suite(name: string, fn: () => void): void {
|
||||
const prev = current
|
||||
current = { name, tests: [] }
|
||||
try {
|
||||
fn()
|
||||
suites.push(current)
|
||||
} finally {
|
||||
current = prev
|
||||
}
|
||||
}
|
||||
|
||||
export function test(name: string, fn: () => void | Promise<void>): void {
|
||||
if (!current) throw new Error(`test("${name}") called outside of suite()`)
|
||||
current.tests.push({ name, fn })
|
||||
}
|
||||
|
||||
export function beforeEach(fn: () => void | Promise<void>): void {
|
||||
if (!current) throw new Error('beforeEach outside of suite()')
|
||||
current.beforeEach = fn
|
||||
}
|
||||
|
||||
export function afterEach(fn: () => void | Promise<void>): void {
|
||||
if (!current) throw new Error('afterEach outside of suite()')
|
||||
current.afterEach = fn
|
||||
}
|
||||
|
||||
class Expectation {
|
||||
constructor(private value: unknown) {}
|
||||
|
||||
toBe(expected: unknown): void {
|
||||
if (!Object.is(this.value, expected)) {
|
||||
throw new Error(`expected ${this.fmt(expected)}, got ${this.fmt(this.value)}`)
|
||||
}
|
||||
}
|
||||
|
||||
toEqual(expected: unknown): void {
|
||||
const a = JSON.stringify(this.value)
|
||||
const b = JSON.stringify(expected)
|
||||
if (a !== b) throw new Error(`expected ${b}, got ${a}`)
|
||||
}
|
||||
|
||||
toBeCloseTo(expected: number, precision = 4): void {
|
||||
const v = this.value as number
|
||||
const eps = Math.pow(10, -precision) / 2
|
||||
if (Math.abs(v - expected) > eps) {
|
||||
throw new Error(`expected ≈${expected} (p=${precision}), got ${v}`)
|
||||
}
|
||||
}
|
||||
|
||||
toBeTruthy(): void {
|
||||
if (!this.value) throw new Error(`expected truthy, got ${this.fmt(this.value)}`)
|
||||
}
|
||||
|
||||
toBeFalsy(): void {
|
||||
if (this.value) throw new Error(`expected falsy, got ${this.fmt(this.value)}`)
|
||||
}
|
||||
|
||||
toBeNull(): void {
|
||||
if (this.value !== null) throw new Error(`expected null, got ${this.fmt(this.value)}`)
|
||||
}
|
||||
|
||||
toBeDefined(): void {
|
||||
if (this.value === undefined) throw new Error('expected defined value, got undefined')
|
||||
}
|
||||
|
||||
toBeGreaterThan(n: number): void {
|
||||
if (!((this.value as number) > n)) throw new Error(`expected > ${n}, got ${this.value}`)
|
||||
}
|
||||
|
||||
toBeLessThan(n: number): void {
|
||||
if (!((this.value as number) < n)) throw new Error(`expected < ${n}, got ${this.value}`)
|
||||
}
|
||||
|
||||
toContain(sub: string): void {
|
||||
const s = this.value as string
|
||||
if (!s || !s.includes(sub)) {
|
||||
throw new Error(`expected string to contain ${JSON.stringify(sub)}`)
|
||||
}
|
||||
}
|
||||
|
||||
toHaveLength(n: number): void {
|
||||
const l = (this.value as { length: number }).length
|
||||
if (l !== n) throw new Error(`expected length ${n}, got ${l}`)
|
||||
}
|
||||
|
||||
private fmt(v: unknown): string {
|
||||
if (v === undefined) return 'undefined'
|
||||
try {
|
||||
return JSON.stringify(v)
|
||||
} catch {
|
||||
return String(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function expect(value: unknown): Expectation {
|
||||
return new Expectation(value)
|
||||
}
|
||||
|
||||
function cleanStack(stack: string | undefined): string | undefined {
|
||||
if (!stack) return undefined
|
||||
const lines = stack.split('\n')
|
||||
const filtered = lines.filter((l) => {
|
||||
if (l.includes('/testRunner.ts')) return false
|
||||
if (l.includes('/testRunner.js')) return false
|
||||
if (l.includes('node_modules')) return false
|
||||
return true
|
||||
})
|
||||
return filtered.slice(0, 10).join('\n')
|
||||
}
|
||||
|
||||
export interface RunOptions {
|
||||
onProgress?: (done: number, total: number) => void
|
||||
onTestStart?: (suite: string, name: string) => void
|
||||
interTestDelayMs?: number
|
||||
}
|
||||
|
||||
export async function runAll(
|
||||
register: () => void,
|
||||
opts: RunOptions = {},
|
||||
): Promise<TestResult[]> {
|
||||
suites.length = 0
|
||||
register()
|
||||
const total = suites.reduce((a, s) => a + s.tests.length, 0)
|
||||
const results: TestResult[] = []
|
||||
let done = 0
|
||||
const delay = Math.max(0, opts.interTestDelayMs ?? 0)
|
||||
for (const s of suites) {
|
||||
for (const t of s.tests) {
|
||||
opts.onTestStart?.(s.name, t.name)
|
||||
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
||||
const start = performance.now()
|
||||
try {
|
||||
if (s.beforeEach) await s.beforeEach()
|
||||
await t.fn()
|
||||
if (s.afterEach) await s.afterEach()
|
||||
results.push({
|
||||
suite: s.name,
|
||||
name: t.name,
|
||||
ok: true,
|
||||
durationMs: performance.now() - start,
|
||||
})
|
||||
} catch (err) {
|
||||
try {
|
||||
if (s.afterEach) await s.afterEach()
|
||||
} catch {
|
||||
/* ignore cleanup errors if test already failed */
|
||||
}
|
||||
const e = err as Error
|
||||
results.push({
|
||||
suite: s.name,
|
||||
name: t.name,
|
||||
ok: false,
|
||||
error: e.message || String(err),
|
||||
stack: cleanStack(e.stack),
|
||||
durationMs: performance.now() - start,
|
||||
})
|
||||
}
|
||||
done++
|
||||
opts.onProgress?.(done, total)
|
||||
if (delay > 0) await new Promise((r) => setTimeout(r, delay))
|
||||
else await new Promise((r) => setTimeout(r, 0))
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer, lerpColor } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
import {
|
||||
generateBundledScript,
|
||||
generateRuntimeScript,
|
||||
generateSceneScript,
|
||||
} from '@/lib/exportPlayer'
|
||||
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
|
||||
import { expect, suite, test } from './testRunner'
|
||||
|
||||
function makeFreshStore() {
|
||||
const s = useSceneStore()
|
||||
s.$reset()
|
||||
return s
|
||||
}
|
||||
|
||||
function tick(ms = 40): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms))
|
||||
}
|
||||
|
||||
export function registerAllTests(): void {
|
||||
suite('scene store — defaults', () => {
|
||||
test('starts with empty scene, playhead 0, not playing', () => {
|
||||
const s = makeFreshStore()
|
||||
expect(s.scene.layers).toHaveLength(0)
|
||||
expect(s.scene.assets).toHaveLength(0)
|
||||
expect(s.playhead).toBe(0)
|
||||
expect(s.playing).toBe(false)
|
||||
expect(s.scene.duration).toBe(10)
|
||||
expect(s.scene.fps).toBe(60)
|
||||
expect(s.selectedLayerId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — playhead', () => {
|
||||
test('setPlayhead clamps to [0, duration]', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.setPlayhead(-5)
|
||||
await tick()
|
||||
expect(s.playhead).toBe(0)
|
||||
s.setPlayhead(999)
|
||||
await tick()
|
||||
expect(s.playhead).toBe(s.scene.duration)
|
||||
s.setPlayhead(3.5)
|
||||
await tick()
|
||||
expect(s.playhead).toBe(3.5)
|
||||
})
|
||||
|
||||
test('setDuration pulls playhead back if past end', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.setPlayhead(9)
|
||||
await tick()
|
||||
s.setDuration(5)
|
||||
await tick()
|
||||
expect(s.playhead).toBe(5)
|
||||
})
|
||||
|
||||
test('setDuration clamps to min 0.1', () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(0)
|
||||
expect(s.scene.duration).toBe(0.1)
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — layers & selection', () => {
|
||||
test('addLayer with rect asset creates layer + initial keyframe + selects it', async () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'TestRect',
|
||||
width: 200,
|
||||
height: 100,
|
||||
fill: '#ff4455',
|
||||
})
|
||||
await tick()
|
||||
const layer = s.addLayer(asset.id)
|
||||
await tick()
|
||||
expect(s.scene.layers).toHaveLength(1)
|
||||
expect(s.selectedLayerId).toBe(layer.id)
|
||||
expect(layer.keyframes).toHaveLength(1)
|
||||
expect(layer.keyframes[0].time).toBe(0)
|
||||
expect(layer.offsetX).toBe(100)
|
||||
expect(layer.offsetY).toBe(50)
|
||||
expect(layer.base.fill).toBe('#ff4455')
|
||||
})
|
||||
|
||||
test('removeLayer deselects if it was selected', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'circle', name: 'TestCircle', radius: 40, fill: '#44aaff' })
|
||||
const l = s.addLayer(a.id)
|
||||
await tick()
|
||||
s.removeLayer(l.id)
|
||||
await tick()
|
||||
expect(s.scene.layers).toHaveLength(0)
|
||||
expect(s.selectedLayerId).toBeNull()
|
||||
})
|
||||
|
||||
test('selectLayer toggles with null', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'circle', name: 'SelC', radius: 30, fill: '#00cc88' })
|
||||
const l = s.addLayer(a.id)
|
||||
await tick()
|
||||
s.selectLayer(null)
|
||||
await tick()
|
||||
expect(s.selectedLayerId).toBeNull()
|
||||
s.selectLayer(l.id)
|
||||
await tick()
|
||||
expect(s.selectedLayerId).toBe(l.id)
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — keyframes', () => {
|
||||
test('upsertKeyframe adds new keyframe sorted by time', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Kfs', width: 80, height: 80, fill: '#ffa500' })
|
||||
const l = s.addLayer(a.id)
|
||||
await tick()
|
||||
s.upsertKeyframe(l.id, 2, { x: 600 })
|
||||
await tick()
|
||||
s.upsertKeyframe(l.id, 1, { x: 300 })
|
||||
await tick()
|
||||
const times = l.keyframes.map((k) => k.time)
|
||||
expect(times).toEqual([0, 1, 2])
|
||||
})
|
||||
|
||||
test('upsertKeyframe updates existing keyframe within epsilon', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Upd', width: 80, height: 80, fill: '#aa44ff' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 1, { x: 500 })
|
||||
await tick()
|
||||
const before = l.keyframes.length
|
||||
s.upsertKeyframe(l.id, 1.0000001, { x: 900 })
|
||||
await tick()
|
||||
expect(l.keyframes).toHaveLength(before)
|
||||
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 0.01)!
|
||||
expect(kf.props.x).toBe(900)
|
||||
})
|
||||
|
||||
test('setKeyframeTime clamps and sorts', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Clamp', width: 60, height: 60, fill: '#ff8844' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 3, { x: 400 })
|
||||
s.upsertKeyframe(l.id, 5, { x: 800 })
|
||||
await tick()
|
||||
const target = l.keyframes.find((k) => k.time === 5)!
|
||||
s.setKeyframeTime(l.id, target.id, -1)
|
||||
await tick()
|
||||
expect(target.time).toBe(0)
|
||||
const times = l.keyframes.map((k) => k.time).sort((a, b) => a - b)
|
||||
expect(times).toEqual(l.keyframes.map((k) => k.time))
|
||||
})
|
||||
|
||||
test('setKeyframeTime clamps to duration upper bound', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Dur', width: 60, height: 60, fill: '#44ffaa' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 2, { x: 400 })
|
||||
await tick()
|
||||
const k = l.keyframes[l.keyframes.length - 1]
|
||||
s.setKeyframeTime(l.id, k.id, 999)
|
||||
await tick()
|
||||
expect(k.time).toBe(s.scene.duration)
|
||||
})
|
||||
|
||||
test('removeKeyframe removes by id', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Rm', width: 50, height: 50, fill: '#ee4488' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 2, { x: 500 })
|
||||
await tick()
|
||||
const k = l.keyframes.find((k) => k.time === 2)!
|
||||
s.removeKeyframe(l.id, k.id)
|
||||
await tick()
|
||||
expect(l.keyframes.find((x) => x.id === k.id)).toBe(undefined)
|
||||
})
|
||||
|
||||
test('duplicateKeyframe creates offset copy', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Dup', width: 50, height: 50, fill: '#22aaee' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 1, { x: 400 })
|
||||
await tick()
|
||||
const src = l.keyframes.find((k) => k.time === 1)!
|
||||
const newId = s.duplicateKeyframe(l.id, src.id)
|
||||
await tick()
|
||||
expect(newId).toBeTruthy()
|
||||
const dup = l.keyframes.find((k) => k.id === newId)!
|
||||
expect(dup.props.x).toBe(400)
|
||||
expect(dup.time).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test('setKeyframeEasing updates easing field', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Ease', width: 50, height: 50, fill: '#88cc44' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 1, { x: 400 })
|
||||
await tick()
|
||||
const k = l.keyframes[l.keyframes.length - 1]
|
||||
s.setKeyframeEasing(l.id, k.id, 'easeInOut')
|
||||
expect(k.easing).toBe('easeInOut')
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — separated tracks', () => {
|
||||
test('setLayerSeparated splits x/y and rotation into dedicated tracks', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 60, height: 60, fill: '#ee66aa' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 1, { x: 400, y: 200, rotation: 45, opacity: 0.5 })
|
||||
await tick()
|
||||
s.setLayerSeparated(l.id, true)
|
||||
await tick()
|
||||
expect(l.separated).toBe(true)
|
||||
expect((l.positionKeyframes ?? []).length).toBeGreaterThan(0)
|
||||
expect((l.rotationKeyframes ?? []).length).toBeGreaterThan(0)
|
||||
for (const k of l.keyframes) {
|
||||
expect((k.props as Record<string, unknown>).x).toBe(undefined)
|
||||
expect((k.props as Record<string, unknown>).y).toBe(undefined)
|
||||
expect((k.props as Record<string, unknown>).rotation).toBe(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('setLayerSeparated(false) merges tracks back together', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Merge', width: 60, height: 60, fill: '#66ccee' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 1, { x: 400, y: 200, rotation: 45, opacity: 0.5 })
|
||||
await tick()
|
||||
s.setLayerSeparated(l.id, true)
|
||||
await tick()
|
||||
s.setLayerSeparated(l.id, false)
|
||||
await tick()
|
||||
expect(l.separated).toBe(false)
|
||||
expect(l.positionKeyframes).toBe(undefined)
|
||||
expect(l.rotationKeyframes).toBe(undefined)
|
||||
const at1 = l.keyframes.find((k) => Math.abs(k.time - 1) < 0.001)!
|
||||
expect((at1.props as Record<string, number>).x).toBe(400)
|
||||
expect((at1.props as Record<string, number>).rotation).toBe(45)
|
||||
})
|
||||
})
|
||||
|
||||
suite('interpolation — sampleLayer', () => {
|
||||
test('no keyframes → returns base', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||||
const l = s.addLayer(a.id)
|
||||
l.keyframes = []
|
||||
const p = sampleLayer(l, 0.5)
|
||||
expect(p.x).toBe(l.base.x)
|
||||
expect(p.y).toBe(l.base.y)
|
||||
})
|
||||
|
||||
test('linear midpoint is halfway', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||||
const l = s.addLayer(a.id)
|
||||
l.keyframes = [
|
||||
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
|
||||
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'linear' },
|
||||
]
|
||||
l.base = { ...l.base, x: 0, y: 0 }
|
||||
const p = sampleLayer(l, 1)
|
||||
expect(p.x).toBeCloseTo(50, 2)
|
||||
})
|
||||
|
||||
test('easeIn midpoint is below linear midpoint', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||||
const l = s.addLayer(a.id)
|
||||
l.base = { ...l.base, x: 0 }
|
||||
l.keyframes = [
|
||||
{ id: 'k1', time: 0, props: { x: 0 }, easing: 'linear' },
|
||||
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'easeIn' },
|
||||
]
|
||||
const p = sampleLayer(l, 1)
|
||||
expect(p.x).toBeLessThan(50)
|
||||
expect(p.x).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('clamps before first kf to first props, after last to last props', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' })
|
||||
const l = s.addLayer(a.id)
|
||||
l.base = { ...l.base, x: 0 }
|
||||
l.keyframes = [
|
||||
{ id: 'k1', time: 1, props: { x: 10 }, easing: 'linear' },
|
||||
{ id: 'k2', time: 2, props: { x: 20 }, easing: 'linear' },
|
||||
]
|
||||
expect(sampleLayer(l, 0).x).toBe(10)
|
||||
expect(sampleLayer(l, 5).x).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
suite('interpolation — color', () => {
|
||||
test('lerpColor 0 returns start', () => {
|
||||
expect(lerpColor('#000000', '#ffffff', 0)).toBe('#000000')
|
||||
})
|
||||
test('lerpColor 1 returns end', () => {
|
||||
expect(lerpColor('#000000', '#ffffff', 1)).toBe('#ffffff')
|
||||
})
|
||||
test('lerpColor 0.5 is around middle gray', () => {
|
||||
const mid = lerpColor('#000000', '#ffffff', 0.5)
|
||||
expect(mid).toBe('#808080')
|
||||
})
|
||||
})
|
||||
|
||||
suite('timeline snap', () => {
|
||||
test('disabled: returns t unchanged', () => {
|
||||
const r = snapToCandidates(1.7, [2.0], {
|
||||
enabled: false,
|
||||
pxPerSec: 100,
|
||||
pixelThreshold: 8,
|
||||
duration: 10,
|
||||
})
|
||||
expect(r).toBe(1.7)
|
||||
})
|
||||
|
||||
test('enabled: snaps to nearest candidate within threshold', () => {
|
||||
const r = snapToCandidates(1.95, [2.0, 5.0], {
|
||||
enabled: true,
|
||||
pxPerSec: 100,
|
||||
pixelThreshold: 8,
|
||||
duration: 10,
|
||||
})
|
||||
expect(r).toBe(2.0)
|
||||
})
|
||||
|
||||
test('enabled: leaves t alone if all candidates are outside threshold', () => {
|
||||
const r = snapToCandidates(1.0, [2.0], {
|
||||
enabled: true,
|
||||
pxPerSec: 100,
|
||||
pixelThreshold: 8,
|
||||
duration: 10,
|
||||
})
|
||||
expect(r).toBe(1.0)
|
||||
})
|
||||
|
||||
test('clamps result to [0, duration]', () => {
|
||||
const r = snapToCandidates(15, [], {
|
||||
enabled: true,
|
||||
pxPerSec: 100,
|
||||
pixelThreshold: 8,
|
||||
duration: 10,
|
||||
})
|
||||
expect(r).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
suite('export — runtime & scene split', () => {
|
||||
test('generateRuntimeScript exposes AnimatrPlayer', () => {
|
||||
const r = generateRuntimeScript()
|
||||
expect(r).toContain('window.AnimatrPlayer')
|
||||
expect(r).toContain('function mount')
|
||||
expect(r).toContain('__animatrPending')
|
||||
})
|
||||
|
||||
test('generateSceneScript embeds scene JSON and uses AnimatrPlayer', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'UniqRect77', width: 10, height: 10, fill: '#abc' })
|
||||
s.addLayer(a.id)
|
||||
const src = generateSceneScript(s.scene)
|
||||
expect(src).toContain('AnimatrPlayer.mount')
|
||||
expect(src).toContain('UniqRect77')
|
||||
expect(src).toContain('__animatrPending')
|
||||
})
|
||||
|
||||
test('scene script does NOT inline the mount implementation', () => {
|
||||
const s = makeFreshStore()
|
||||
const src = generateSceneScript(s.scene)
|
||||
expect(src.indexOf('function mount')).toBe(-1)
|
||||
})
|
||||
|
||||
test('generateBundledScript contains runtime + scene boot', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'BundlR', width: 10, height: 10, fill: '#123' })
|
||||
s.addLayer(a.id)
|
||||
const src = generateBundledScript(s.scene)
|
||||
expect(src).toContain('function mount')
|
||||
expect(src).toContain('AnimatrPlayer.mount')
|
||||
expect(src).toContain('BundlR')
|
||||
})
|
||||
})
|
||||
|
||||
suite('playback — transport', () => {
|
||||
test('togglePlay toggles playing state', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Play', width: 60, height: 60, fill: '#55cc66' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 2, { x: 500 })
|
||||
await tick()
|
||||
s.togglePlay()
|
||||
await tick()
|
||||
expect(s.playing).toBe(true)
|
||||
s.togglePlay()
|
||||
await tick()
|
||||
expect(s.playing).toBe(false)
|
||||
})
|
||||
|
||||
test('togglePlay rewinds if at end', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Rew', width: 60, height: 60, fill: '#cc5555' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 2, { x: 600 })
|
||||
s.setPlayhead(s.effectiveEnd)
|
||||
await tick()
|
||||
s.togglePlay()
|
||||
expect(s.playhead).toBe(0)
|
||||
expect(s.playing).toBe(true)
|
||||
s.pause()
|
||||
await tick()
|
||||
})
|
||||
|
||||
test('tick advances playhead while playing', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Tk', width: 60, height: 60, fill: '#9977dd' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 5, { x: 800 })
|
||||
s.play()
|
||||
s.tick(1)
|
||||
s.pause()
|
||||
expect(s.playhead).toBeCloseTo(1, 3)
|
||||
await tick()
|
||||
})
|
||||
|
||||
test('tick stops at effectiveEnd', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'End', width: 60, height: 60, fill: '#dd7799' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 2, { x: 400 })
|
||||
s.play()
|
||||
await tick()
|
||||
s.tick(10)
|
||||
await tick()
|
||||
expect(s.playing).toBe(false)
|
||||
expect(s.playhead).toBe(s.effectiveEnd)
|
||||
})
|
||||
})
|
||||
|
||||
suite('project file — save & load roundtrip', () => {
|
||||
test('serialize + parse roundtrip preserves scene', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'PF', width: 80, height: 60, fill: '#66ddaa' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 1, { x: 400, rotation: 30 })
|
||||
s.setDuration(7)
|
||||
await tick()
|
||||
const text = serializeProject(s.scene)
|
||||
const parsed = parseProject(text)
|
||||
expect(parsed.duration).toBe(7)
|
||||
expect(parsed.layers).toHaveLength(1)
|
||||
expect(parsed.assets).toHaveLength(1)
|
||||
expect(parsed.layers[0].keyframes.find((k) => k.time === 1)?.props.x).toBe(400)
|
||||
})
|
||||
|
||||
test('loadScene replaces store state visibly', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'L1', width: 80, height: 60, fill: '#aa6666' })
|
||||
s.addLayer(a.id)
|
||||
await tick()
|
||||
const dump = serializeProject(s.scene)
|
||||
s.$reset()
|
||||
await tick()
|
||||
s.loadScene(parseProject(dump))
|
||||
await tick()
|
||||
expect(s.scene.layers).toHaveLength(1)
|
||||
expect(s.scene.assets).toHaveLength(1)
|
||||
expect(s.selectedLayerId).toBeNull()
|
||||
expect(s.playhead).toBe(0)
|
||||
expect(s.playing).toBe(false)
|
||||
})
|
||||
|
||||
test('parseProject rejects non-JSON', () => {
|
||||
let caught: unknown = null
|
||||
try {
|
||||
parseProject('not json at all')
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect(caught instanceof ProjectFileError).toBe(true)
|
||||
})
|
||||
|
||||
test('parseProject rejects foreign JSON without magic', () => {
|
||||
let caught: unknown = null
|
||||
try {
|
||||
parseProject(JSON.stringify({ foo: 'bar' }))
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect(caught instanceof ProjectFileError).toBe(true)
|
||||
})
|
||||
|
||||
test('parseProject rejects missing scene fields', () => {
|
||||
let caught: unknown = null
|
||||
try {
|
||||
parseProject(JSON.stringify({ magic: 'animatr-project', fileVersion: 1, scene: {} }))
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect(caught instanceof ProjectFileError).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
suite('app smoke — live DOM', () => {
|
||||
test('app root, topbar, timeline, stage are mounted', () => {
|
||||
expect(document.querySelector('.app')).toBeTruthy()
|
||||
expect(document.querySelector('.topbar')).toBeTruthy()
|
||||
expect(document.querySelector('.timeline')).toBeTruthy()
|
||||
expect(document.querySelector('.stage')).toBeTruthy()
|
||||
})
|
||||
|
||||
test('timeline snap toggle button is present', () => {
|
||||
expect(document.querySelector('.snap-btn')).toBeTruthy()
|
||||
})
|
||||
|
||||
test('topbar has save & recents buttons', () => {
|
||||
const saveBtn = document.querySelector('button[title^="Projekt speichern"]')
|
||||
const recentsBtn = document.querySelector('button[title="Zuletzt verwendete Projekte"]')
|
||||
expect(saveBtn).toBeTruthy()
|
||||
expect(recentsBtn).toBeTruthy()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -363,6 +363,12 @@ export const useSceneStore = defineStore('scene', {
|
||||
layer.separated = false
|
||||
}
|
||||
},
|
||||
loadScene(scene: Scene) {
|
||||
this.scene = scene
|
||||
this.selectedLayerId = null
|
||||
this.playhead = 0
|
||||
this.playing = false
|
||||
},
|
||||
play() {
|
||||
if (this.playing) return
|
||||
this.playing = true
|
||||
|
||||
Reference in new issue
Block a user