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
|
||||
|
||||
@@ -19,4 +19,8 @@ der playhead soll kein gedrehtes quadrat sein so wie die keyframes sondern ein a
|
||||
baue eine frontent testing engine. mit shift+control+alt+t muss such die app dann selbst durchtesten. schreibe tests für die funktionen die wir bisher haben
|
||||
|
||||
|
||||
fehlerreport mit allen relevanten informationen muss automatisch in die zwischenablage kopiert werden. schreib dir da alles rein was du zum fixen brauchst
|
||||
|
||||
rechtsklick auf keyframe muss die auswahl von "still frame" bieten. von einem ein still frame aus findet keine tween animation statt. das objekt bleibt einfach stehen. der keyframe wird als dot dargestellt statt als diamant
|
||||
|
||||
text-objekte muss man neben rect und kreis zur verfügung haben
|
||||
Reference in new issue
Block a user