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