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