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

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

+5 -1
View File
@@ -60,6 +60,10 @@
for(var i=0;i<kfs.length-1;i++){ for(var i=0;i<kfs.length-1;i++){
var a=kfs[i], b=kfs[i+1]; var a=kfs[i], b=kfs[i+1];
if(time>=a.time && time<=b.time){ 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 raw=(time-a.time)/(b.time-a.time);
var t=easing(b.easing,raw); var t=easing(b.easing,raw);
return lerpProps(merge(layer.base,a.props), merge(layer.base,b.props), t); return lerpProps(merge(layer.base,a.props), merge(layer.base,b.props), t);
@@ -90,7 +94,7 @@
ctx.fillStyle=scene.canvas.background; ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh); ctx.fillRect(0,0,lw,lh);
var am={}; for(var i=0;i<scene.assets.length;i++) am[scene.assets[i].id]=scene.assets[i]; var am={}; for(var i=0;i<scene.assets.length;i++) am[scene.assets[i].id]=scene.assets[i];
for(var j=0;j<scene.layers.length;j++){ for(var j=scene.layers.length-1;j>=0;j--){
var layer=scene.layers[j]; if(!layer.visible) continue; var layer=scene.layers[j]; if(!layer.visible) continue;
var asset=am[layer.assetId]; if(!asset) continue; var asset=am[layer.assetId]; if(!asset) continue;
var p=sample(layer,time); var p=sample(layer,time);
+78 -103
View File
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import { import {
PhArrowClockwise,
PhArrowCounterClockwise,
PhExport, PhExport,
PhFolderOpen, PhFolderOpen,
PhFloppyDisk, PhFloppyDisk,
@@ -15,10 +17,13 @@ import AssetSidebar from './components/AssetSidebar.vue'
import CanvasStage from './components/CanvasStage.vue' import CanvasStage from './components/CanvasStage.vue'
import DocumentSettings from './components/DocumentSettings.vue' import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.vue' import ExportDialog from './components/ExportDialog.vue'
import FullscreenPreview from './components/FullscreenPreview.vue'
import PropertiesPanel from './components/PropertiesPanel.vue' import PropertiesPanel from './components/PropertiesPanel.vue'
import Timeline from './components/Timeline.vue' import Timeline from './components/Timeline.vue'
import WelcomeDialog from './components/WelcomeDialog.vue' import WelcomeDialog from './components/WelcomeDialog.vue'
import TestOverlay from './devtools/TestOverlay.vue' import TestOverlay from './devtools/TestOverlay.vue'
import { IconButton, Toast } from './components/ui'
import { useHistory } from './composables/useHistory'
import { usePlaybackLoop } from './composables/usePlaybackLoop' import { usePlaybackLoop } from './composables/usePlaybackLoop'
import { import {
ProjectFileError, ProjectFileError,
@@ -39,6 +44,7 @@ const settingsOpen = ref(false)
const exportOpen = ref(false) const exportOpen = ref(false)
const testsOpen = ref(false) const testsOpen = ref(false)
const welcomeOpen = ref(false) const welcomeOpen = ref(false)
const fullscreenOpen = ref(false)
const loadError = ref<string | null>(null) const loadError = ref<string | null>(null)
const savedToast = ref<string | null>(null) const savedToast = ref<string | null>(null)
const currentProjectId = ref<string | null>(null) const currentProjectId = ref<string | null>(null)
@@ -46,6 +52,7 @@ const currentProjectName = ref<string | null>(null)
usePlaybackLoop() usePlaybackLoop()
const store = useSceneStore() const store = useSceneStore()
const history = useHistory()
function rewind() { function rewind() {
store.setPlayhead(0) store.setPlayhead(0)
@@ -94,6 +101,7 @@ async function importFromFile() {
try { try {
const scene = await readProjectFile(file) const scene = await readProjectFile(file)
store.loadScene(scene) store.loadScene(scene)
history.resetTo(scene)
currentProjectId.value = null currentProjectId.value = null
currentProjectName.value = file.name.replace(/\.[^.]+$/, '') currentProjectName.value = file.name.replace(/\.[^.]+$/, '')
loadError.value = null loadError.value = null
@@ -108,7 +116,9 @@ async function importFromFile() {
} }
function openFromRecent(project: StoredProject) { 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 currentProjectId.value = project.id
currentProjectName.value = project.name currentProjectName.value = project.name
setLastOpenedId(project.id) setLastOpenedId(project.id)
@@ -150,9 +160,28 @@ function onKeyDown(e: KeyboardEvent) {
testsOpen.value = true testsOpen.value = true
return 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 (e.code !== 'Space') return
if (isEditable(e.target)) return if (isEditable(e.target)) return
e.preventDefault() e.preventDefault()
if (e.shiftKey) {
store.pause()
fullscreenOpen.value = true
return
}
store.togglePlay() store.togglePlay()
} }
@@ -171,23 +200,47 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<PhSparkle :size="16" weight="fill" class="brand-icon" /> <PhSparkle :size="16" weight="fill" class="brand-icon" />
Animatr Animatr
</span> </span>
<span class="hint">PoC</span>
</div> </div>
<div class="transport"> <div class="transport">
<button class="tbtn" title="Zum Anfang" @click="rewind"> <IconButton
<PhSkipBack :size="14" weight="fill" /> :icon="PhArrowCounterClockwise"
</button> icon-weight="bold"
<button size="md"
class="tbtn primary" :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)'" :title="store.playing ? 'Pause (Space)' : 'Play (Space)'"
@click="store.togglePlay()" @click="store.togglePlay()"
> />
<PhPause v-if="store.playing" :size="14" weight="fill" /> <IconButton
<PhPlay v-else :size="14" weight="fill" /> :icon="PhStop"
</button> icon-weight="fill"
<button class="tbtn" title="Stop" @click="stop"> size="md"
<PhStop :size="14" weight="fill" /> title="Stop"
</button> @click="stop"
/>
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span> <span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
@@ -216,6 +269,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<DocumentSettings v-model="settingsOpen" /> <DocumentSettings v-model="settingsOpen" />
<ExportDialog v-model="exportOpen" /> <ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" /> <TestOverlay v-model="testsOpen" />
<FullscreenPreview v-model="fullscreenOpen" />
<WelcomeDialog <WelcomeDialog
v-model="welcomeOpen" v-model="welcomeOpen"
@open="openFromRecent" @open="openFromRecent"
@@ -223,15 +277,11 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
@import="importFromFile" @import="importFromFile"
@export="exportProjectFile" @export="exportProjectFile"
/> />
<Transition name="fade"> <Toast :visible="!!loadError" variant="error">
<div v-if="loadError" class="load-error" role="alert"> <strong>Projekt konnte nicht geladen werden.</strong>
<strong>Projekt konnte nicht geladen werden.</strong> <span>{{ loadError }}</span>
<span>{{ loadError }}</span> </Toast>
</div> <Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" />
</Transition>
<Transition name="fade">
<div v-if="savedToast" class="saved-toast" role="status">{{ savedToast }}</div>
</Transition>
</div> </div>
</template> </template>
@@ -279,49 +329,17 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
color: var(--accent); 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 { .transport {
display: flex; display: flex;
gap: 4px; gap: 4px;
align-items: center; align-items: center;
} }
.tbtn { .tsep {
width: 32px; width: 1px;
height: 30px; height: 18px;
padding: 0; background: var(--border);
display: inline-flex; margin: 0 4px;
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);
} }
.time { .time {
@@ -398,40 +416,6 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
overflow: hidden; 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 { .project-name {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -447,13 +431,4 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
text-overflow: ellipsis; text-overflow: ellipsis;
margin-right: 4px; margin-right: 4px;
} }
.fade-enter-active,
.fade-leave-active {
transition: opacity 140ms var(--ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style> </style>
+18
View File
@@ -51,6 +51,20 @@ function addSampleCircle() {
store.addLayer(asset.id) 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) { async function handleFileSelected(event: Event) {
uploadError.value = null uploadError.value = null
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
@@ -154,6 +168,10 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
<PhCircle :size="14" /> <PhCircle :size="14" />
Circle Circle
</button> </button>
<button class="shape-btn" @click="addSampleText">
<PhTextT :size="14" />
Text
</button>
</div> </div>
<input <input
+64 -28
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type Konva from 'konva' import type Konva from 'konva'
import { PhCornersIn } from '@phosphor-icons/vue' import { PhCornersIn, PhCrop } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate' import { sampleLayer } from '@/lib/interpolate'
import { loadImage } from '@/lib/fileLoader' import { loadImage } from '@/lib/fileLoader'
@@ -17,6 +17,7 @@ const transformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
const zoom = ref(1) const zoom = ref(1)
const viewX = ref(0) const viewX = ref(0)
const viewY = ref(0) const viewY = ref(0)
const cropObjects = ref(false)
let didInitialFit = false let didInitialFit = false
const ZOOM_MIN = 0.05 const ZOOM_MIN = 0.05
@@ -61,6 +62,18 @@ const viewConfig = computed(() => ({
scaleY: zoom.value, 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(() => ({ const backgroundConfig = computed(() => ({
x: 0, x: 0,
y: 0, y: 0,
@@ -115,6 +128,8 @@ interface RenderedLayer {
const rendered = computed<RenderedLayer[]>(() => const rendered = computed<RenderedLayer[]>(() =>
store.scene.layers store.scene.layers
.slice()
.reverse()
.map((layer) => { .map((layer) => {
const asset = assetMap.value.get(layer.assetId) const asset = assetMap.value.get(layer.assetId)
if (!asset) return null if (!asset) return null
@@ -513,33 +528,35 @@ onBeforeUnmount(() => {
<v-stage ref="stageRef" :config="stageConfig" @mousedown="handleStageMouseDown"> <v-stage ref="stageRef" :config="stageConfig" @mousedown="handleStageMouseDown">
<v-layer> <v-layer>
<v-group :config="viewConfig"> <v-group :config="viewConfig">
<v-rect :config="backgroundConfig" /> <v-group :config="clipGroupConfig">
<template v-for="item in rendered" :key="item.layer.id"> <v-rect :config="backgroundConfig" />
<v-rect <template v-for="item in rendered" :key="item.layer.id">
v-if="item.asset.kind === 'rect'" <v-rect
:config="rectConfig(item.layer, item.asset, item.props)" v-if="item.asset.kind === 'rect'"
@dragend="handleDragEnd" :config="rectConfig(item.layer, item.asset, item.props)"
@transformend="handleTransformEnd" @dragend="handleDragEnd"
/> @transformend="handleTransformEnd"
<v-circle />
v-else-if="item.asset.kind === 'circle'" <v-circle
:config="circleConfig(item.layer, item.asset, item.props)" v-else-if="item.asset.kind === 'circle'"
@dragend="handleDragEnd" :config="circleConfig(item.layer, item.asset, item.props)"
@transformend="handleTransformEnd" @dragend="handleDragEnd"
/> @transformend="handleTransformEnd"
<v-text />
v-else-if="item.asset.kind === 'text'" <v-text
:config="textConfig(item.layer, item.asset, item.props)" v-else-if="item.asset.kind === 'text'"
@dragend="handleDragEnd" :config="textConfig(item.layer, item.asset, item.props)"
@transformend="handleTransformEnd" @dragend="handleDragEnd"
/> @transformend="handleTransformEnd"
<v-image />
v-else-if="item.asset.kind === 'image' && imageConfig(item.layer, item.asset, item.props)" <v-image
:config="imageConfig(item.layer, item.asset, item.props)!" v-else-if="item.asset.kind === 'image' && imageConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd" :config="imageConfig(item.layer, item.asset, item.props)!"
@transformend="handleTransformEnd" @dragend="handleDragEnd"
/> @transformend="handleTransformEnd"
</template> />
</template>
</v-group>
<v-rect :config="canvasBorderConfig" /> <v-rect :config="canvasBorderConfig" />
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" /> <v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
<v-group <v-group
@@ -573,6 +590,15 @@ onBeforeUnmount(() => {
<button class="zc-btn" title="An Viewport anpassen" @click="fitToContainer"> <button class="zc-btn" title="An Viewport anpassen" @click="fitToContainer">
<PhCornersIn :size="13" /> <PhCornersIn :size="13" />
</button> </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>
<div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div> <div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div>
@@ -637,6 +663,16 @@ onBeforeUnmount(() => {
font-weight: 500; 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 { .zc-label {
min-width: 54px; min-width: 54px;
height: 24px; height: 24px;
+17 -109
View File
@@ -1,18 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, ref } from 'vue'
import { import { PhClock, PhFrameCorners, PhMoon, PhPalette, PhSun } from '@phosphor-icons/vue'
PhClock,
PhFrameCorners,
PhMoon,
PhPalette,
PhSun,
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import type { Theme } 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 emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore() const store = useSceneStore()
@@ -69,32 +63,20 @@ function close() {
emit('update:modelValue', false) 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) { function applyTheme(t: Theme) {
setTheme(t) setTheme(t)
} }
</script> </script>
<template> <template>
<Teleport to="body"> <Modal
<Transition name="fade"> :model-value="modelValue"
<div v-if="modelValue" class="backdrop" @mousedown.self="close"> title="Settings"
<div class="dialog" role="dialog" aria-modal="true"> size="lg"
<header class="dialog-header"> @update:model-value="emit('update:modelValue', $event)"
<h2>Settings</h2> >
<button class="close-btn" title="Schließen" @click="close"> <div class="body">
<PhX :size="14" weight="bold" /> <nav class="tabs">
</button>
</header>
<div class="body">
<nav class="tabs">
<button <button
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.id" :key="tab.id"
@@ -196,72 +178,15 @@ function applyTheme(t: Theme) {
</div> </div>
<p class="hint">Gespeichert im Browser, beim nächsten Öffnen wiederverwendet.</p> <p class="hint">Gespeichert im Browser, beim nächsten Öffnen wiederverwendet.</p>
</section> </section>
</div>
</div>
<footer class="dialog-footer">
<button class="primary" @click="close">Fertig</button>
</footer>
</div>
</div> </div>
</Transition> </div>
</Teleport> <template #footer>
<button class="primary" @click="close">Fertig</button>
</template>
</Modal>
</template> </template>
<style scoped> <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 { .body {
display: flex; display: flex;
min-height: 340px; min-height: 340px;
@@ -508,14 +433,6 @@ input[type='number'] {
color: var(--accent); color: var(--accent);
} }
.dialog-footer {
padding: 12px 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.primary { .primary {
padding: 6px 14px; padding: 6px 14px;
background: var(--accent-soft); background: var(--accent-soft);
@@ -528,13 +445,4 @@ input[type='number'] {
.primary:hover:not(:disabled) { .primary:hover:not(:disabled) {
background: var(--accent-soft-strong); 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> </style>
+95 -168
View File
@@ -1,19 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue' import { computed, ref } from 'vue'
import { import {
PhCode, PhCode,
PhDownloadSimple, PhDownloadSimple,
PhEngine, PhEngine,
PhFileHtml, PhFileHtml,
PhFilmSlate, PhFilmSlate,
PhX,
} from '@phosphor-icons/vue' } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer' import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport' import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadFromServer } from '@/lib/serverExport' 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 emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore() const store = useSceneStore()
@@ -34,17 +34,17 @@ function sceneClone() {
function exportScene() { function exportScene() {
downloadSceneExport(sceneClone()) downloadSceneExport(sceneClone())
close() emit('update:modelValue', false)
} }
function exportRuntime() { function exportRuntime() {
downloadRuntime() downloadRuntime()
close() emit('update:modelValue', false)
} }
function exportHtml() { function exportHtml() {
downloadExampleHtml(sceneClone()) downloadExampleHtml(sceneClone())
close() emit('update:modelValue', false)
} }
async function exportWebm() { async function exportWebm() {
@@ -111,174 +111,110 @@ function dismissError() {
progress.value = null progress.value = null
} }
const isLocked = () => !!progress.value && !progress.value.error const isLocked = computed(() => !!progress.value && !progress.value.error)
function close() { function onUpdate(v: boolean) {
if (isLocked()) return if (!v && isLocked.value) return
emit('update:modelValue', false) 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> </script>
<template> <template>
<Teleport to="body"> <Modal
<Transition name="fade"> :model-value="modelValue"
<div v-if="modelValue" class="backdrop" @mousedown.self="close"> title="Export"
<div class="dialog" role="dialog" aria-modal="true"> size="md"
<header class="dialog-header"> :dismissable="!isLocked"
<h2>Export</h2> :close-on-backdrop="!isLocked"
<button class="close-btn" :disabled="isLocked()" title="Schließen" @click="close"> :close-on-esc="!isLocked"
<PhX :size="14" weight="bold" /> @update:model-value="onUpdate"
</button> >
</header> <div v-if="!progress" class="options">
<button class="opt" @click="exportRuntime">
<div v-if="!progress" class="options"> <div class="opt-icon"><PhEngine :size="20" /></div>
<button class="opt" @click="exportRuntime"> <div class="opt-text">
<div class="opt-icon"><PhEngine :size="20" /></div> <div class="opt-title">
<div class="opt-text"> animatr-runtime.js
<div class="opt-title"> <span class="badge">1× pro Seite</span>
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>
</div> </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"> <button class="opt" @click="exportScene">
<div class="r-label"> <div class="opt-icon"><PhCode :size="20" /></div>
<PhFilmSlate :size="14" /> <div class="opt-text">
<span class="r-text">{{ progress.label }}</span> <div class="opt-title">
<span class="r-percent">{{ Math.round(progress.progress * 100) }}%</span> animation.js
</div> <span class="badge">Szene</span>
<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>
<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"> <button class="opt" @click="exportHtml">
<div class="error-title">Export fehlgeschlagen</div> <div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="error-msg">{{ progress.error }}</div> <div class="opt-text">
<div class="error-actions"> <div class="opt-title">Beispiel HTML</div>
<button class="primary" @click="dismissError">Zurück</button> <div class="opt-desc">Standalone HTML — Browser öffnen und fertig</div>
</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>
</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> </div>
</Transition> <div class="r-bar">
</Teleport> <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> </template>
<style scoped> <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 { .options {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -466,13 +402,4 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
.error-actions .primary:hover:not(:disabled) { .error-actions .primary:hover:not(:disabled) {
background: var(--accent-soft-strong); 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> </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 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 canFill = computed(() => {
const a = selectedAsset.value const a = selectedAsset.value
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text') 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> <label>Scale Y<input type="number" step="0.1" v-model.number="scaleY" /></label>
</div> </div>
<label>Rotation<input type="number" v-model.number="rotation" /></label> <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"> <label v-if="canFill" class="color-label">
Color Color
<div class="color-row"> <div class="color-row">
+211 -8
View File
@@ -1,6 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' 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 { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate' import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap' import { snapToCandidates } from '@/lib/timelineSnap'
@@ -13,6 +21,7 @@ interface TimelineTrack {
kind: 'main' | 'position' | 'rotation' kind: 'main' | 'position' | 'rotation'
label: string label: string
isParent: boolean isParent: boolean
layerIndex: number
} }
const store = useSceneStore() const store = useSceneStore()
@@ -208,7 +217,7 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
const tracks = computed<TimelineTrack[]>(() => { const tracks = computed<TimelineTrack[]>(() => {
const out: TimelineTrack[] = [] const out: TimelineTrack[] = []
for (const layer of store.scene.layers) { store.scene.layers.forEach((layer, layerIndex) => {
if (!layer.separated) { if (!layer.separated) {
out.push({ out.push({
layerId: layer.id, layerId: layer.id,
@@ -217,6 +226,7 @@ const tracks = computed<TimelineTrack[]>(() => {
kind: 'main', kind: 'main',
label: layer.name, label: layer.name,
isParent: true, isParent: true,
layerIndex,
}) })
} else { } else {
out.push({ out.push({
@@ -226,6 +236,7 @@ const tracks = computed<TimelineTrack[]>(() => {
kind: 'main', kind: 'main',
label: layer.name, label: layer.name,
isParent: true, isParent: true,
layerIndex,
}) })
out.push({ out.push({
layerId: layer.id, layerId: layer.id,
@@ -234,6 +245,7 @@ const tracks = computed<TimelineTrack[]>(() => {
kind: 'position', kind: 'position',
label: 'Position', label: 'Position',
isParent: false, isParent: false,
layerIndex,
}) })
out.push({ out.push({
layerId: layer.id, layerId: layer.id,
@@ -242,12 +254,30 @@ const tracks = computed<TimelineTrack[]>(() => {
kind: 'rotation', kind: 'rotation',
label: 'Rotation', label: 'Rotation',
isParent: false, isParent: false,
layerIndex,
}) })
} }
} })
return out 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 { interface KfDrag {
layerId: string layerId: string
keyframeId: string keyframeId: string
@@ -305,6 +335,7 @@ interface KfSegment {
fromTime: number fromTime: number
toTime: number toTime: number
easing: Easing easing: Easing
held: boolean
} }
function segmentsOf(kfs: Keyframe[]): KfSegment[] { function segmentsOf(kfs: Keyframe[]): KfSegment[] {
@@ -319,6 +350,7 @@ function segmentsOf(kfs: Keyframe[]): KfSegment[] {
fromTime: a.time, fromTime: a.time,
toTime: b.time, toTime: b.time,
easing: b.easing, easing: b.easing,
held: !!a.hold,
}) })
} }
return out return out
@@ -346,6 +378,8 @@ interface KfMenuState {
y: number y: number
currentEasing: Easing currentEasing: Easing
isFirst: boolean isFirst: boolean
isLast: boolean
isHeld: boolean
} }
const menu = ref<KfMenuState | null>(null) const menu = ref<KfMenuState | null>(null)
@@ -371,10 +405,18 @@ function onKeyframeContextMenu(
y: e.clientY, y: e.clientY,
currentEasing: kf.easing, currentEasing: kf.easing,
isFirst: sorted[0]?.id === kf.id, isFirst: sorted[0]?.id === kf.id,
isLast: sorted[sorted.length - 1]?.id === kf.id,
isHeld: !!kf.hold,
} }
store.selectLayer(layerId) 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) { function applyEasing(easing: Easing) {
if (!menu.value) return if (!menu.value) return
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing) store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
@@ -411,6 +453,56 @@ interface TrackMenuState {
y: number y: number
separated: boolean 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 trackMenu = ref<TrackMenuState | null>(null)
const trackMenuRef = ref<HTMLDivElement | null>(null) const trackMenuRef = ref<HTMLDivElement | null>(null)
@@ -574,7 +666,13 @@ onBeforeUnmount(() => {
:class="{ :class="{
'sub-track': !track.isParent, 'sub-track': !track.isParent,
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId, 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 <span
class="track-name" class="track-name"
@@ -583,9 +681,17 @@ onBeforeUnmount(() => {
parent: track.isParent, parent: track.isParent,
sub: !track.isParent, sub: !track.isParent,
}" }"
:draggable="track.isParent"
@click="store.selectLayer(track.layerId)" @click="store.selectLayer(track.layerId)"
@contextmenu="track.isParent ? onTrackContextMenu($event, track.layerId) : null" @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 <PhArrowsSplit
v-if="track.isParent && track.layer.separated" v-if="track.isParent && track.layer.separated"
:size="11" :size="11"
@@ -605,15 +711,20 @@ onBeforeUnmount(() => {
v-for="seg in segmentsOf(track.kfs)" v-for="seg in segmentsOf(track.kfs)"
:key="seg.id" :key="seg.id"
class="kf-seg" class="kf-seg"
:class="`easing-${seg.easing}`" :class="[seg.held ? 'held' : `easing-${seg.easing}`]"
:style="segmentStyle(seg)" :style="segmentStyle(seg)"
/> />
<span <span
v-for="kf in track.kfs" v-for="kf in track.kfs"
:key="kf.id" :key="kf.id"
class="kf" class="kf"
:class="{ hold: kf.hold }"
:style="{ left: pxFromTime(kf.time) + 'px' }" :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)" @pointerdown="onKeyframePointerDown($event, track.layerId, kf.id)"
@pointermove="onKeyframePointerMove" @pointermove="onKeyframePointerMove"
@pointerup="onKeyframePointerUp" @pointerup="onKeyframePointerUp"
@@ -641,18 +752,37 @@ onBeforeUnmount(() => {
v-for="opt in EASING_OPTIONS" v-for="opt in EASING_OPTIONS"
:key="opt.value" :key="opt.value"
class="kf-menu-item" class="kf-menu-item"
:class="{ active: menu.currentEasing === opt.value }" :class="{ active: menu.currentEasing === opt.value && !menu.isHeld }"
:disabled="menu.isFirst" :disabled="menu.isFirst || menu.isHeld"
@click="applyEasing(opt.value)" @click="applyEasing(opt.value)"
> >
<span class="check"> <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> </span>
{{ opt.label }} {{ opt.label }}
</button> </button>
<div v-if="menu.isFirst" class="kf-menu-note">Erster Keyframe — Easing ohne Effekt</div> <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>
<div class="kf-menu-divider"></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"> <button class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" /> <PhCopy :size="12" />
Duplizieren Duplizieren
@@ -872,6 +1002,32 @@ onBeforeUnmount(() => {
height: 30px; height: 30px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
transition: background 140ms var(--ease); 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 { .track.inactive .track-lane {
@@ -951,6 +1107,24 @@ onBeforeUnmount(() => {
flex-shrink: 0; 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 { .sub-indent {
color: var(--text-dim); color: var(--text-dim);
margin-right: 4px; margin-right: 4px;
@@ -1020,6 +1194,17 @@ onBeforeUnmount(() => {
opacity: 0.95; 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 { .kf {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -1046,6 +1231,24 @@ onBeforeUnmount(() => {
cursor: grabbing; 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 { .empty {
color: var(--text-dim); color: var(--text-dim);
font-style: italic; font-style: italic;
+30 -103
View File
@@ -6,13 +6,13 @@ import {
PhFolderOpen, PhFolderOpen,
PhPlus, PhPlus,
PhTrash, PhTrash,
PhX,
} from '@phosphor-icons/vue' } from '@phosphor-icons/vue'
import { import {
deleteStoredProject, deleteStoredProject,
listRecentProjects, listRecentProjects,
type StoredProject, type StoredProject,
} from '@/lib/projectStore' } from '@/lib/projectStore'
import { Modal } from '@/components/ui'
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -101,21 +101,18 @@ watch(
</script> </script>
<template> <template>
<Teleport to="body"> <Modal
<Transition name="fade"> :model-value="modelValue"
<div v-if="modelValue" class="backdrop" @mousedown.self="close"> size="md"
<div class="dialog" role="dialog" aria-modal="true"> @update:model-value="emit('update:modelValue', $event)"
<header class="hdr"> >
<div> <template #header>
<h2>Willkommen bei Animatr</h2> <div class="hdr-text">
<p class="sub">Zuletzt bearbeitete Projekte</p> <h2>Willkommen bei Animatr</h2>
</div> <p class="sub">Zuletzt bearbeitete Projekte</p>
<button class="close" title="Schließen" @click="close"> </div>
<PhX :size="14" weight="bold" /> </template>
</button> <div class="body">
</header>
<div class="body">
<div v-if="loading" class="empty">Laden…</div> <div v-if="loading" class="empty">Laden…</div>
<div v-else-if="!recents.length" class="empty"> <div v-else-if="!recents.length" class="empty">
Keine gespeicherten Projekte vorhanden. Keine gespeicherten Projekte vorhanden.
@@ -146,61 +143,26 @@ watch(
</button> </button>
</li> </li>
</ul> </ul>
</div> </div>
<template #footer>
<footer class="ftr"> <button class="ftr-btn" @click="startFresh">
<button class="ftr-btn" @click="startFresh"> <PhPlus :size="13" weight="bold" />
<PhPlus :size="13" weight="bold" /> Neues Projekt
Neues Projekt </button>
</button> <button class="ftr-btn" @click="importFile">
<button class="ftr-btn" @click="importFile"> <PhFileArrowUp :size="13" />
<PhFileArrowUp :size="13" /> Datei importieren…
Datei importieren… </button>
</button> <button class="ftr-btn" @click="exportFile">
<button class="ftr-btn" @click="exportFile"> <PhDownloadSimple :size="13" />
<PhDownloadSimple :size="13" /> Aktuelles exportieren
Aktuelles exportieren </button>
</button> </template>
</footer> </Modal>
</div>
</div>
</Transition>
</Teleport>
</template> </template>
<style scoped> <style scoped>
.backdrop { .hdr-text h2 {
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; margin: 0;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
@@ -212,24 +174,6 @@ watch(
color: var(--text-dim); 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 { .body {
padding: 8px 12px; padding: 8px 12px;
overflow: auto; overflow: auto;
@@ -334,14 +278,6 @@ watch(
border-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 { .ftr-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -358,13 +294,4 @@ watch(
background: var(--panel-hover); background: var(--panel-hover);
border-color: var(--border-strong); 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> </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 { useSceneStore } from '@/stores/scene'
import { sampleLayer, lerpColor } from '@/lib/interpolate' import { sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap' import { snapToCandidates } from '@/lib/timelineSnap'
@@ -7,8 +8,48 @@ import {
generateSceneScript, generateSceneScript,
} from '@/lib/exportPlayer' } from '@/lib/exportPlayer'
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile' 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' 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() { function makeFreshStore() {
const s = useSceneStore() const s = useSceneStore()
s.$reset() s.$reset()
@@ -96,6 +137,43 @@ export function registerAllTests(): void {
expect(s.selectedLayerId).toBeNull() 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 () => { test('selectLayer toggles with null', async () => {
const s = makeFreshStore() const s = makeFreshStore()
const a = s.addAsset({ kind: 'circle', name: 'SelC', radius: 30, fill: '#00cc88' }) 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) 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 () => { test('setKeyframeEasing updates easing field', async () => {
const s = makeFreshStore() const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Ease', width: 50, height: 50, fill: '#88cc44' }) 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) 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', () => { test('clamps before first kf to first props, after last to last props', () => {
const s = makeFreshStore() const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#000' }) 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', () => { suite('app smoke — live DOM', () => {
test('app root, topbar, timeline, stage are mounted', () => { test('app root, topbar, timeline, stage are mounted', () => {
expect(document.querySelector('.app')).toBeTruthy() expect(document.querySelector('.app')).toBeTruthy()
@@ -521,5 +1004,10 @@ export function registerAllTests(): void {
expect(saveBtn).toBeTruthy() expect(saveBtn).toBeTruthy()
expect(recentsBtn).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++){ for(var i=0;i<kfs.length-1;i++){
var a=kfs[i],b=kfs[i+1]; var a=kfs[i],b=kfs[i+1];
if(time>=a.time&&time<=b.time){ 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 raw=(time-a.time)/(b.time-a.time);
var t=easing(b.easing,raw); var t=easing(b.easing,raw);
return lerpProps(merge(layer.base,a.props),merge(layer.base,b.props),t); return lerpProps(merge(layer.base,a.props),merge(layer.base,b.props),t);
@@ -140,7 +144,7 @@ const ENGINE_SOURCE = `(function(){
ctx.clip(); ctx.clip();
ctx.fillStyle=scene.canvas.background; ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh); 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]; var layer=scene.layers[i];
if(!layer.visible)continue; if(!layer.visible)continue;
var asset=assetMap[layer.assetId]; var asset=assetMap[layer.assetId];
+4
View File
@@ -70,6 +70,10 @@ function sampleKeyframeSet(
const a = sorted[i] const a = sorted[i]
const b = sorted[i + 1] const b = sorted[i + 1]
if (time >= a.time && time <= b.time) { 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 raw = (time - a.time) / (b.time - a.time)
const t = easings[b.easing](raw) const t = easings[b.easing](raw)
return lerpProps(merge(base, a.props), merge(base, b.props), t) 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>() const assetMap = new Map<string, Asset>()
for (const a of scene.assets) assetMap.set(a.id, a) 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 if (!layer.visible) continue
const asset = assetMap.get(layer.assetId) const asset = assetMap.get(layer.assetId)
if (!asset) continue if (!asset) continue
+18 -1
View File
@@ -163,7 +163,7 @@ export const useSceneStore = defineStore('scene', {
layer.offsetY = 0 layer.offsetY = 0
} }
} }
this.scene.layers.push(layer) this.scene.layers.unshift(layer)
this.selectedLayerId = layer.id this.selectedLayerId = layer.id
return layer return layer
}, },
@@ -171,6 +171,15 @@ export const useSceneStore = defineStore('scene', {
this.scene.layers = this.scene.layers.filter((l) => l.id !== id) this.scene.layers = this.scene.layers.filter((l) => l.id !== id)
if (this.selectedLayerId === id) this.selectedLayerId = null 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) { selectLayer(id: string | null) {
this.selectedLayerId = id this.selectedLayerId = id
}, },
@@ -237,6 +246,14 @@ export const useSceneStore = defineStore('scene', {
const found = findKf(layer, keyframeId) const found = findKf(layer, keyframeId)
if (found) found.kf.easing = easing 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) { setKeyframeTime(layerId: string, keyframeId: string, newTime: number) {
const layer = this.scene.layers.find((l) => l.id === layerId) const layer = this.scene.layers.find((l) => l.id === layerId)
if (!layer) return if (!layer) return
+1
View File
@@ -15,6 +15,7 @@ export interface Keyframe {
time: number time: number
props: Partial<AnimatableProps> props: Partial<AnimatableProps>
easing: Easing easing: Easing
hold?: boolean
} }
export type AssetKind = 'image' | 'rect' | 'circle' | 'text' export type AssetKind = 'image' | 'rect' | 'circle' | 'text'
+8 -1
View File
@@ -23,4 +23,11 @@ fehlerreport mit allen relevanten informationen muss automatisch in die zwischen
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 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 text-objekte muss man neben rect und kreis zur verfügung haben
ich möchte ein fullscreen preview auf shift-space
bitte fehlende tests ergänzen
neues feature: clips. ein clip ist eine eigene timeline. diese timelines sind bibliotheks-objekte und können in die haupt-timeline gezogen werden. sie landen auf einer oder mehreren clip-spuren. ein clip wird dort nur als block angezeigt, nicht als keyframes. man kann den clip durch doppelklick aber öffnen wie die haupt-timeline. durch DnD kann man die position des clips in der timeline verschieben.