This commit is contained in:
th committed 2026-04-19 19:32:23 +02:00
1 parent 94e9846c13
commit e4aa0dce86
11 files changed
+2227 -32

No files matched your search

+13 -31
View File
@@ -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]
+370
View File
@@ -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>