This commit is contained in:
th committed 2026-04-19 20:31:53 +02:00
1 parent e4aa0dce86
commit cc6b11e61d
23 files changed
+2047 -524

No files matched your search

+78 -103
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhArrowClockwise,
PhArrowCounterClockwise,
PhExport,
PhFolderOpen,
PhFloppyDisk,
@@ -15,10 +17,13 @@ import AssetSidebar from './components/AssetSidebar.vue'
import CanvasStage from './components/CanvasStage.vue'
import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.vue'
import FullscreenPreview from './components/FullscreenPreview.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 { IconButton, Toast } from './components/ui'
import { useHistory } from './composables/useHistory'
import { usePlaybackLoop } from './composables/usePlaybackLoop'
import {
ProjectFileError,
@@ -39,6 +44,7 @@ const settingsOpen = ref(false)
const exportOpen = ref(false)
const testsOpen = ref(false)
const welcomeOpen = ref(false)
const fullscreenOpen = ref(false)
const loadError = ref<string | null>(null)
const savedToast = ref<string | null>(null)
const currentProjectId = ref<string | null>(null)
@@ -46,6 +52,7 @@ const currentProjectName = ref<string | null>(null)
usePlaybackLoop()
const store = useSceneStore()
const history = useHistory()
function rewind() {
store.setPlayhead(0)
@@ -94,6 +101,7 @@ async function importFromFile() {
try {
const scene = await readProjectFile(file)
store.loadScene(scene)
history.resetTo(scene)
currentProjectId.value = null
currentProjectName.value = file.name.replace(/\.[^.]+$/, '')
loadError.value = null
@@ -108,7 +116,9 @@ async function importFromFile() {
}
function openFromRecent(project: StoredProject) {
store.loadScene(JSON.parse(JSON.stringify(project.scene)))
const cloned = JSON.parse(JSON.stringify(project.scene))
store.loadScene(cloned)
history.resetTo(cloned)
currentProjectId.value = project.id
currentProjectName.value = project.name
setLastOpenedId(project.id)
@@ -150,9 +160,28 @@ function onKeyDown(e: KeyboardEvent) {
testsOpen.value = true
return
}
const mod = e.ctrlKey || e.metaKey
if (mod && !isEditable(e.target)) {
const key = e.key.toLowerCase()
if (key === 'z' && !e.shiftKey) {
e.preventDefault()
history.undo()
return
}
if ((key === 'z' && e.shiftKey) || key === 'y') {
e.preventDefault()
history.redo()
return
}
}
if (e.code !== 'Space') return
if (isEditable(e.target)) return
e.preventDefault()
if (e.shiftKey) {
store.pause()
fullscreenOpen.value = true
return
}
store.togglePlay()
}
@@ -171,23 +200,47 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<PhSparkle :size="16" weight="fill" class="brand-icon" />
Animatr
</span>
<span class="hint">PoC</span>
</div>
<div class="transport">
<button class="tbtn" title="Zum Anfang" @click="rewind">
<PhSkipBack :size="14" weight="fill" />
</button>
<button
class="tbtn primary"
<IconButton
:icon="PhArrowCounterClockwise"
icon-weight="bold"
size="md"
:disabled="!history.canUndo.value"
title="Rückgängig (Ctrl+Z)"
@click="history.undo"
/>
<IconButton
:icon="PhArrowClockwise"
icon-weight="bold"
size="md"
:disabled="!history.canRedo.value"
title="Wiederholen (Ctrl+Shift+Z)"
@click="history.redo"
/>
<div class="tsep"></div>
<IconButton
:icon="PhSkipBack"
icon-weight="fill"
size="md"
title="Zum Anfang"
@click="rewind"
/>
<IconButton
:icon="store.playing ? PhPause : PhPlay"
icon-weight="fill"
size="md"
variant="primary"
:title="store.playing ? 'Pause (Space)' : 'Play (Space)'"
@click="store.togglePlay()"
>
<PhPause v-if="store.playing" :size="14" weight="fill" />
<PhPlay v-else :size="14" weight="fill" />
</button>
<button class="tbtn" title="Stop" @click="stop">
<PhStop :size="14" weight="fill" />
</button>
/>
<IconButton
:icon="PhStop"
icon-weight="fill"
size="md"
title="Stop"
@click="stop"
/>
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
</div>
<div class="topbar-right">
@@ -216,6 +269,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<DocumentSettings v-model="settingsOpen" />
<ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" />
<FullscreenPreview v-model="fullscreenOpen" />
<WelcomeDialog
v-model="welcomeOpen"
@open="openFromRecent"
@@ -223,15 +277,11 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
@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>
<Toast :visible="!!loadError" variant="error">
<strong>Projekt konnte nicht geladen werden.</strong>
<span>{{ loadError }}</span>
</Toast>
<Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" />
</div>
</template>
@@ -279,49 +329,17 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
color: var(--accent);
}
.hint {
color: var(--text-dim);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: 10px;
}
.transport {
display: flex;
gap: 4px;
align-items: center;
}
.tbtn {
width: 32px;
height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim-strong);
border-radius: var(--radius-sm);
}
.tbtn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.tbtn.primary {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.tbtn.primary:hover:not(:disabled) {
background: var(--accent-soft-strong);
color: var(--accent);
.tsep {
width: 1px;
height: 18px;
background: var(--border);
margin: 0 4px;
}
.time {
@@ -398,40 +416,6 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
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;
@@ -447,13 +431,4 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
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>
+18
View File
@@ -51,6 +51,20 @@ function addSampleCircle() {
store.addLayer(asset.id)
}
function addSampleText() {
const input = prompt('Text?', 'Hallo')
if (input === null) return
const text = input.trim() || 'Text'
const asset = store.addAsset({
kind: 'text',
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
text,
fontSize: 48,
fill: '#f5d76e',
})
store.addLayer(asset.id)
}
async function handleFileSelected(event: Event) {
uploadError.value = null
const input = event.target as HTMLInputElement
@@ -154,6 +168,10 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
<PhCircle :size="14" />
Circle
</button>
<button class="shape-btn" @click="addSampleText">
<PhTextT :size="14" />
Text
</button>
</div>
<input
+64 -28
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type Konva from 'konva'
import { PhCornersIn } from '@phosphor-icons/vue'
import { PhCornersIn, PhCrop } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { loadImage } from '@/lib/fileLoader'
@@ -17,6 +17,7 @@ const transformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
const zoom = ref(1)
const viewX = ref(0)
const viewY = ref(0)
const cropObjects = ref(false)
let didInitialFit = false
const ZOOM_MIN = 0.05
@@ -61,6 +62,18 @@ const viewConfig = computed(() => ({
scaleY: zoom.value,
}))
const clipGroupConfig = computed(() => {
const w = store.scene.canvas.width
const h = store.scene.canvas.height
const crop = cropObjects.value
return {
clipFunc: (ctx: CanvasRenderingContext2D) => {
if (crop) ctx.rect(0, 0, w, h)
else ctx.rect(-1e6, -1e6, 2e6, 2e6)
},
}
})
const backgroundConfig = computed(() => ({
x: 0,
y: 0,
@@ -115,6 +128,8 @@ interface RenderedLayer {
const rendered = computed<RenderedLayer[]>(() =>
store.scene.layers
.slice()
.reverse()
.map((layer) => {
const asset = assetMap.value.get(layer.assetId)
if (!asset) return null
@@ -513,33 +528,35 @@ onBeforeUnmount(() => {
<v-stage ref="stageRef" :config="stageConfig" @mousedown="handleStageMouseDown">
<v-layer>
<v-group :config="viewConfig">
<v-rect :config="backgroundConfig" />
<template v-for="item in rendered" :key="item.layer.id">
<v-rect
v-if="item.asset.kind === 'rect'"
:config="rectConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-circle
v-else-if="item.asset.kind === 'circle'"
:config="circleConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-text
v-else-if="item.asset.kind === 'text'"
:config="textConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-image
v-else-if="item.asset.kind === 'image' && imageConfig(item.layer, item.asset, item.props)"
:config="imageConfig(item.layer, item.asset, item.props)!"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
</template>
<v-group :config="clipGroupConfig">
<v-rect :config="backgroundConfig" />
<template v-for="item in rendered" :key="item.layer.id">
<v-rect
v-if="item.asset.kind === 'rect'"
:config="rectConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-circle
v-else-if="item.asset.kind === 'circle'"
:config="circleConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-text
v-else-if="item.asset.kind === 'text'"
:config="textConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-image
v-else-if="item.asset.kind === 'image' && imageConfig(item.layer, item.asset, item.props)"
:config="imageConfig(item.layer, item.asset, item.props)!"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
</template>
</v-group>
<v-rect :config="canvasBorderConfig" />
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
<v-group
@@ -573,6 +590,15 @@ onBeforeUnmount(() => {
<button class="zc-btn" title="An Viewport anpassen" @click="fitToContainer">
<PhCornersIn :size="13" />
</button>
<div class="zc-sep"></div>
<button
class="zc-btn crop-btn"
:class="{ active: cropObjects }"
:title="cropObjects ? 'Alles zeigen' : 'Nur Bühnenausschnitt zeigen (crop)'"
@click="cropObjects = !cropObjects"
>
<PhCrop :size="13" />
</button>
</div>
<div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div>
@@ -637,6 +663,16 @@ onBeforeUnmount(() => {
font-weight: 500;
}
.zc-btn.active {
background: var(--accent-soft);
color: var(--accent);
}
.zc-btn.active:hover:not(:disabled) {
background: var(--accent-soft-strong);
color: var(--accent);
}
.zc-label {
min-width: 54px;
height: 24px;
+17 -109
View File
@@ -1,18 +1,12 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhClock,
PhFrameCorners,
PhMoon,
PhPalette,
PhSun,
PhX,
} from '@phosphor-icons/vue'
import { computed, ref } from 'vue'
import { PhClock, PhFrameCorners, PhMoon, PhPalette, PhSun } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
import { Modal } from '@/components/ui'
const props = defineProps<{ modelValue: boolean }>()
defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
@@ -69,32 +63,20 @@ function close() {
emit('update:modelValue', false)
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.modelValue) close()
}
onMounted(() => document.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
function applyTheme(t: Theme) {
setTheme(t)
}
</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="dialog-header">
<h2>Settings</h2>
<button class="close-btn" title="Schließen" @click="close">
<PhX :size="14" weight="bold" />
</button>
</header>
<div class="body">
<nav class="tabs">
<Modal
:model-value="modelValue"
title="Settings"
size="lg"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="body">
<nav class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
@@ -196,72 +178,15 @@ function applyTheme(t: Theme) {
</div>
<p class="hint">Gespeichert im Browser, beim nächsten Öffnen wiederverwendet.</p>
</section>
</div>
</div>
<footer class="dialog-footer">
<button class="primary" @click="close">Fertig</button>
</footer>
</div>
</div>
</Transition>
</Teleport>
</div>
<template #footer>
<button class="primary" @click="close">Fertig</button>
</template>
</Modal>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 900;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.dialog {
width: min(620px, calc(100% - 32px));
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 10px;
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
max-height: calc(100vh - 64px);
overflow: hidden;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.dialog-header h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.close-btn {
width: 26px;
height: 26px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
}
.close-btn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.body {
display: flex;
min-height: 340px;
@@ -508,14 +433,6 @@ input[type='number'] {
color: var(--accent);
}
.dialog-footer {
padding: 12px 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.primary {
padding: 6px 14px;
background: var(--accent-soft);
@@ -528,13 +445,4 @@ input[type='number'] {
.primary:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 140ms var(--ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+95 -168
View File
@@ -1,19 +1,19 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import {
PhCode,
PhDownloadSimple,
PhEngine,
PhFileHtml,
PhFilmSlate,
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadFromServer } from '@/lib/serverExport'
import { Modal } from '@/components/ui'
const props = defineProps<{ modelValue: boolean }>()
defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
@@ -34,17 +34,17 @@ function sceneClone() {
function exportScene() {
downloadSceneExport(sceneClone())
close()
emit('update:modelValue', false)
}
function exportRuntime() {
downloadRuntime()
close()
emit('update:modelValue', false)
}
function exportHtml() {
downloadExampleHtml(sceneClone())
close()
emit('update:modelValue', false)
}
async function exportWebm() {
@@ -111,174 +111,110 @@ function dismissError() {
progress.value = null
}
const isLocked = () => !!progress.value && !progress.value.error
const isLocked = computed(() => !!progress.value && !progress.value.error)
function close() {
if (isLocked()) return
emit('update:modelValue', false)
function onUpdate(v: boolean) {
if (!v && isLocked.value) return
emit('update:modelValue', v)
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.modelValue) close()
}
onMounted(() => document.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</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="dialog-header">
<h2>Export</h2>
<button class="close-btn" :disabled="isLocked()" title="Schließen" @click="close">
<PhX :size="14" weight="bold" />
</button>
</header>
<div v-if="!progress" class="options">
<button class="opt" @click="exportRuntime">
<div class="opt-icon"><PhEngine :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
animatr-runtime.js
<span class="badge">1× pro Seite</span>
</div>
<div class="opt-desc">Engine — einmal einbinden, beliebig viele Szenen teilen sich die Runtime</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportScene">
<div class="opt-icon"><PhCode :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
animation.js
<span class="badge">Szene</span>
</div>
<div class="opt-desc">Nur Szenen-Daten — benötigt geladene Runtime</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportHtml">
<div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="opt-text">
<div class="opt-title">Beispiel HTML</div>
<div class="opt-desc">Standalone HTML — Browser öffnen und fertig</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportMp4Server">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
MP4 Video
<span class="badge accent">Server · Frame-exakt</span>
</div>
<div class="opt-desc">H.264, beliebige Auflösung — benötigt Render-Server</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" :disabled="!videoSupported" @click="exportWebm">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
WebM Video
<span class="badge">Browser · Realtime</span>
</div>
<div class="opt-desc">
{{ videoSupported ? 'Ohne Server — Aufnahme in Echtzeit' : 'Browser unterstützt MediaRecorder nicht' }}
</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<Modal
:model-value="modelValue"
title="Export"
size="md"
:dismissable="!isLocked"
:close-on-backdrop="!isLocked"
:close-on-esc="!isLocked"
@update:model-value="onUpdate"
>
<div v-if="!progress" class="options">
<button class="opt" @click="exportRuntime">
<div class="opt-icon"><PhEngine :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
animatr-runtime.js
<span class="badge">1× pro Seite</span>
</div>
<div class="opt-desc">Engine — einmal einbinden, beliebig viele Szenen teilen sich die Runtime</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<div v-else-if="!progress.error" class="rendering">
<div class="r-label">
<PhFilmSlate :size="14" />
<span class="r-text">{{ progress.label }}</span>
<span class="r-percent">{{ Math.round(progress.progress * 100) }}%</span>
</div>
<div class="r-bar">
<div class="r-bar-fill" :style="{ width: progress.progress * 100 + '%' }"></div>
</div>
<div class="r-actions">
<button class="secondary" @click="cancel">Abbrechen</button>
</div>
<button class="opt" @click="exportScene">
<div class="opt-icon"><PhCode :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
animation.js
<span class="badge">Szene</span>
</div>
<div class="opt-desc">Nur Szenen-Daten — benötigt geladene Runtime</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<div v-else class="error-view">
<div class="error-title">Export fehlgeschlagen</div>
<div class="error-msg">{{ progress.error }}</div>
<div class="error-actions">
<button class="primary" @click="dismissError">Zurück</button>
</div>
<button class="opt" @click="exportHtml">
<div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="opt-text">
<div class="opt-title">Beispiel HTML</div>
<div class="opt-desc">Standalone HTML — Browser öffnen und fertig</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportMp4Server">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
MP4 Video
<span class="badge accent">Server · Frame-exakt</span>
</div>
<div class="opt-desc">H.264, beliebige Auflösung — benötigt Render-Server</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" :disabled="!videoSupported" @click="exportWebm">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
WebM Video
<span class="badge">Browser · Realtime</span>
</div>
<div class="opt-desc">
{{ videoSupported ? 'Ohne Server — Aufnahme in Echtzeit' : 'Browser unterstützt MediaRecorder nicht' }}
</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
</div>
<div v-else-if="!progress.error" class="rendering">
<div class="r-label">
<PhFilmSlate :size="14" />
<span class="r-text">{{ progress.label }}</span>
<span class="r-percent">{{ Math.round(progress.progress * 100) }}%</span>
</div>
</Transition>
</Teleport>
<div class="r-bar">
<div class="r-bar-fill" :style="{ width: progress.progress * 100 + '%' }"></div>
</div>
<div class="r-actions">
<button class="secondary" @click="cancel">Abbrechen</button>
</div>
</div>
<div v-else class="error-view">
<div class="error-title">Export fehlgeschlagen</div>
<div class="error-msg">{{ progress.error }}</div>
<div class="error-actions">
<button class="primary" @click="dismissError">Zurück</button>
</div>
</div>
</Modal>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 900;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.dialog {
width: min(520px, calc(100% - 32px));
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 10px;
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
max-height: calc(100vh - 64px);
overflow: hidden;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.dialog-header h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.close-btn {
width: 26px;
height: 26px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
}
.close-btn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.options {
display: flex;
flex-direction: column;
@@ -466,13 +402,4 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
.error-actions .primary:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 140ms var(--ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+458
View File
@@ -0,0 +1,458 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const canvasEl = ref<HTMLCanvasElement | null>(null)
const frameEl = ref<HTMLDivElement | null>(null)
const rootEl = ref<HTMLDivElement | null>(null)
const progressTrackEl = ref<HTMLDivElement | null>(null)
const viewportSize = ref({ w: 0, h: 0 })
const images = ref<Record<string, HTMLImageElement>>({})
const localTime = ref(0)
const playing = ref(false)
const showControls = ref(false)
const scrubbing = ref(false)
const HOVER_ZONE_PX = 160
let isClosing = false
const aspect = computed(
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`,
)
const sceneEnd = computed(() => store.effectiveEnd)
let raf = 0
let lastFrame = 0
let ro: ResizeObserver | null = null
function resize() {
if (!canvasEl.value || !frameEl.value) return
const rect = frameEl.value.getBoundingClientRect()
viewportSize.value = { w: rect.width, h: rect.height }
const dpr = window.devicePixelRatio || 1
canvasEl.value.width = Math.max(1, Math.round(rect.width * dpr))
canvasEl.value.height = Math.max(1, Math.round(rect.height * dpr))
draw()
}
function draw() {
const canvas = canvasEl.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
const lw = store.scene.canvas.width
const lh = store.scene.canvas.height
const cw = viewportSize.value.w
const ch = viewportSize.value.h
if (!cw || !ch) return
const scale = Math.min(cw / lw, ch / lh)
const ox = (cw - lw * scale) / 2
const oy = (ch - lh * scale) / 2
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, cw, ch)
ctx.save()
ctx.translate(ox, oy)
ctx.scale(scale, scale)
drawSceneFrame(ctx, store.scene, localTime.value, images.value)
ctx.restore()
}
function loop(now: number) {
if (!props.modelValue) return
if (playing.value) {
const end = sceneEnd.value
const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000
if (end > 0) {
let next = localTime.value + dt
if (next >= end) next = 0
localTime.value = next
}
}
lastFrame = now
draw()
raf = requestAnimationFrame(loop)
}
function startLoop() {
cancelAnimationFrame(raf)
lastFrame = 0
raf = requestAnimationFrame(loop)
}
function stopLoop() {
cancelAnimationFrame(raf)
raf = 0
}
async function startPreview() {
localTime.value = store.playhead
playing.value = true
showControls.value = false
images.value = await loadSceneImages(store.scene)
resize()
startLoop()
await requestBrowserFullscreen()
}
function stopPreview() {
stopLoop()
playing.value = false
showControls.value = false
exitBrowserFullscreen()
}
async function requestBrowserFullscreen() {
const el = rootEl.value
if (!el) return
if (document.fullscreenElement === el) return
try {
await el.requestFullscreen?.()
} catch {
/* user gesture missing or not supported — gracefully keep overlay */
}
}
function exitBrowserFullscreen() {
if (document.fullscreenElement) {
isClosing = true
document.exitFullscreen?.().catch(() => {
/* ignore */
})
}
}
function onFullscreenChange() {
if (!props.modelValue) return
if (document.fullscreenElement === rootEl.value) return
if (isClosing) {
isClosing = false
return
}
close()
}
function onRootMouseMove(e: MouseEvent) {
const nearBottom = e.clientY > window.innerHeight - HOVER_ZONE_PX
if (nearBottom || scrubbing.value) showControls.value = true
else showControls.value = false
}
function onRootMouseLeave() {
if (!scrubbing.value) showControls.value = false
}
const progressPct = computed(() => {
const end = sceneEnd.value
if (!end) return 0
return Math.max(0, Math.min(1, localTime.value / end)) * 100
})
function seekFromEvent(e: PointerEvent) {
const el = progressTrackEl.value
if (!el) return
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const end = sceneEnd.value
localTime.value = ratio * end
draw()
}
function onProgressPointerDown(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
scrubbing.value = true
seekFromEvent(e)
}
function onProgressPointerMove(e: PointerEvent) {
if (!scrubbing.value) return
seekFromEvent(e)
}
function onProgressPointerUp(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
scrubbing.value = false
}
function close() {
emit('update:modelValue', false)
}
function toggle() {
playing.value = !playing.value
}
function rewind() {
localTime.value = 0
playing.value = true
}
function onKeyDown(e: KeyboardEvent) {
if (!props.modelValue) return
if (e.key === 'Escape') {
e.preventDefault()
close()
return
}
if (e.code === 'Space') {
e.preventDefault()
toggle()
}
}
watch(
() => props.modelValue,
(v) => {
if (v) startPreview()
else stopPreview()
},
)
watch(
() => [store.scene.canvas.width, store.scene.canvas.height],
() => {
if (props.modelValue) resize()
},
)
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
document.addEventListener('fullscreenchange', onFullscreenChange)
if (props.modelValue) startPreview()
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('fullscreenchange', onFullscreenChange)
stopLoop()
exitBrowserFullscreen()
if (ro) ro.disconnect()
ro = null
})
watch(frameEl, (el, _old, onCleanup) => {
if (ro) ro.disconnect()
ro = null
if (el) {
ro = new ResizeObserver(() => resize())
ro.observe(el)
}
onCleanup(() => {
if (ro) ro.disconnect()
ro = null
})
})
const timeLabel = computed(() => {
const end = sceneEnd.value || 0
return `${localTime.value.toFixed(2)}s / ${end.toFixed(2)}s`
})
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="modelValue"
ref="rootEl"
class="fs-root"
:class="{ 'controls-visible': showControls || scrubbing }"
@mousemove="onRootMouseMove"
@mouseleave="onRootMouseLeave"
@dblclick.self="close"
>
<div ref="frameEl" class="fs-frame" :style="{ aspectRatio: aspect }">
<canvas ref="canvasEl" />
</div>
<div class="fs-controls" @mousedown.stop>
<div
ref="progressTrackEl"
class="fs-progress"
@pointerdown="onProgressPointerDown"
@pointermove="onProgressPointerMove"
@pointerup="onProgressPointerUp"
@pointercancel="onProgressPointerUp"
>
<div class="fs-progress-track">
<div class="fs-progress-fill" :style="{ width: progressPct + '%' }"></div>
</div>
<div class="fs-progress-handle" :style="{ left: progressPct + '%' }"></div>
</div>
<div class="fs-toolbar">
<button class="fs-btn" :title="playing ? 'Pause' : 'Play'" @click="toggle">
<PhPause v-if="playing" :size="15" weight="fill" />
<PhPlay v-else :size="15" weight="fill" />
</button>
<button class="fs-btn" title="Zum Anfang" @click="rewind">
<PhSkipBack :size="15" weight="fill" />
</button>
<span class="fs-time">{{ timeLabel }}</span>
<div class="fs-spacer"></div>
<button class="fs-btn close" title="Schließen (Esc)" @click="close">
<PhX :size="15" weight="bold" />
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.fs-root {
position: fixed;
inset: 0;
z-index: 2500;
background: #000;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.fs-frame {
width: min(100vw, 100vh * (16 / 9));
max-width: 100%;
max-height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.fs-frame canvas {
display: block;
width: 100%;
height: 100%;
}
.fs-controls {
position: absolute;
left: 50%;
bottom: 24px;
transform: translateX(-50%) translateY(12px);
width: min(720px, calc(100vw - 48px));
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px 10px;
background: rgba(20, 20, 24, 0.92);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
opacity: 0;
transition: opacity 180ms ease, transform 180ms ease;
pointer-events: none;
z-index: 10;
}
.fs-root.controls-visible .fs-controls {
opacity: 1;
transform: translateX(-50%) translateY(0);
pointer-events: auto;
}
.fs-progress {
position: relative;
height: 18px;
display: flex;
align-items: center;
cursor: pointer;
touch-action: none;
user-select: none;
}
.fs-progress-track {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
overflow: hidden;
}
.fs-progress-fill {
height: 100%;
background: var(--accent, #6ea8ff);
border-radius: 999px;
transition: width 60ms linear;
}
.fs-progress-handle {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
margin-left: -6px;
margin-top: -6px;
background: #fff;
border: 2px solid var(--accent, #6ea8ff);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
pointer-events: none;
}
.fs-toolbar {
display: flex;
align-items: center;
gap: 6px;
}
.fs-spacer {
flex: 1;
}
.fs-btn {
width: 30px;
height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.85);
border-radius: 50%;
cursor: pointer;
}
.fs-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.fs-btn.close:hover {
background: rgba(255, 85, 85, 0.2);
color: #ff7a7a;
}
.fs-time {
font-variant-numeric: tabular-nums;
font-size: 11px;
color: rgba(255, 255, 255, 0.65);
padding: 0 6px;
min-width: 96px;
text-align: center;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 160ms var(--ease, ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+9
View File
@@ -28,6 +28,11 @@ const selectedAsset = computed(() => {
return store.scene.assets.find((a) => a.id === selected.value!.assetId) ?? null
})
const textAsset = computed(() => {
const a = selectedAsset.value
return a && a.kind === 'text' ? a : null
})
const canFill = computed(() => {
const a = selectedAsset.value
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
@@ -98,6 +103,10 @@ function removeKeyframeHere() {
<label>Scale Y<input type="number" step="0.1" v-model.number="scaleY" /></label>
</div>
<label>Rotation<input type="number" v-model.number="rotation" /></label>
<template v-if="textAsset">
<label>Text<input type="text" v-model="textAsset.text" /></label>
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
</template>
<label v-if="canFill" class="color-label">
Color
<div class="color-row">
+211 -8
View File
@@ -1,6 +1,14 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhArrowsSplit, PhCheck, PhCopy, PhMagnet, PhTrash } from '@phosphor-icons/vue'
import {
PhArrowsSplit,
PhCheck,
PhCopy,
PhDotsSixVertical,
PhMagnet,
PhPauseCircle,
PhTrash,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
@@ -13,6 +21,7 @@ interface TimelineTrack {
kind: 'main' | 'position' | 'rotation'
label: string
isParent: boolean
layerIndex: number
}
const store = useSceneStore()
@@ -208,7 +217,7 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
const tracks = computed<TimelineTrack[]>(() => {
const out: TimelineTrack[] = []
for (const layer of store.scene.layers) {
store.scene.layers.forEach((layer, layerIndex) => {
if (!layer.separated) {
out.push({
layerId: layer.id,
@@ -217,6 +226,7 @@ const tracks = computed<TimelineTrack[]>(() => {
kind: 'main',
label: layer.name,
isParent: true,
layerIndex,
})
} else {
out.push({
@@ -226,6 +236,7 @@ const tracks = computed<TimelineTrack[]>(() => {
kind: 'main',
label: layer.name,
isParent: true,
layerIndex,
})
out.push({
layerId: layer.id,
@@ -234,6 +245,7 @@ const tracks = computed<TimelineTrack[]>(() => {
kind: 'position',
label: 'Position',
isParent: false,
layerIndex,
})
out.push({
layerId: layer.id,
@@ -242,12 +254,30 @@ const tracks = computed<TimelineTrack[]>(() => {
kind: 'rotation',
label: 'Rotation',
isParent: false,
layerIndex,
})
}
}
})
return out
})
function isDropBefore(track: TimelineTrack): boolean {
return (
dropIndex.value !== null &&
track.isParent &&
dropIndex.value === track.layerIndex &&
dragLayerId.value !== track.layerId
)
}
function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined): boolean {
if (dropIndex.value === null) return false
if (dragLayerId.value === track.layerId) return false
if (dropIndex.value !== track.layerIndex + 1) return false
if (nextTrack && nextTrack.layerIndex === track.layerIndex) return false
return true
}
interface KfDrag {
layerId: string
keyframeId: string
@@ -305,6 +335,7 @@ interface KfSegment {
fromTime: number
toTime: number
easing: Easing
held: boolean
}
function segmentsOf(kfs: Keyframe[]): KfSegment[] {
@@ -319,6 +350,7 @@ function segmentsOf(kfs: Keyframe[]): KfSegment[] {
fromTime: a.time,
toTime: b.time,
easing: b.easing,
held: !!a.hold,
})
}
return out
@@ -346,6 +378,8 @@ interface KfMenuState {
y: number
currentEasing: Easing
isFirst: boolean
isLast: boolean
isHeld: boolean
}
const menu = ref<KfMenuState | null>(null)
@@ -371,10 +405,18 @@ function onKeyframeContextMenu(
y: e.clientY,
currentEasing: kf.easing,
isFirst: sorted[0]?.id === kf.id,
isLast: sorted[sorted.length - 1]?.id === kf.id,
isHeld: !!kf.hold,
}
store.selectLayer(layerId)
}
function toggleHold() {
if (!menu.value) return
store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
menu.value = null
}
function applyEasing(easing: Easing) {
if (!menu.value) return
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
@@ -411,6 +453,56 @@ interface TrackMenuState {
y: number
separated: boolean
}
const dragLayerId = ref<string | null>(null)
const dropIndex = ref<number | null>(null)
function onTrackDragStart(e: DragEvent, layerId: string) {
if (!e.dataTransfer) return
dragLayerId.value = layerId
e.dataTransfer.effectAllowed = 'move'
try {
e.dataTransfer.setData('text/plain', layerId)
} catch {
/* Safari older versions */
}
}
function onTrackDragOver(e: DragEvent, layerId: string) {
if (!dragLayerId.value) return
if (!e.dataTransfer) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const midY = rect.top + rect.height / 2
const index = store.scene.layers.findIndex((l) => l.id === layerId)
if (index < 0) return
dropIndex.value = e.clientY < midY ? index : index + 1
}
function onTrackDragLeave() {
/* dropIndex updates on next dragover, keep as-is to avoid flicker */
}
function onTrackDrop(e: DragEvent) {
e.preventDefault()
const srcId = dragLayerId.value
const target = dropIndex.value
dragLayerId.value = null
dropIndex.value = null
if (!srcId || target === null) return
const from = store.scene.layers.findIndex((l) => l.id === srcId)
if (from < 0) return
let to = target
if (from < to) to -= 1
store.moveLayer(from, to)
}
function onTrackDragEnd() {
dragLayerId.value = null
dropIndex.value = null
}
const trackMenu = ref<TrackMenuState | null>(null)
const trackMenuRef = ref<HTMLDivElement | null>(null)
@@ -574,7 +666,13 @@ onBeforeUnmount(() => {
:class="{
'sub-track': !track.isParent,
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: track.isParent && dragLayerId === track.layerId,
}"
@dragover="track.isParent ? onTrackDragOver($event, track.layerId) : null"
@drop="track.isParent ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave"
>
<span
class="track-name"
@@ -583,9 +681,17 @@ onBeforeUnmount(() => {
parent: track.isParent,
sub: !track.isParent,
}"
:draggable="track.isParent"
@click="store.selectLayer(track.layerId)"
@contextmenu="track.isParent ? onTrackContextMenu($event, track.layerId) : null"
@dragstart="track.isParent ? onTrackDragStart($event, track.layerId) : null"
@dragend="onTrackDragEnd"
>
<PhDotsSixVertical
v-if="track.isParent"
:size="12"
class="drag-handle"
/>
<PhArrowsSplit
v-if="track.isParent && track.layer.separated"
:size="11"
@@ -605,15 +711,20 @@ onBeforeUnmount(() => {
v-for="seg in segmentsOf(track.kfs)"
:key="seg.id"
class="kf-seg"
:class="`easing-${seg.easing}`"
:class="[seg.held ? 'held' : `easing-${seg.easing}`]"
:style="segmentStyle(seg)"
/>
<span
v-for="kf in track.kfs"
:key="kf.id"
class="kf"
:class="{ hold: kf.hold }"
:style="{ left: pxFromTime(kf.time) + 'px' }"
:title="`Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`"
:title="
kf.hold
? `Still Frame ${formatKfTime(kf.time)} — hält bis zum nächsten Keyframe`
: `Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`
"
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id)"
@pointermove="onKeyframePointerMove"
@pointerup="onKeyframePointerUp"
@@ -641,18 +752,37 @@ onBeforeUnmount(() => {
v-for="opt in EASING_OPTIONS"
:key="opt.value"
class="kf-menu-item"
:class="{ active: menu.currentEasing === opt.value }"
:disabled="menu.isFirst"
:class="{ active: menu.currentEasing === opt.value && !menu.isHeld }"
:disabled="menu.isFirst || menu.isHeld"
@click="applyEasing(opt.value)"
>
<span class="check">
<PhCheck v-if="menu.currentEasing === opt.value" :size="12" weight="bold" />
<PhCheck
v-if="menu.currentEasing === opt.value && !menu.isHeld"
:size="12"
weight="bold"
/>
</span>
{{ opt.label }}
</button>
<div v-if="menu.isFirst" class="kf-menu-note">Erster Keyframe — Easing ohne Effekt</div>
<div v-else-if="menu.isHeld" class="kf-menu-note">Still Frame — Easing deaktiviert</div>
</div>
<div class="kf-menu-divider"></div>
<button
class="kf-menu-item"
:class="{ active: menu.isHeld }"
:disabled="menu.isLast"
:title="menu.isLast ? 'Letzter Keyframe — Still Frame ohne Effekt' : ''"
@click="toggleHold"
>
<span class="check">
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
</span>
<PhPauseCircle :size="12" />
Still Frame
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" />
Duplizieren
@@ -872,6 +1002,32 @@ onBeforeUnmount(() => {
height: 30px;
border-bottom: 1px solid var(--border);
transition: background 140ms var(--ease);
position: relative;
}
.track.dragging {
opacity: 0.5;
}
.track.drop-before::before,
.track.drop-after::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
z-index: 5;
pointer-events: none;
box-shadow: 0 0 0 1px rgba(110, 168, 255, 0.35);
}
.track.drop-before::before {
top: -1px;
}
.track.drop-after::after {
bottom: -1px;
}
.track.inactive .track-lane {
@@ -951,6 +1107,24 @@ onBeforeUnmount(() => {
flex-shrink: 0;
}
.drag-handle {
color: var(--text-dim);
margin-right: 4px;
flex-shrink: 0;
cursor: grab;
opacity: 0;
transition: opacity 140ms var(--ease);
}
.track-name:hover .drag-handle,
.track-name.selected .drag-handle {
opacity: 0.7;
}
.drag-handle:active {
cursor: grabbing;
}
.sub-indent {
color: var(--text-dim);
margin-right: 4px;
@@ -1020,6 +1194,17 @@ onBeforeUnmount(() => {
opacity: 0.95;
}
.kf-seg.held {
background: repeating-linear-gradient(
90deg,
var(--text-dim) 0,
var(--text-dim) 2px,
transparent 2px,
transparent 5px
);
opacity: 0.7;
}
.kf {
position: absolute;
top: 50%;
@@ -1046,6 +1231,24 @@ onBeforeUnmount(() => {
cursor: grabbing;
}
.kf.hold {
width: 15px;
height: 15px;
margin-left: -7.5px;
margin-top: -7.5px;
transform: none;
border-radius: 50%;
background: var(--panel);
border: 2px solid var(--accent);
box-shadow: none;
}
.kf.hold:hover {
background: var(--panel);
border-color: #fff;
transform: scale(1.2);
}
.empty {
color: var(--text-dim);
font-style: italic;
+30 -103
View File
@@ -6,13 +6,13 @@ import {
PhFolderOpen,
PhPlus,
PhTrash,
PhX,
} from '@phosphor-icons/vue'
import {
deleteStoredProject,
listRecentProjects,
type StoredProject,
} from '@/lib/projectStore'
import { Modal } from '@/components/ui'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{
@@ -101,21 +101,18 @@ watch(
</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">
<Modal
:model-value="modelValue"
size="md"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="hdr-text">
<h2>Willkommen bei Animatr</h2>
<p class="sub">Zuletzt bearbeitete Projekte</p>
</div>
</template>
<div class="body">
<div v-if="loading" class="empty">Laden…</div>
<div v-else-if="!recents.length" class="empty">
Keine gespeicherten Projekte vorhanden.
@@ -146,61 +143,26 @@ watch(
</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>
</div>
<template #footer>
<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>
</template>
</Modal>
</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 {
.hdr-text h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
@@ -212,24 +174,6 @@ watch(
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;
@@ -334,14 +278,6 @@ watch(
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;
@@ -358,13 +294,4 @@ watch(
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>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import type { Component } from 'vue'
type Variant = 'default' | 'ghost' | 'primary' | 'danger'
type Size = 'xs' | 'sm' | 'md'
const props = withDefaults(
defineProps<{
icon?: Component
iconSize?: number
iconWeight?: 'regular' | 'bold' | 'fill'
size?: Size
variant?: Variant
active?: boolean
disabled?: boolean
rounded?: boolean
title?: string
}>(),
{
iconWeight: 'regular',
size: 'sm',
variant: 'default',
active: false,
disabled: false,
rounded: false,
},
)
const iconPx: Record<Size, number> = { xs: 12, sm: 14, md: 15 }
</script>
<template>
<button
type="button"
class="icon-btn"
:class="[`size-${size}`, `variant-${variant}`, { active, rounded }]"
:disabled="disabled"
:title="title"
>
<slot>
<component
v-if="icon"
:is="icon"
:size="iconSize ?? iconPx[size]"
:weight="iconWeight"
/>
</slot>
</button>
</template>
<style scoped>
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: var(--radius-sm);
transition: background 140ms var(--ease), color 140ms var(--ease),
border-color 140ms var(--ease);
}
.icon-btn.rounded {
border-radius: 50%;
}
.icon-btn.size-xs {
width: 22px;
height: 20px;
}
.icon-btn.size-sm {
width: 30px;
height: 26px;
}
.icon-btn.size-md {
width: 32px;
height: 30px;
}
.icon-btn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.icon-btn.variant-ghost {
border-color: transparent;
}
.icon-btn.variant-primary {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.icon-btn.variant-primary:hover:not(:disabled) {
background: var(--accent-soft-strong);
color: var(--accent);
}
.icon-btn.variant-danger:hover:not(:disabled) {
background: var(--danger-soft);
color: var(--danger);
border-color: var(--danger);
}
.icon-btn.active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.icon-btn.active:hover:not(:disabled) {
background: var(--accent-soft-strong);
color: var(--accent);
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { PhX } from '@phosphor-icons/vue'
type Size = 'sm' | 'md' | 'lg'
const props = withDefaults(
defineProps<{
modelValue: boolean
title?: string
size?: Size
closeOnBackdrop?: boolean
closeOnEsc?: boolean
dismissable?: boolean
}>(),
{
size: 'md',
closeOnBackdrop: true,
closeOnEsc: true,
dismissable: true,
},
)
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
function close() {
if (!props.dismissable) return
emit('update:modelValue', false)
}
function onBackdropMouseDown(e: MouseEvent) {
if (!props.closeOnBackdrop) return
if (e.target === e.currentTarget) close()
}
function onKeyDown(e: KeyboardEvent) {
if (!props.modelValue) return
if (!props.closeOnEsc) return
if (e.key === 'Escape') {
e.preventDefault()
close()
}
}
onMounted(() => document.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</script>
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="modelValue" class="backdrop" @mousedown="onBackdropMouseDown">
<div class="dialog" :class="`size-${size}`" role="dialog" aria-modal="true">
<header v-if="title || $slots.header" class="hdr">
<slot name="header">
<h2>{{ title }}</h2>
</slot>
<button
v-if="dismissable"
class="close"
:disabled="!dismissable"
title="Schließen"
@click="close"
>
<PhX :size="14" weight="bold" />
</button>
</header>
<div class="body">
<slot />
</div>
<footer v-if="$slots.footer" class="ftr">
<slot name="footer" />
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 900;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.dialog {
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 10px;
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
max-height: calc(100vh - 64px);
overflow: hidden;
}
.dialog.size-sm {
width: min(420px, calc(100% - 32px));
}
.dialog.size-md {
width: min(560px, calc(100% - 32px));
}
.dialog.size-lg {
width: min(720px, calc(100% - 32px));
}
.hdr {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 12px;
}
.hdr :deep(h2) {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.close {
width: 26px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.close:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.body {
overflow: auto;
flex: 1 1 auto;
min-height: 0;
}
.ftr {
padding: 12px 16px;
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 140ms var(--ease);
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
type Variant = 'info' | 'success' | 'error'
type Position = 'bottom' | 'top'
withDefaults(
defineProps<{
visible: boolean
variant?: Variant
position?: Position
title?: string
message?: string
}>(),
{
variant: 'info',
position: 'bottom',
},
)
</script>
<template>
<Teleport to="body">
<Transition name="toast-fade">
<div
v-if="visible"
class="toast"
:class="[`variant-${variant}`, `position-${position}`]"
role="status"
>
<slot>
<strong v-if="title">{{ title }}</strong>
<span v-if="message">{{ message }}</span>
</slot>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.toast {
position: fixed;
left: 50%;
transform: translateX(-50%);
z-index: 1500;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
border-radius: var(--radius);
box-shadow: var(--shadow-md);
font-size: 12px;
max-width: 520px;
pointer-events: auto;
}
.toast.position-bottom {
bottom: 240px;
}
.toast.position-top {
top: 64px;
}
.toast.variant-info {
background: var(--panel);
border: 1px solid var(--border-strong);
color: var(--text);
}
.toast.variant-success {
background: rgba(71, 191, 130, 0.14);
border: 1px solid #47bf82;
color: #47bf82;
}
.toast.variant-error {
background: var(--danger-soft);
border: 1px solid var(--danger);
color: var(--danger);
}
.toast-fade-enter-active,
.toast-fade-leave-active {
transition: opacity 140ms var(--ease);
}
.toast-fade-enter-from,
.toast-fade-leave-to {
opacity: 0;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
export { default as IconButton } from './IconButton.vue'
export { default as Modal } from './Modal.vue'
export { default as Toast } from './Toast.vue'
+144
View File
@@ -0,0 +1,144 @@
import { computed, onBeforeUnmount, onMounted, ref, watch, type ComputedRef } from 'vue'
import { useSceneStore } from '@/stores/scene'
import type { Scene } from '@/types/scene'
export const HISTORY_MAX = 100
export const HISTORY_DEBOUNCE_MS = 400
type SceneStore = ReturnType<typeof useSceneStore>
export interface HistoryApi {
undo: () => void
redo: () => void
canUndo: ComputedRef<boolean>
canRedo: ComputedRef<boolean>
resetTo: (scene?: Scene) => void
commitNow: () => void
start: () => void
stop: () => void
}
export interface CreateHistoryOptions {
max?: number
debounceMs?: number
}
export function createHistory(store: SceneStore, opts: CreateHistoryOptions = {}): HistoryApi {
const max = opts.max ?? HISTORY_MAX
const debounceMs = opts.debounceMs ?? HISTORY_DEBOUNCE_MS
const stack = ref<string[]>([])
const index = ref(-1)
let suspend = false
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let unsub: (() => void) | null = null
const snap = () => JSON.stringify(store.scene)
function pushNow() {
if (suspend) return
const next = snap()
if (stack.value[index.value] === next) return
stack.value = stack.value.slice(0, index.value + 1)
stack.value.push(next)
if (stack.value.length > max) {
const overflow = stack.value.length - max
stack.value.splice(0, overflow)
index.value -= overflow
}
index.value = stack.value.length - 1
}
function schedulePush() {
if (suspend) return
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = null
pushNow()
}, debounceMs)
}
function apply(json: string) {
suspend = true
store.scene = JSON.parse(json)
store.setPlayhead(store.playhead)
if (store.selectedLayerId && !store.scene.layers.some((l) => l.id === store.selectedLayerId)) {
store.selectLayer(null)
}
setTimeout(() => {
suspend = false
}, 0)
}
function flushPending() {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
pushNow()
}
}
function undo() {
flushPending()
if (index.value <= 0) return
index.value -= 1
apply(stack.value[index.value])
}
function redo() {
if (index.value >= stack.value.length - 1) return
index.value += 1
apply(stack.value[index.value])
}
function resetTo(scene?: Scene) {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
const json = scene ? JSON.stringify(scene) : snap()
stack.value = [json]
index.value = 0
}
const canUndo = computed(() => index.value > 0)
const canRedo = computed(() => index.value < stack.value.length - 1)
function start() {
stack.value = [snap()]
index.value = 0
unsub = watch(
() => store.scene,
() => schedulePush(),
{ deep: true, flush: 'post' },
)
}
function stop() {
if (unsub) unsub()
unsub = null
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
return {
undo,
redo,
canUndo,
canRedo,
resetTo,
commitNow: pushNow,
start,
stop,
}
}
export function useHistory(): HistoryApi {
const store = useSceneStore()
const api = createHistory(store)
onMounted(api.start)
onBeforeUnmount(api.stop)
return api
}
+488
View File
@@ -1,3 +1,4 @@
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
@@ -7,8 +8,48 @@ import {
generateSceneScript,
} from '@/lib/exportPlayer'
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
import { createHistory } from '@/composables/useHistory'
import {
deleteStoredProject,
getStoredProject,
listRecentProjects,
saveProjectToStore,
} from '@/lib/projectStore'
import Modal from '@/components/ui/Modal.vue'
import IconButton from '@/components/ui/IconButton.vue'
import Toast from '@/components/ui/Toast.vue'
import { expect, suite, test } from './testRunner'
interface MountedHandle {
host: HTMLDivElement
app: ReturnType<typeof createApp>
unmount: () => void
}
function mountComponent(
component: Component,
props: Record<string, unknown>,
slots?: Record<string, Slot>,
): MountedHandle {
const host = document.createElement('div')
host.setAttribute('data-test-host', '')
document.body.appendChild(host)
const app = createApp({
render() {
return h(component, props, slots)
},
})
app.mount(host)
return {
host,
app,
unmount() {
app.unmount()
host.remove()
},
}
}
function makeFreshStore() {
const s = useSceneStore()
s.$reset()
@@ -96,6 +137,43 @@ export function registerAllTests(): void {
expect(s.selectedLayerId).toBeNull()
})
test('addLayer inserts at the top (index 0)', async () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'First', width: 40, height: 40, fill: '#ff4444' })
const a2 = s.addAsset({ kind: 'rect', name: 'Second', width: 40, height: 40, fill: '#44ff44' })
const l1 = s.addLayer(a1.id)
await tick()
const l2 = s.addLayer(a2.id)
await tick()
expect(s.scene.layers[0].id).toBe(l2.id)
expect(s.scene.layers[1].id).toBe(l1.id)
})
test('moveLayer reorders layers', async () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 40, height: 40, fill: '#ff4444' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 40, height: 40, fill: '#44ff44' })
const a3 = s.addAsset({ kind: 'rect', name: 'C', width: 40, height: 40, fill: '#4444ff' })
const l1 = s.addLayer(a1.id)
const l2 = s.addLayer(a2.id)
const l3 = s.addLayer(a3.id)
await tick()
// Insertion at top → starting order: [l3, l2, l1]
s.moveLayer(0, 2)
await tick()
expect(s.scene.layers[0].id).toBe(l2.id)
expect(s.scene.layers[1].id).toBe(l1.id)
expect(s.scene.layers[2].id).toBe(l3.id)
})
test('moveLayer ignores identical from/to', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
s.moveLayer(0, 0)
expect(s.scene.layers[0].id).toBe(l.id)
})
test('selectLayer toggles with null', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'SelC', radius: 30, fill: '#00cc88' })
@@ -192,6 +270,21 @@ export function registerAllTests(): void {
expect(dup.time).toBeGreaterThan(1)
})
test('setKeyframeHold toggles hold flag', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Hold', width: 50, height: 50, fill: '#ddaa44' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 400 })
await tick()
const k = l.keyframes[l.keyframes.length - 1]
s.setKeyframeHold(l.id, k.id, true)
await tick()
expect(k.hold).toBe(true)
s.setKeyframeHold(l.id, k.id, false)
await tick()
expect(k.hold).toBe(undefined)
})
test('setKeyframeEasing updates easing field', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Ease', width: 50, height: 50, fill: '#88cc44' })
@@ -280,6 +373,21 @@ export function registerAllTests(): void {
expect(p.x).toBeGreaterThan(0)
})
test('hold keyframe holds value until next keyframe', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'H', 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', hold: true },
{ id: 'k2', time: 2, props: { x: 100 }, easing: 'linear' },
]
expect(sampleLayer(l, 0).x).toBe(0)
expect(sampleLayer(l, 0.5).x).toBe(0)
expect(sampleLayer(l, 1.5).x).toBe(0)
expect(sampleLayer(l, 2).x).toBe(100)
})
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' })
@@ -503,6 +611,381 @@ export function registerAllTests(): void {
})
})
suite('history — undo/redo', () => {
test('canUndo starts false; becomes true after a committed mutation', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
expect(h.canUndo.value).toBe(false)
const a = s.addAsset({ kind: 'rect', name: 'H1', width: 10, height: 10, fill: '#000' })
s.addLayer(a.id)
await tick(60)
expect(h.canUndo.value).toBe(true)
h.stop()
})
test('undo reverts a mutation, redo reapplies', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'H2', width: 10, height: 10, fill: '#000' })
s.addLayer(a.id)
await tick(60)
expect(s.scene.layers).toHaveLength(1)
h.undo()
await tick(20)
expect(s.scene.layers).toHaveLength(0)
expect(h.canRedo.value).toBe(true)
h.redo()
await tick(20)
expect(s.scene.layers).toHaveLength(1)
h.stop()
})
test('rapid mutations within debounce window collapse into one step', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 50 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'H3', width: 10, height: 10, fill: '#000' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 1, { x: 100 })
s.upsertKeyframe(l.id, 1, { x: 200 })
s.upsertKeyframe(l.id, 1, { x: 300 })
await tick(120)
h.undo()
await tick(20)
expect(s.scene.layers).toHaveLength(0)
h.stop()
})
test('mutation after undo drops the redo branch', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'H4', width: 10, height: 10, fill: '#000' })
s.addLayer(a.id)
await tick(40)
h.undo()
await tick(20)
expect(h.canRedo.value).toBe(true)
const b = s.addAsset({ kind: 'circle', name: 'H4c', radius: 10, fill: '#fff' })
s.addLayer(b.id)
await tick(40)
expect(h.canRedo.value).toBe(false)
h.stop()
})
test('resetTo clears history and seeds initial snapshot', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
const a = s.addAsset({ kind: 'rect', name: 'H5', width: 10, height: 10, fill: '#000' })
s.addLayer(a.id)
await tick(40)
expect(h.canUndo.value).toBe(true)
h.resetTo(s.scene)
expect(h.canUndo.value).toBe(false)
expect(h.canRedo.value).toBe(false)
h.stop()
})
test('undo clamps playhead when target scene has shorter duration', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10 })
h.start()
s.setDuration(3)
await tick(40)
s.setDuration(10)
s.setPlayhead(8)
await tick(40)
h.undo()
await tick(20)
expect(s.scene.duration).toBe(3)
expect(s.playhead).toBe(3)
h.stop()
})
test('max history cap drops oldest entries', async () => {
const s = makeFreshStore()
const h = createHistory(s, { debounceMs: 10, max: 3 })
h.start()
s.setDuration(2)
await tick(30)
s.setDuration(3)
await tick(30)
s.setDuration(4)
await tick(30)
s.setDuration(5)
await tick(30)
h.undo()
h.undo()
h.undo()
await tick(20)
expect(s.scene.duration).toBeGreaterThan(2)
h.stop()
})
})
suite('ui — Modal', () => {
test('renders nothing when modelValue is false', async () => {
const handle = mountComponent(Modal, { modelValue: false, title: 'X' })
await nextTick()
expect(document.querySelector('[role="dialog"]')).toBeNull()
handle.unmount()
})
test('teleports backdrop + dialog to body when open, renders title', async () => {
const handle = mountComponent(Modal, { modelValue: true, title: 'Hallo Modal' })
await nextTick()
const dialog = document.querySelector('[role="dialog"]') as HTMLElement | null
expect(dialog).toBeTruthy()
expect(dialog!.textContent?.includes('Hallo Modal')).toBe(true)
handle.unmount()
})
test('Esc triggers update:modelValue false', async () => {
let latest: boolean | null = null
const handle = mountComponent(Modal, {
modelValue: true,
title: 'X',
'onUpdate:modelValue': (v: boolean) => {
latest = v
},
})
await nextTick()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(latest).toBe(false)
handle.unmount()
})
test('Esc does nothing when closeOnEsc is false', async () => {
let latest: boolean | null = null
const handle = mountComponent(Modal, {
modelValue: true,
title: 'X',
closeOnEsc: false,
'onUpdate:modelValue': (v: boolean) => {
latest = v
},
})
await nextTick()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(latest).toBeNull()
handle.unmount()
})
test('dismissable=false hides close button and swallows Esc', async () => {
let latest: boolean | null = null
const handle = mountComponent(Modal, {
modelValue: true,
title: 'Busy',
dismissable: false,
'onUpdate:modelValue': (v: boolean) => {
latest = v
},
})
await nextTick()
const btn = document.querySelector('[role="dialog"] header button')
expect(btn).toBeNull()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(latest).toBeNull()
handle.unmount()
})
test('renders footer slot content', async () => {
const handle = mountComponent(
Modal,
{ modelValue: true, title: 'X' },
{
footer: () => [h('button', { class: 'my-footer-btn' }, 'Fertig')],
},
)
await nextTick()
const btn = document.querySelector('.my-footer-btn')
expect(btn).toBeTruthy()
expect(btn!.textContent).toBe('Fertig')
handle.unmount()
})
})
suite('ui — IconButton', () => {
test('renders a button element', async () => {
const handle = mountComponent(IconButton, { title: 'hi' })
await nextTick()
const btn = handle.host.querySelector('button')
expect(btn).toBeTruthy()
expect(btn!.getAttribute('title')).toBe('hi')
handle.unmount()
})
test('applies variant + size + active classes', async () => {
const handle = mountComponent(IconButton, {
variant: 'primary',
size: 'md',
active: true,
})
await nextTick()
const btn = handle.host.querySelector('button')!
expect(btn.className.includes('variant-primary')).toBe(true)
expect(btn.className.includes('size-md')).toBe(true)
expect(btn.className.includes('active')).toBe(true)
handle.unmount()
})
test('disabled prop disables the native button', async () => {
const handle = mountComponent(IconButton, { disabled: true })
await nextTick()
const btn = handle.host.querySelector('button')!
expect(btn.disabled).toBe(true)
handle.unmount()
})
test('renders default slot in place of icon prop', async () => {
const handle = mountComponent(
IconButton,
{},
{ default: () => [h('span', { class: 'custom-icon' }, 'X')] },
)
await nextTick()
const span = handle.host.querySelector('.custom-icon')
expect(span).toBeTruthy()
expect(span!.textContent).toBe('X')
handle.unmount()
})
test('click fires native click event', async () => {
let clicks = 0
const handle = mountComponent(IconButton, {
onClick: () => {
clicks++
},
})
await nextTick()
handle.host.querySelector('button')!.click()
expect(clicks).toBe(1)
handle.unmount()
})
})
suite('ui — Toast', () => {
test('hidden when visible=false, nothing in body', async () => {
const handle = mountComponent(Toast, { visible: false, message: 'x' })
await nextTick()
expect(document.querySelector('.toast')).toBeNull()
handle.unmount()
})
test('visible=true renders a teleported element with message', async () => {
const handle = mountComponent(Toast, {
visible: true,
variant: 'success',
message: 'Alles gut',
})
await nextTick()
const el = document.querySelector('.toast') as HTMLElement | null
expect(el).toBeTruthy()
expect(el!.className.includes('variant-success')).toBe(true)
expect(el!.textContent?.includes('Alles gut')).toBe(true)
handle.unmount()
})
test('applies position class', async () => {
const handle = mountComponent(Toast, {
visible: true,
position: 'top',
message: 'x',
})
await nextTick()
const el = document.querySelector('.toast')!
expect(el.className.includes('position-top')).toBe(true)
handle.unmount()
})
test('default slot overrides title/message', async () => {
const handle = mountComponent(
Toast,
{ visible: true, message: 'ignored' },
{ default: () => [h('span', { class: 'slot-body' }, 'slot wins')] },
)
await nextTick()
const slot = document.querySelector('.slot-body')
expect(slot).toBeTruthy()
expect(document.querySelector('.toast')!.textContent?.includes('slot wins')).toBe(true)
handle.unmount()
})
})
suite('project store — IndexedDB recents', () => {
test('save then getStoredProject returns identical scene', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'RS', width: 42, height: 42, fill: '#123456' })
s.addLayer(a.id)
const rec = await saveProjectToStore(s.scene, { name: 'test-save-get' })
try {
const back = await getStoredProject(rec.id)
expect(back).toBeTruthy()
expect(back!.name).toBe('test-save-get')
expect(back!.scene.assets).toHaveLength(1)
expect(back!.scene.layers).toHaveLength(1)
} finally {
await deleteStoredProject(rec.id)
}
})
test('listRecentProjects is sorted by savedAt desc', async () => {
const s = makeFreshStore()
const olderId = `test-older-${Date.now()}`
const newerId = `test-newer-${Date.now()}`
const older = await saveProjectToStore(s.scene, { id: olderId, name: 'older' })
await tick(5)
const newer = await saveProjectToStore(s.scene, { id: newerId, name: 'newer' })
try {
const list = await listRecentProjects()
const newerIdx = list.findIndex((p) => p.id === newer.id)
const olderIdx = list.findIndex((p) => p.id === older.id)
expect(newerIdx).toBeGreaterThan(-1)
expect(olderIdx).toBeGreaterThan(-1)
expect(newerIdx).toBeLessThan(olderIdx)
} finally {
await deleteStoredProject(olderId)
await deleteStoredProject(newerId)
}
})
test('save with same id updates existing record instead of adding', async () => {
const s = makeFreshStore()
const id = `test-update-${Date.now()}`
await saveProjectToStore(s.scene, { id, name: 'v1' })
const before = (await listRecentProjects()).filter((p) => p.id === id).length
await saveProjectToStore(s.scene, { id, name: 'v2' })
try {
const list = await listRecentProjects()
const matches = list.filter((p) => p.id === id)
expect(matches).toHaveLength(1)
expect(matches[0].name).toBe('v2')
expect(before).toBe(1)
} finally {
await deleteStoredProject(id)
}
})
test('deleteStoredProject removes entry', async () => {
const s = makeFreshStore()
const rec = await saveProjectToStore(s.scene, { name: 'to-delete' })
await deleteStoredProject(rec.id)
const back = await getStoredProject(rec.id)
expect(back).toBeNull()
})
test('getStoredProject returns null for missing id', async () => {
const back = await getStoredProject('definitely-missing-id-42')
expect(back).toBeNull()
})
})
suite('app smoke — live DOM', () => {
test('app root, topbar, timeline, stage are mounted', () => {
expect(document.querySelector('.app')).toBeTruthy()
@@ -521,5 +1004,10 @@ export function registerAllTests(): void {
expect(saveBtn).toBeTruthy()
expect(recentsBtn).toBeTruthy()
})
test('canvas has crop toggle button', () => {
const cropBtn = document.querySelector('.crop-btn')
expect(cropBtn).toBeTruthy()
})
})
}
+5 -1
View File
@@ -58,6 +58,10 @@ const ENGINE_SOURCE = `(function(){
for(var i=0;i<kfs.length-1;i++){
var a=kfs[i],b=kfs[i+1];
if(time>=a.time&&time<=b.time){
if(a.hold){
if(time>=b.time)return merge(layer.base,b.props);
return merge(layer.base,a.props);
}
var raw=(time-a.time)/(b.time-a.time);
var t=easing(b.easing,raw);
return lerpProps(merge(layer.base,a.props),merge(layer.base,b.props),t);
@@ -140,7 +144,7 @@ const ENGINE_SOURCE = `(function(){
ctx.clip();
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
for(var i=0;i<scene.layers.length;i++){
for(var i=scene.layers.length-1;i>=0;i--){
var layer=scene.layers[i];
if(!layer.visible)continue;
var asset=assetMap[layer.assetId];
+4
View File
@@ -70,6 +70,10 @@ function sampleKeyframeSet(
const a = sorted[i]
const b = sorted[i + 1]
if (time >= a.time && time <= b.time) {
if (a.hold) {
if (time >= b.time) return merge(base, b.props)
return merge(base, a.props)
}
const raw = (time - a.time) / (b.time - a.time)
const t = easings[b.easing](raw)
return lerpProps(merge(base, a.props), merge(base, b.props), t)
+2 -1
View File
@@ -43,7 +43,8 @@ export function drawSceneFrame(
const assetMap = new Map<string, Asset>()
for (const a of scene.assets) assetMap.set(a.id, a)
for (const layer of scene.layers) {
for (let i = scene.layers.length - 1; i >= 0; i--) {
const layer = scene.layers[i]
if (!layer.visible) continue
const asset = assetMap.get(layer.assetId)
if (!asset) continue
+18 -1
View File
@@ -163,7 +163,7 @@ export const useSceneStore = defineStore('scene', {
layer.offsetY = 0
}
}
this.scene.layers.push(layer)
this.scene.layers.unshift(layer)
this.selectedLayerId = layer.id
return layer
},
@@ -171,6 +171,15 @@ export const useSceneStore = defineStore('scene', {
this.scene.layers = this.scene.layers.filter((l) => l.id !== id)
if (this.selectedLayerId === id) this.selectedLayerId = null
},
moveLayer(fromIndex: number, toIndex: number) {
const arr = this.scene.layers
if (fromIndex < 0 || fromIndex >= arr.length) return
if (toIndex < 0 || toIndex > arr.length) return
const clamped = Math.min(toIndex, arr.length - 1)
if (fromIndex === clamped) return
const [moved] = arr.splice(fromIndex, 1)
arr.splice(clamped, 0, moved)
},
selectLayer(id: string | null) {
this.selectedLayerId = id
},
@@ -237,6 +246,14 @@ export const useSceneStore = defineStore('scene', {
const found = findKf(layer, keyframeId)
if (found) found.kf.easing = easing
},
setKeyframeHold(layerId: string, keyframeId: string, hold: boolean) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
const found = findKf(layer, keyframeId)
if (!found) return
if (hold) found.kf.hold = true
else delete found.kf.hold
},
setKeyframeTime(layerId: string, keyframeId: string, newTime: number) {
const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return
+1
View File
@@ -15,6 +15,7 @@ export interface Keyframe {
time: number
props: Partial<AnimatableProps>
easing: Easing
hold?: boolean
}
export type AssetKind = 'image' | 'rect' | 'circle' | 'text'