init
This commit is contained in:
commit
c6863f2bfc
34 files changed
+6909
No files matched your search
@@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
PhCircle,
|
||||
PhImage,
|
||||
PhPlus,
|
||||
PhSquare,
|
||||
PhTextT,
|
||||
PhUploadSimple,
|
||||
} from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { isSvgFile, loadImage, parseSvgDimensions, readAsDataUrl } from '@/lib/fileLoader'
|
||||
import type { Asset } from '@/types/scene'
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploadError = ref<string | null>(null)
|
||||
|
||||
function addSampleRect() {
|
||||
const asset = store.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'Rect',
|
||||
width: 160,
|
||||
height: 100,
|
||||
fill: '#6ea8ff',
|
||||
})
|
||||
store.addLayer(asset.id)
|
||||
}
|
||||
|
||||
function addSampleCircle() {
|
||||
const asset = store.addAsset({
|
||||
kind: 'circle',
|
||||
name: 'Circle',
|
||||
radius: 60,
|
||||
fill: '#ff7a59',
|
||||
})
|
||||
store.addLayer(asset.id)
|
||||
}
|
||||
|
||||
async function handleFileSelected(event: Event) {
|
||||
uploadError.value = null
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = Array.from(input.files ?? [])
|
||||
try {
|
||||
for (const file of files) await importFile(file)
|
||||
} catch (err) {
|
||||
uploadError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function importFile(file: File) {
|
||||
const dataUrl = await readAsDataUrl(file)
|
||||
|
||||
let width = 0
|
||||
let height = 0
|
||||
|
||||
if (isSvgFile(file)) {
|
||||
const text = await file.text()
|
||||
const dims = parseSvgDimensions(text)
|
||||
if (dims) {
|
||||
width = dims.width
|
||||
height = dims.height
|
||||
}
|
||||
}
|
||||
|
||||
if (!width || !height) {
|
||||
const img = await loadImage(dataUrl)
|
||||
width = img.naturalWidth || 300
|
||||
height = img.naturalHeight || 300
|
||||
}
|
||||
|
||||
const asset = store.addAsset({
|
||||
kind: 'image',
|
||||
name: file.name,
|
||||
src: dataUrl,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
store.addLayer(asset.id)
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
|
||||
rect: PhSquare,
|
||||
circle: PhCircle,
|
||||
image: PhImage,
|
||||
text: PhTextT,
|
||||
}
|
||||
|
||||
const assetsWithUsage = computed(() =>
|
||||
store.scene.assets.map((a) => ({
|
||||
asset: a,
|
||||
inUse: store.scene.layers.some((l) => l.assetId === a.id),
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<h3>Assets</h3>
|
||||
|
||||
<button class="upload-btn" @click="triggerUpload">
|
||||
<PhUploadSimple :size="16" />
|
||||
<span>SVG / Bild hinzufügen</span>
|
||||
</button>
|
||||
|
||||
<div class="actions">
|
||||
<button class="shape-btn" @click="addSampleRect">
|
||||
<PhSquare :size="14" />
|
||||
Rect
|
||||
</button>
|
||||
<button class="shape-btn" @click="addSampleCircle">
|
||||
<PhCircle :size="14" />
|
||||
Circle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/svg+xml,image/png,image/jpeg,image/webp,image/gif"
|
||||
multiple
|
||||
style="display: none"
|
||||
@change="handleFileSelected"
|
||||
/>
|
||||
<p v-if="uploadError" class="error">{{ uploadError }}</p>
|
||||
|
||||
<h4>Bibliothek</h4>
|
||||
<ul class="assets">
|
||||
<li v-for="item in assetsWithUsage" :key="item.asset.id" :class="{ unused: !item.inUse }">
|
||||
<component :is="assetIconMap[item.asset.kind]" :size="14" class="asset-icon" />
|
||||
<span class="name" :title="item.asset.name">{{ item.asset.name }}</span>
|
||||
<button
|
||||
v-if="!item.inUse"
|
||||
class="add-layer"
|
||||
title="Als Layer einfügen"
|
||||
@click="store.addLayer(item.asset.id)"
|
||||
>
|
||||
<PhPlus :size="12" weight="bold" />
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="!store.scene.assets.length" class="empty">keine Assets</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 6px 0 4px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--accent-soft);
|
||||
border: 1px dashed var(--accent);
|
||||
color: var(--accent);
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-btn:hover:not(:disabled) {
|
||||
background: var(--accent-soft-strong);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.shape-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 11px;
|
||||
margin: 2px 0 0;
|
||||
padding: 6px 8px;
|
||||
background: var(--danger-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.assets {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.assets li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-alt);
|
||||
font-size: 12px;
|
||||
transition: background 140ms var(--ease);
|
||||
}
|
||||
|
||||
.assets li:hover {
|
||||
background: var(--panel-hover);
|
||||
}
|
||||
|
||||
.assets li.unused {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.asset-icon {
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.add-layer {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.add-layer:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.empty {
|
||||
background: transparent !important;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,581 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type Konva from 'konva'
|
||||
import { PhCornersIn } from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import { loadImage } from '@/lib/fileLoader'
|
||||
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null)
|
||||
const containerSize = ref({ width: 0, height: 0 })
|
||||
const stageRef = ref<{ getStage(): Konva.Stage } | null>(null)
|
||||
const transformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
|
||||
|
||||
const zoom = ref(1)
|
||||
const viewX = ref(0)
|
||||
const viewY = ref(0)
|
||||
let didInitialFit = false
|
||||
|
||||
const ZOOM_MIN = 0.05
|
||||
const ZOOM_MAX = 20
|
||||
|
||||
function clamp(v: number, lo: number, hi: number) {
|
||||
return Math.max(lo, Math.min(hi, v))
|
||||
}
|
||||
|
||||
function fitToContainer() {
|
||||
const cw = containerSize.value.width
|
||||
const ch = containerSize.value.height
|
||||
const lw = store.scene.canvas.width
|
||||
const lh = store.scene.canvas.height
|
||||
if (!cw || !ch) return
|
||||
const z = Math.min(cw / lw, ch / lh) * 0.92
|
||||
zoom.value = z
|
||||
viewX.value = (cw - lw * z) / 2
|
||||
viewY.value = (ch - lh * z) / 2
|
||||
}
|
||||
|
||||
function zoomToOne() {
|
||||
const cw = containerSize.value.width
|
||||
const ch = containerSize.value.height
|
||||
const lw = store.scene.canvas.width
|
||||
const lh = store.scene.canvas.height
|
||||
if (!cw || !ch) return
|
||||
zoom.value = 1
|
||||
viewX.value = (cw - lw) / 2
|
||||
viewY.value = (ch - lh) / 2
|
||||
}
|
||||
|
||||
const stageConfig = computed(() => ({
|
||||
width: containerSize.value.width,
|
||||
height: containerSize.value.height,
|
||||
}))
|
||||
|
||||
const viewConfig = computed(() => ({
|
||||
x: viewX.value,
|
||||
y: viewY.value,
|
||||
scaleX: zoom.value,
|
||||
scaleY: zoom.value,
|
||||
}))
|
||||
|
||||
const backgroundConfig = computed(() => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: store.scene.canvas.width,
|
||||
height: store.scene.canvas.height,
|
||||
fill: store.scene.canvas.background,
|
||||
}))
|
||||
|
||||
const canvasBorderConfig = computed(() => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: store.scene.canvas.width,
|
||||
height: store.scene.canvas.height,
|
||||
stroke: '#4a4a56',
|
||||
strokeWidth: 1 / zoom.value,
|
||||
listening: false,
|
||||
}))
|
||||
|
||||
const zoomPercent = computed(() => Math.round(zoom.value * 100))
|
||||
|
||||
const assetMap = computed(() => {
|
||||
const m = new Map<string, Asset>()
|
||||
for (const a of store.scene.assets) m.set(a.id, a)
|
||||
return m
|
||||
})
|
||||
|
||||
const imageCache = ref<Record<string, HTMLImageElement>>({})
|
||||
|
||||
watch(
|
||||
() => store.scene.assets.map((a) => (a.kind === 'image' ? `${a.id}:${a.src.length}` : null)),
|
||||
() => {
|
||||
for (const a of store.scene.assets) {
|
||||
if (a.kind !== 'image') continue
|
||||
if (imageCache.value[a.id]) continue
|
||||
loadImage(a.src)
|
||||
.then((img) => {
|
||||
imageCache.value = { ...imageCache.value, [a.id]: img }
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('image load failed', err)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
interface RenderedLayer {
|
||||
layer: Layer
|
||||
asset: Asset
|
||||
props: AnimatableProps
|
||||
}
|
||||
|
||||
const rendered = computed<RenderedLayer[]>(() =>
|
||||
store.scene.layers
|
||||
.map((layer) => {
|
||||
const asset = assetMap.value.get(layer.assetId)
|
||||
if (!asset) return null
|
||||
return {
|
||||
layer,
|
||||
asset,
|
||||
props: sampleLayer(layer, store.playhead),
|
||||
}
|
||||
})
|
||||
.filter((x): x is RenderedLayer => x !== null),
|
||||
)
|
||||
|
||||
function baseNodeConfig(layer: Layer, props: AnimatableProps) {
|
||||
return {
|
||||
id: layer.id,
|
||||
x: props.x,
|
||||
y: props.y,
|
||||
scaleX: props.scaleX,
|
||||
scaleY: props.scaleY,
|
||||
rotation: props.rotation,
|
||||
opacity: props.opacity,
|
||||
visible: layer.visible,
|
||||
draggable: true,
|
||||
}
|
||||
}
|
||||
|
||||
function rectConfig(layer: Layer, asset: Extract<Asset, { kind: 'rect' }>, props: AnimatableProps) {
|
||||
return {
|
||||
...baseNodeConfig(layer, props),
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
offsetX: layer.offsetX ?? asset.width / 2,
|
||||
offsetY: layer.offsetY ?? asset.height / 2,
|
||||
fill: props.fill ?? asset.fill,
|
||||
}
|
||||
}
|
||||
|
||||
function circleConfig(
|
||||
layer: Layer,
|
||||
asset: Extract<Asset, { kind: 'circle' }>,
|
||||
props: AnimatableProps,
|
||||
) {
|
||||
return {
|
||||
...baseNodeConfig(layer, props),
|
||||
radius: asset.radius,
|
||||
fill: props.fill ?? asset.fill,
|
||||
}
|
||||
}
|
||||
|
||||
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
|
||||
return {
|
||||
...baseNodeConfig(layer, props),
|
||||
text: asset.text,
|
||||
fontSize: asset.fontSize,
|
||||
fill: props.fill ?? asset.fill,
|
||||
}
|
||||
}
|
||||
|
||||
function imageConfig(
|
||||
layer: Layer,
|
||||
asset: Extract<Asset, { kind: 'image' }>,
|
||||
props: AnimatableProps,
|
||||
) {
|
||||
const img = imageCache.value[asset.id]
|
||||
if (!img) return null
|
||||
return {
|
||||
...baseNodeConfig(layer, props),
|
||||
image: img,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
offsetX: layer.offsetX ?? asset.width / 2,
|
||||
offsetY: layer.offsetY ?? asset.height / 2,
|
||||
}
|
||||
}
|
||||
|
||||
interface PivotInfo {
|
||||
layerId: string
|
||||
sampled: AnimatableProps
|
||||
}
|
||||
|
||||
const pivotInfo = computed<PivotInfo | null>(() => {
|
||||
const id = store.selectedLayerId
|
||||
if (!id) return null
|
||||
const layer = store.scene.layers.find((l) => l.id === id)
|
||||
if (!layer) return null
|
||||
const asset = assetMap.value.get(layer.assetId)
|
||||
if (!asset || (asset.kind !== 'rect' && asset.kind !== 'image')) return null
|
||||
return { layerId: layer.id, sampled: sampleLayer(layer, store.playhead) }
|
||||
})
|
||||
|
||||
const pivotHandleConfig = computed(() => {
|
||||
const info = pivotInfo.value
|
||||
if (!info) return null
|
||||
return {
|
||||
x: info.sampled.x,
|
||||
y: info.sampled.y,
|
||||
draggable: true,
|
||||
name: 'pivot-handle',
|
||||
}
|
||||
})
|
||||
|
||||
const pivotRingConfig = computed(() => ({
|
||||
radius: 8 / zoom.value,
|
||||
stroke: '#6ea8ff',
|
||||
strokeWidth: 1.5 / zoom.value,
|
||||
fill: '#ffffff',
|
||||
}))
|
||||
|
||||
const pivotDotConfig = computed(() => ({
|
||||
radius: 2.5 / zoom.value,
|
||||
fill: '#6ea8ff',
|
||||
listening: false,
|
||||
}))
|
||||
|
||||
function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
||||
const info = pivotInfo.value
|
||||
if (!info) return
|
||||
const node = e.target
|
||||
const dx = node.x() - info.sampled.x
|
||||
const dy = node.y() - info.sampled.y
|
||||
if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) return
|
||||
const theta = (info.sampled.rotation * Math.PI) / 180
|
||||
const c = Math.cos(theta)
|
||||
const s = Math.sin(theta)
|
||||
const unrotDx = dx * c + dy * s
|
||||
const unrotDy = -dx * s + dy * c
|
||||
const invSX = info.sampled.scaleX !== 0 ? 1 / info.sampled.scaleX : 1
|
||||
const invSY = info.sampled.scaleY !== 0 ? 1 / info.sampled.scaleY : 1
|
||||
const localDx = unrotDx * invSX
|
||||
const localDy = unrotDy * invSY
|
||||
store.shiftLayerPivot(info.layerId, dx, dy, localDx, localDy)
|
||||
}
|
||||
|
||||
const transformerConfig = {
|
||||
rotateEnabled: true,
|
||||
enabledAnchors: [
|
||||
'top-left',
|
||||
'top-center',
|
||||
'top-right',
|
||||
'middle-right',
|
||||
'middle-left',
|
||||
'bottom-left',
|
||||
'bottom-center',
|
||||
'bottom-right',
|
||||
],
|
||||
anchorSize: 9,
|
||||
borderStroke: '#4f8cff',
|
||||
anchorStroke: '#4f8cff',
|
||||
anchorFill: '#ffffff',
|
||||
padding: 2,
|
||||
ignoreStroke: true,
|
||||
}
|
||||
|
||||
function attachTransformer() {
|
||||
const stage = stageRef.value?.getStage()
|
||||
const tr = transformerRef.value?.getNode()
|
||||
if (!stage || !tr) return
|
||||
const id = store.selectedLayerId
|
||||
if (!id) {
|
||||
tr.nodes([])
|
||||
tr.getLayer()?.batchDraw()
|
||||
return
|
||||
}
|
||||
const node = stage.findOne<Konva.Node>(`#${id}`)
|
||||
tr.nodes(node ? [node] : [])
|
||||
tr.getLayer()?.batchDraw()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [store.selectedLayerId, rendered.value.length, store.playhead] as const,
|
||||
async () => {
|
||||
await nextTick()
|
||||
attachTransformer()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||
const target = e.target
|
||||
const tr = transformerRef.value?.getNode()
|
||||
if (tr) {
|
||||
let p: Konva.Node | null = target
|
||||
while (p) {
|
||||
if (p === tr) return
|
||||
p = p.getParent() ?? null
|
||||
}
|
||||
}
|
||||
if (target.findAncestor?.('.pivot-handle')) return
|
||||
if (target === target.getStage()) {
|
||||
store.selectLayer(null)
|
||||
return
|
||||
}
|
||||
const id = target.id?.()
|
||||
if (!id) {
|
||||
store.selectLayer(null)
|
||||
return
|
||||
}
|
||||
const match = store.scene.layers.find((l) => l.id === id)
|
||||
store.selectLayer(match ? match.id : null)
|
||||
}
|
||||
|
||||
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
||||
const node = e.target
|
||||
const id = node.id()
|
||||
if (!store.scene.layers.some((l) => l.id === id)) return
|
||||
store.upsertKeyframe(id, store.playhead, {
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
})
|
||||
}
|
||||
|
||||
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
|
||||
const node = e.target
|
||||
const id = node.id()
|
||||
if (!store.scene.layers.some((l) => l.id === id)) return
|
||||
store.upsertKeyframe(id, store.playhead, {
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
scaleX: node.scaleX(),
|
||||
scaleY: node.scaleY(),
|
||||
rotation: node.rotation(),
|
||||
})
|
||||
}
|
||||
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
const rect = container.value?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const sx = e.clientX - rect.left
|
||||
const sy = e.clientY - rect.top
|
||||
const logX = (sx - viewX.value) / zoom.value
|
||||
const logY = (sy - viewY.value) / zoom.value
|
||||
const factor = Math.pow(1.0015, -e.deltaY)
|
||||
const newZoom = clamp(zoom.value * factor, ZOOM_MIN, ZOOM_MAX)
|
||||
zoom.value = newZoom
|
||||
viewX.value = sx - logX * newZoom
|
||||
viewY.value = sy - logY * newZoom
|
||||
}
|
||||
|
||||
let panning = false
|
||||
let panStartX = 0
|
||||
let panStartY = 0
|
||||
let panStartViewX = 0
|
||||
let panStartViewY = 0
|
||||
let panPointerId = -1
|
||||
|
||||
function onContainerPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 1) return
|
||||
e.preventDefault()
|
||||
panning = true
|
||||
panStartX = e.clientX
|
||||
panStartY = e.clientY
|
||||
panStartViewX = viewX.value
|
||||
panStartViewY = viewY.value
|
||||
panPointerId = e.pointerId
|
||||
container.value?.setPointerCapture(e.pointerId)
|
||||
if (container.value) container.value.style.cursor = 'grabbing'
|
||||
}
|
||||
|
||||
function onContainerPointerMove(e: PointerEvent) {
|
||||
if (!panning || e.pointerId !== panPointerId) return
|
||||
viewX.value = panStartViewX + (e.clientX - panStartX)
|
||||
viewY.value = panStartViewY + (e.clientY - panStartY)
|
||||
}
|
||||
|
||||
function onContainerPointerUp(e: PointerEvent) {
|
||||
if (e.pointerId !== panPointerId) return
|
||||
panning = false
|
||||
container.value?.releasePointerCapture?.(e.pointerId)
|
||||
if (container.value) container.value.style.cursor = ''
|
||||
}
|
||||
|
||||
function zoomBy(factor: number) {
|
||||
const cw = containerSize.value.width
|
||||
const ch = containerSize.value.height
|
||||
if (!cw || !ch) return
|
||||
const sx = cw / 2
|
||||
const sy = ch / 2
|
||||
const logX = (sx - viewX.value) / zoom.value
|
||||
const logY = (sy - viewY.value) / zoom.value
|
||||
const newZoom = clamp(zoom.value * factor, ZOOM_MIN, ZOOM_MAX)
|
||||
zoom.value = newZoom
|
||||
viewX.value = sx - logX * newZoom
|
||||
viewY.value = sy - logY * newZoom
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!container.value) return
|
||||
ro = new ResizeObserver((entries) => {
|
||||
const rect = entries[0].contentRect
|
||||
containerSize.value = { width: rect.width, height: rect.height }
|
||||
if (!didInitialFit && rect.width > 0 && rect.height > 0) {
|
||||
fitToContainer()
|
||||
didInitialFit = true
|
||||
}
|
||||
})
|
||||
ro.observe(container.value)
|
||||
|
||||
container.value.addEventListener('wheel', onWheel, { passive: false })
|
||||
container.value.addEventListener('pointerdown', onContainerPointerDown)
|
||||
container.value.addEventListener('pointermove', onContainerPointerMove)
|
||||
container.value.addEventListener('pointerup', onContainerPointerUp)
|
||||
container.value.addEventListener('pointercancel', onContainerPointerUp)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect()
|
||||
ro = null
|
||||
container.value?.removeEventListener('wheel', onWheel)
|
||||
container.value?.removeEventListener('pointerdown', onContainerPointerDown)
|
||||
container.value?.removeEventListener('pointermove', onContainerPointerMove)
|
||||
container.value?.removeEventListener('pointerup', onContainerPointerUp)
|
||||
container.value?.removeEventListener('pointercancel', onContainerPointerUp)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="stage-container">
|
||||
<v-stage ref="stageRef" :config="stageConfig" @mousedown="handleStageMouseDown">
|
||||
<v-layer>
|
||||
<v-group :config="viewConfig">
|
||||
<v-rect :config="backgroundConfig" />
|
||||
<template v-for="item in rendered" :key="item.layer.id">
|
||||
<v-rect
|
||||
v-if="item.asset.kind === 'rect'"
|
||||
:config="rectConfig(item.layer, item.asset, item.props)"
|
||||
@dragend="handleDragEnd"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
<v-circle
|
||||
v-else-if="item.asset.kind === 'circle'"
|
||||
:config="circleConfig(item.layer, item.asset, item.props)"
|
||||
@dragend="handleDragEnd"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
<v-text
|
||||
v-else-if="item.asset.kind === 'text'"
|
||||
:config="textConfig(item.layer, item.asset, item.props)"
|
||||
@dragend="handleDragEnd"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
<v-image
|
||||
v-else-if="item.asset.kind === 'image' && imageConfig(item.layer, item.asset, item.props)"
|
||||
:config="imageConfig(item.layer, item.asset, item.props)!"
|
||||
@dragend="handleDragEnd"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
</template>
|
||||
<v-rect :config="canvasBorderConfig" />
|
||||
<v-group
|
||||
v-if="pivotHandleConfig"
|
||||
:config="pivotHandleConfig"
|
||||
@dragend="onPivotDragEnd"
|
||||
>
|
||||
<v-circle :config="pivotRingConfig" />
|
||||
<v-circle :config="pivotDotConfig" />
|
||||
</v-group>
|
||||
</v-group>
|
||||
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
||||
</v-layer>
|
||||
</v-stage>
|
||||
|
||||
<div class="zoom-controls" @pointerdown.stop>
|
||||
<button class="zc-btn" title="Zoom out" @click="zoomBy(1 / 1.25)">−</button>
|
||||
<button class="zc-label" title="Fit to view" @click="fitToContainer">
|
||||
{{ zoomPercent }}%
|
||||
</button>
|
||||
<button class="zc-btn" title="Zoom in" @click="zoomBy(1.25)">+</button>
|
||||
<div class="zc-sep"></div>
|
||||
<button class="zc-btn wide" title="Zoom 1:1" @click="zoomToOne">1:1</button>
|
||||
<button class="zc-btn" title="An Viewport anpassen" @click="fitToContainer">
|
||||
<PhCornersIn :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stage-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dims {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zc-btn {
|
||||
width: 26px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.zc-btn:hover:not(:disabled) {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.zc-btn.wide {
|
||||
width: auto;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.zc-label {
|
||||
min-width: 54px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.zc-label:hover:not(:disabled) {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.zc-sep {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
margin: 0 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,540 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
PhClock,
|
||||
PhFrameCorners,
|
||||
PhMoon,
|
||||
PhPalette,
|
||||
PhSun,
|
||||
PhX,
|
||||
} from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import type { Theme } from '@/composables/useTheme'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const store = useSceneStore()
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
type TabId = 'document' | 'timeline' | 'theme'
|
||||
|
||||
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
|
||||
{ id: 'document', label: 'Document', icon: PhFrameCorners },
|
||||
{ id: 'timeline', label: 'Timeline', icon: PhClock },
|
||||
{ id: 'theme', label: 'UI Theme', icon: PhPalette },
|
||||
]
|
||||
|
||||
const activeTab = ref<TabId>('document')
|
||||
|
||||
const width = computed({
|
||||
get: () => store.scene.canvas.width,
|
||||
set: (v: number) => store.setCanvasSize(v, store.scene.canvas.height),
|
||||
})
|
||||
|
||||
const height = computed({
|
||||
get: () => store.scene.canvas.height,
|
||||
set: (v: number) => store.setCanvasSize(store.scene.canvas.width, v),
|
||||
})
|
||||
|
||||
const background = computed({
|
||||
get: () => store.scene.canvas.background,
|
||||
set: (v: string) => store.setCanvasBackground(v),
|
||||
})
|
||||
|
||||
const duration = computed({
|
||||
get: () => store.scene.duration,
|
||||
set: (v: number) => store.setDuration(v),
|
||||
})
|
||||
|
||||
const fps = computed({
|
||||
get: () => store.scene.fps,
|
||||
set: (v: number) => store.setFps(v),
|
||||
})
|
||||
|
||||
const presets: { label: string; w: number; h: number }[] = [
|
||||
{ label: '16:9 · 1280×720', w: 1280, h: 720 },
|
||||
{ label: '16:9 · 1920×1080', w: 1920, h: 1080 },
|
||||
{ label: '1:1 · 1080×1080', w: 1080, h: 1080 },
|
||||
{ label: '9:16 · 1080×1920', w: 1080, h: 1920 },
|
||||
{ label: '4:5 · 1080×1350', w: 1080, h: 1350 },
|
||||
]
|
||||
|
||||
function applyPreset(p: { w: number; h: number }) {
|
||||
store.setCanvasSize(p.w, p.h)
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && props.modelValue) close()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeyDown))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
|
||||
|
||||
function applyTheme(t: Theme) {
|
||||
setTheme(t)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="modelValue" class="backdrop" @mousedown.self="close">
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
<header class="dialog-header">
|
||||
<h2>Settings</h2>
|
||||
<button class="close-btn" title="Schließen" @click="close">
|
||||
<PhX :size="14" weight="bold" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="tab"
|
||||
:class="{ active: activeTab === tab.id }"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
<component :is="tab.icon" :size="14" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
<section v-if="activeTab === 'document'">
|
||||
<h3>Canvas</h3>
|
||||
<div class="row">
|
||||
<label>
|
||||
Width
|
||||
<input type="number" min="1" step="1" v-model.number="width" />
|
||||
</label>
|
||||
<label>
|
||||
Height
|
||||
<input type="number" min="1" step="1" v-model.number="height" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="presets">
|
||||
<button
|
||||
v-for="p in presets"
|
||||
:key="p.label"
|
||||
class="preset"
|
||||
:class="{ active: width === p.w && height === p.h }"
|
||||
@click="applyPreset(p)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
<label class="stacked">
|
||||
Background
|
||||
<div class="color-row">
|
||||
<input
|
||||
type="color"
|
||||
class="color-swatch"
|
||||
:value="background"
|
||||
@input="background = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<span class="hex">{{ background.toUpperCase() }}</span>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'timeline'">
|
||||
<h3>Timeline</h3>
|
||||
<div class="row">
|
||||
<label>
|
||||
Duration (s)
|
||||
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
|
||||
</label>
|
||||
<label>
|
||||
FPS
|
||||
<input type="number" min="1" step="1" v-model.number="fps" />
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Die Timeline-Länge setzt die Ruler-Skala. Playback stoppt am letzten gesetzten Keyframe.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'theme'">
|
||||
<h3>UI Theme</h3>
|
||||
<div class="theme-grid">
|
||||
<button
|
||||
class="theme-card"
|
||||
:class="{ active: theme === 'dark' }"
|
||||
@click="applyTheme('dark')"
|
||||
>
|
||||
<div class="theme-preview theme-preview-dark">
|
||||
<div class="tp-panel"></div>
|
||||
<div class="tp-accent"></div>
|
||||
</div>
|
||||
<div class="theme-label">
|
||||
<PhMoon :size="14" />
|
||||
<span>Dark</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="theme-card"
|
||||
:class="{ active: theme === 'light' }"
|
||||
@click="applyTheme('light')"
|
||||
>
|
||||
<div class="theme-preview theme-preview-light">
|
||||
<div class="tp-panel"></div>
|
||||
<div class="tp-accent"></div>
|
||||
</div>
|
||||
<div class="theme-label">
|
||||
<PhSun :size="14" />
|
||||
<span>Light</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">Gespeichert im Browser, beim nächsten Öffnen wiederverwendet.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<button class="primary" @click="close">Fertig</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(620px, calc(100% - 32px));
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 64px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dialog-header h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.close-btn:hover:not(:disabled) {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
width: 150px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 10px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background: var(--panel-alt);
|
||||
}
|
||||
|
||||
.tab {
|
||||
justify-content: flex-start;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.tab:hover:not(:disabled) {
|
||||
background: var(--panel-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
section h3 {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
label.stacked {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.presets {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preset {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.preset:hover:not(:disabled) {
|
||||
background: var(--panel-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.preset.active {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-swatch::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
.color-swatch::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.color-swatch::-moz-color-swatch {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.hex {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim-strong);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
background: var(--panel-alt);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.theme-card:hover:not(:disabled) {
|
||||
background: var(--panel-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.theme-card.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
height: 74px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.theme-preview-dark {
|
||||
background: #17171b;
|
||||
border: 1px solid #2e2e37;
|
||||
}
|
||||
|
||||
.theme-preview-dark .tp-panel {
|
||||
background: #272730;
|
||||
flex: 1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.theme-preview-dark .tp-accent {
|
||||
background: #6ea8ff;
|
||||
width: 22px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.theme-preview-light {
|
||||
background: #f3f3f6;
|
||||
border: 1px solid #e2e2e7;
|
||||
}
|
||||
|
||||
.theme-preview-light .tp-panel {
|
||||
background: #ffffff;
|
||||
flex: 1;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #e2e2e7;
|
||||
}
|
||||
|
||||
.theme-preview-light .tp-accent {
|
||||
background: #2b6af5;
|
||||
width: 22px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.theme-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.theme-card.active .theme-label {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.primary {
|
||||
padding: 6px 14px;
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--accent-soft-strong);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 140ms var(--ease);
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,460 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
PhCode,
|
||||
PhDownloadSimple,
|
||||
PhFileHtml,
|
||||
PhFilmSlate,
|
||||
PhX,
|
||||
} from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { downloadExampleHtml, downloadExport } from '@/lib/exportPlayer'
|
||||
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
|
||||
import { downloadFromServer } from '@/lib/serverExport'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
interface ProgressState {
|
||||
progress: number
|
||||
controller: AbortController
|
||||
label: string
|
||||
error: string | null
|
||||
}
|
||||
const progress = ref<ProgressState | null>(null)
|
||||
|
||||
const videoSupported = pickMimeType() !== null
|
||||
|
||||
function sceneClone() {
|
||||
return JSON.parse(JSON.stringify(store.scene))
|
||||
}
|
||||
|
||||
function exportJs() {
|
||||
downloadExport(sceneClone())
|
||||
close()
|
||||
}
|
||||
|
||||
function exportHtml() {
|
||||
downloadExampleHtml(sceneClone())
|
||||
close()
|
||||
}
|
||||
|
||||
async function exportWebm() {
|
||||
if (progress.value) return
|
||||
const controller = new AbortController()
|
||||
progress.value = {
|
||||
progress: 0,
|
||||
controller,
|
||||
label: 'Rendering WebM (Browser, Realtime)…',
|
||||
error: null,
|
||||
}
|
||||
store.pause()
|
||||
try {
|
||||
await downloadSceneAsVideo(sceneClone(), {
|
||||
signal: controller.signal,
|
||||
onProgress: (p) => {
|
||||
if (progress.value) progress.value.progress = p
|
||||
},
|
||||
})
|
||||
progress.value = null
|
||||
emit('update:modelValue', false)
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
progress.value = null
|
||||
return
|
||||
}
|
||||
if (progress.value) progress.value.error = (err as Error).message || String(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function exportMp4Server() {
|
||||
if (progress.value) return
|
||||
const controller = new AbortController()
|
||||
progress.value = {
|
||||
progress: 0,
|
||||
controller,
|
||||
label: 'Rendering MP4 (Server)…',
|
||||
error: null,
|
||||
}
|
||||
store.pause()
|
||||
try {
|
||||
await downloadFromServer(sceneClone(), {
|
||||
signal: controller.signal,
|
||||
onProgress: (p) => {
|
||||
if (progress.value) progress.value.progress = p
|
||||
},
|
||||
})
|
||||
progress.value = null
|
||||
emit('update:modelValue', false)
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
progress.value = null
|
||||
return
|
||||
}
|
||||
if (progress.value) progress.value.error = (err as Error).message || String(err)
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
progress.value?.controller.abort()
|
||||
}
|
||||
|
||||
function dismissError() {
|
||||
progress.value = null
|
||||
}
|
||||
|
||||
const isLocked = () => !!progress.value && !progress.value.error
|
||||
|
||||
function close() {
|
||||
if (isLocked()) return
|
||||
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))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="modelValue" class="backdrop" @mousedown.self="close">
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
<header class="dialog-header">
|
||||
<h2>Export</h2>
|
||||
<button class="close-btn" :disabled="isLocked()" title="Schließen" @click="close">
|
||||
<PhX :size="14" weight="bold" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="!progress" class="options">
|
||||
<button class="opt" @click="exportJs">
|
||||
<div class="opt-icon"><PhCode :size="20" /></div>
|
||||
<div class="opt-text">
|
||||
<div class="opt-title">
|
||||
animation.js
|
||||
<span class="badge">Web Embed</span>
|
||||
</div>
|
||||
<div class="opt-desc">Selbstständiges JS — in eine eigene Seite einbinden</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 v-else-if="!progress.error" class="rendering">
|
||||
<div class="r-label">
|
||||
<PhFilmSlate :size="14" />
|
||||
<span class="r-text">{{ progress.label }}</span>
|
||||
<span class="r-percent">{{ Math.round(progress.progress * 100) }}%</span>
|
||||
</div>
|
||||
<div class="r-bar">
|
||||
<div class="r-bar-fill" :style="{ width: progress.progress * 100 + '%' }"></div>
|
||||
</div>
|
||||
<div class="r-actions">
|
||||
<button class="secondary" @click="cancel">Abbrechen</button>
|
||||
</div>
|
||||
</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>
|
||||
</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 {
|
||||
width: min(520px, calc(100% - 32px));
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 64px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dialog-header h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.close-btn:hover:not(:disabled) {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
transition: background 140ms var(--ease), border-color 140ms var(--ease),
|
||||
transform 140ms var(--ease);
|
||||
}
|
||||
|
||||
.opt:hover:not(:disabled) {
|
||||
background: var(--panel-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.opt:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.opt-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.opt-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.opt-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.opt-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.opt-arrow {
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.opt:hover:not(:disabled) .opt-arrow {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text-dim);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.badge.accent {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.rendering {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.r-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-dim-strong);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.r-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.r-percent {
|
||||
color: var(--accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.r-bar {
|
||||
height: 6px;
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.r-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 140ms linear;
|
||||
}
|
||||
|
||||
.r-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.r-actions button {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.r-actions button:hover:not(:disabled) {
|
||||
background: var(--panel-hover);
|
||||
}
|
||||
|
||||
.error-view {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim-strong);
|
||||
background: var(--danger-soft);
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
word-break: break-word;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.error-actions .primary {
|
||||
padding: 6px 14px;
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.error-actions .primary:hover:not(:disabled) {
|
||||
background: var(--accent-soft-strong);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 140ms var(--ease);
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { PhDiamond, PhTrash, PhX } from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import type { AnimatableProps } from '@/types/scene'
|
||||
import RangeSlider from './RangeSlider.vue'
|
||||
|
||||
const formatPercent = (v: number) => `${Math.round(v * 100)}%`
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
const selected = computed(() =>
|
||||
store.scene.layers.find((l) => l.id === store.selectedLayerId) ?? null,
|
||||
)
|
||||
|
||||
const sampled = computed<AnimatableProps | null>(() =>
|
||||
selected.value ? sampleLayer(selected.value, store.playhead) : null,
|
||||
)
|
||||
|
||||
const hasKeyframeHere = computed(() => {
|
||||
if (!selected.value) return false
|
||||
return selected.value.keyframes.some((k) => Math.abs(k.time - store.playhead) < 1e-3)
|
||||
})
|
||||
|
||||
const selectedAsset = computed(() => {
|
||||
if (!selected.value) return null
|
||||
return store.scene.assets.find((a) => a.id === selected.value!.assetId) ?? null
|
||||
})
|
||||
|
||||
const canFill = computed(() => {
|
||||
const a = selectedAsset.value
|
||||
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text')
|
||||
})
|
||||
|
||||
const fillColor = computed<string>({
|
||||
get: () => {
|
||||
if (sampled.value?.fill) return sampled.value.fill
|
||||
const a = selectedAsset.value
|
||||
if (a && 'fill' in a) return a.fill
|
||||
return '#000000'
|
||||
},
|
||||
set: (v: string) => {
|
||||
if (!selected.value) return
|
||||
store.upsertKeyframe(selected.value.id, store.playhead, { fill: v })
|
||||
},
|
||||
})
|
||||
|
||||
type NumericKey = 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotation' | 'opacity'
|
||||
|
||||
function bind(key: NumericKey, decimals = 0) {
|
||||
const factor = 10 ** decimals
|
||||
return computed({
|
||||
get: () => (sampled.value ? Math.round(sampled.value[key] * factor) / factor : 0),
|
||||
set: (v: number) => {
|
||||
if (!selected.value) return
|
||||
store.upsertKeyframe(selected.value.id, store.playhead, { [key]: v })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const x = bind('x')
|
||||
const y = bind('y')
|
||||
const scaleX = bind('scaleX', 2)
|
||||
const scaleY = bind('scaleY', 2)
|
||||
const rotation = bind('rotation')
|
||||
const opacity = bind('opacity', 2)
|
||||
|
||||
function removeKeyframeHere() {
|
||||
if (!selected.value) return
|
||||
store.removeKeyframeAt(selected.value.id, store.playhead)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<h3>Properties</h3>
|
||||
<template v-if="selected && sampled">
|
||||
<label>Name<input v-model="selected.name" /></label>
|
||||
|
||||
<div class="kf-row" :class="{ on: hasKeyframeHere }">
|
||||
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
|
||||
<span class="kf-label">
|
||||
{{ hasKeyframeHere ? 'Keyframe' : 'Interpoliert' }}
|
||||
<span class="kf-time">@ {{ store.playhead.toFixed(2) }}s</span>
|
||||
</span>
|
||||
<button v-if="hasKeyframeHere" class="kf-btn" title="Keyframe hier löschen" @click="removeKeyframeHere">
|
||||
<PhX :size="12" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>X<input type="number" v-model.number="x" /></label>
|
||||
<label>Y<input type="number" v-model.number="y" /></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Scale X<input type="number" step="0.1" v-model.number="scaleX" /></label>
|
||||
<label>Scale Y<input type="number" step="0.1" v-model.number="scaleY" /></label>
|
||||
</div>
|
||||
<label>Rotation<input type="number" v-model.number="rotation" /></label>
|
||||
<label v-if="canFill" class="color-label">
|
||||
Color
|
||||
<div class="color-row">
|
||||
<input type="color" class="color-swatch" :value="fillColor" @input="fillColor = ($event.target as HTMLInputElement).value" />
|
||||
<span class="color-hex">{{ fillColor.toUpperCase() }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="slider-label">
|
||||
Opacity
|
||||
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
|
||||
</label>
|
||||
|
||||
<button class="danger-btn" @click="store.removeLayer(selected.id)">
|
||||
<PhTrash :size="13" />
|
||||
Layer löschen
|
||||
</button>
|
||||
</template>
|
||||
<p v-else class="hint">Kein Layer ausgewählt.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.kf-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
transition: background 140ms var(--ease), border-color 140ms var(--ease);
|
||||
}
|
||||
|
||||
.kf-row.on {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.kf-dot {
|
||||
color: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kf-label {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: baseline;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kf-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.kf-row.on .kf-time {
|
||||
color: var(--accent);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.kf-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kf-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 140ms var(--ease);
|
||||
}
|
||||
|
||||
.color-swatch:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.color-swatch::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-swatch::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.color-swatch::-moz-color-swatch {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim-strong);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
justify-content: center;
|
||||
margin-top: 6px;
|
||||
background: var(--danger-soft);
|
||||
border-color: transparent;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.danger-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 122, 122, 0.18);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
format?: (v: number) => string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
format: undefined,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
|
||||
|
||||
const trackRef = ref<HTMLDivElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
|
||||
const fillPercent = computed(() => {
|
||||
const range = props.max - props.min
|
||||
if (range <= 0) return 0
|
||||
const clamped = Math.max(props.min, Math.min(props.max, props.modelValue))
|
||||
return ((clamped - props.min) / range) * 100
|
||||
})
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
if (props.format) return props.format(props.modelValue)
|
||||
return String(props.modelValue)
|
||||
})
|
||||
|
||||
function snap(value: number): number {
|
||||
const s = props.step
|
||||
if (s <= 0) return value
|
||||
const snapped = Math.round(value / s) * s
|
||||
const decimals = (s.toString().split('.')[1] || '').length
|
||||
return decimals > 0 ? parseFloat(snapped.toFixed(decimals)) : snapped
|
||||
}
|
||||
|
||||
function clamp(value: number): number {
|
||||
return Math.max(props.min, Math.min(props.max, value))
|
||||
}
|
||||
|
||||
function updateFromClientX(clientX: number) {
|
||||
const track = trackRef.value
|
||||
if (!track) return
|
||||
const rect = track.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
const raw = props.min + ratio * (props.max - props.min)
|
||||
emit('update:modelValue', clamp(snap(raw)))
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (props.disabled) return
|
||||
if (e.button !== 0) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
el.focus()
|
||||
dragging.value = true
|
||||
updateFromClientX(e.clientX)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (!el.hasPointerCapture(e.pointerId)) return
|
||||
updateFromClientX(e.clientX)
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||||
dragging.value = false
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (props.disabled) return
|
||||
const big = e.shiftKey ? 10 : 1
|
||||
let next = props.modelValue
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowDown':
|
||||
next -= props.step * big
|
||||
break
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
next += props.step * big
|
||||
break
|
||||
case 'Home':
|
||||
next = props.min
|
||||
break
|
||||
case 'End':
|
||||
next = props.max
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
emit('update:modelValue', clamp(snap(next)))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="slider"
|
||||
:class="{ disabled, dragging }"
|
||||
:tabindex="disabled ? -1 : 0"
|
||||
role="slider"
|
||||
:aria-valuemin="min"
|
||||
:aria-valuemax="max"
|
||||
:aria-valuenow="modelValue"
|
||||
:aria-disabled="disabled"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
@keydown="onKeyDown"
|
||||
>
|
||||
<div ref="trackRef" class="track">
|
||||
<div class="track-bg"></div>
|
||||
<div class="track-fill" :style="{ width: fillPercent + '%' }"></div>
|
||||
<div class="thumb" :style="{ left: fillPercent + '%' }">
|
||||
<div class="thumb-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="value">{{ formattedValue }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.slider.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.track-bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.track-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: background 140ms var(--ease);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.thumb-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text);
|
||||
border: 2px solid var(--accent);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
transition: transform 140ms var(--ease), box-shadow 140ms var(--ease),
|
||||
background 140ms var(--ease);
|
||||
}
|
||||
|
||||
.slider:hover:not(.disabled) .thumb-dot {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.slider:focus-visible:not(.disabled) .thumb-dot {
|
||||
box-shadow: 0 0 0 3px var(--accent-soft-strong), 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.slider.dragging .thumb-dot {
|
||||
transform: scale(1.15);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim-strong);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.slider:hover:not(.disabled) .value {
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,839 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { PhCheck, PhCopy, PhTrash } from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import type { Easing, Keyframe } from '@/types/scene'
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
const duration = computed(() => store.scene.duration)
|
||||
|
||||
const NAME_WIDTH = 140
|
||||
const MIN_PX_PER_SEC = 2
|
||||
const MAX_PX_PER_SEC = 2000
|
||||
|
||||
const scrollEl = ref<HTMLDivElement | null>(null)
|
||||
const viewportWidth = ref(0)
|
||||
const pxPerSec = ref(0)
|
||||
const autoFit = ref(true)
|
||||
|
||||
function clamp(v: number, lo: number, hi: number) {
|
||||
return Math.max(lo, Math.min(hi, v))
|
||||
}
|
||||
|
||||
const fitPxPerSec = computed(() => {
|
||||
const avail = Math.max(60, viewportWidth.value - NAME_WIDTH)
|
||||
const d = Math.max(0.1, duration.value)
|
||||
return avail / d
|
||||
})
|
||||
|
||||
const effectivePxPerSec = computed(() => {
|
||||
if (autoFit.value || pxPerSec.value <= 0) return fitPxPerSec.value
|
||||
return pxPerSec.value
|
||||
})
|
||||
|
||||
const laneWidth = computed(() => duration.value * effectivePxPerSec.value)
|
||||
const totalWidth = computed(() => NAME_WIDTH + laneWidth.value)
|
||||
|
||||
type DisplayMode = 'seconds' | 'frames'
|
||||
const displayMode = ref<DisplayMode>('seconds')
|
||||
|
||||
const TICK_CANDIDATES_SEC = [0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 300]
|
||||
const TICK_CANDIDATES_FRAMES = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800]
|
||||
|
||||
const fps = computed(() => (store.scene.fps > 0 ? store.scene.fps : 60))
|
||||
|
||||
const tickStep = computed(() => {
|
||||
const pps = effectivePxPerSec.value
|
||||
if (displayMode.value === 'frames') {
|
||||
const pxPerFrame = pps / fps.value
|
||||
for (const f of TICK_CANDIDATES_FRAMES) {
|
||||
if (f * pxPerFrame >= 55) return f / fps.value
|
||||
}
|
||||
return TICK_CANDIDATES_FRAMES[TICK_CANDIDATES_FRAMES.length - 1] / fps.value
|
||||
}
|
||||
for (const s of TICK_CANDIDATES_SEC) {
|
||||
if (s * pps >= 55) return s
|
||||
}
|
||||
return TICK_CANDIDATES_SEC[TICK_CANDIDATES_SEC.length - 1]
|
||||
})
|
||||
|
||||
const ticks = computed(() => {
|
||||
const d = duration.value
|
||||
const step = tickStep.value
|
||||
const out: number[] = []
|
||||
for (let t = 0; t <= d + 1e-6; t += step) {
|
||||
out.push(Math.round(t * 1000) / 1000)
|
||||
}
|
||||
if (out[out.length - 1] < d - 1e-6) out.push(d)
|
||||
return out
|
||||
})
|
||||
|
||||
function pxFromTime(t: number) {
|
||||
return t * effectivePxPerSec.value
|
||||
}
|
||||
|
||||
function formatTick(t: number) {
|
||||
if (displayMode.value === 'frames') {
|
||||
return `${Math.round(t * fps.value)}`
|
||||
}
|
||||
if (Number.isInteger(t)) return `${t}s`
|
||||
return `${t.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')}s`
|
||||
}
|
||||
|
||||
function formatKfTime(t: number) {
|
||||
if (displayMode.value === 'frames') return `${Math.round(t * fps.value)} fr`
|
||||
return `${t.toFixed(2)}s`
|
||||
}
|
||||
|
||||
function seekFromEvent(e: PointerEvent, el: HTMLElement) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
store.setPlayhead(ratio * duration.value)
|
||||
}
|
||||
|
||||
function onScrubPointerDown(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
seekFromEvent(e, el)
|
||||
}
|
||||
|
||||
function onScrubPointerMove(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (!el.hasPointerCapture(e.pointerId)) return
|
||||
seekFromEvent(e, el)
|
||||
}
|
||||
|
||||
function onScrubPointerUp(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function onLaneClick(e: MouseEvent) {
|
||||
if (kfDrag.value) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
store.setPlayhead(ratio * duration.value)
|
||||
}
|
||||
|
||||
function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
|
||||
e.stopPropagation()
|
||||
if (kfDrag.value?.moved) {
|
||||
kfDrag.value = null
|
||||
return
|
||||
}
|
||||
kfDrag.value = null
|
||||
store.selectLayer(layerId)
|
||||
store.setPlayhead(time)
|
||||
}
|
||||
|
||||
function onKeyframeDblClick(e: MouseEvent, layerId: string, time: number) {
|
||||
e.stopPropagation()
|
||||
if (kfDrag.value?.moved) return
|
||||
store.removeKeyframeAt(layerId, time)
|
||||
}
|
||||
|
||||
interface KfDrag {
|
||||
layerId: string
|
||||
keyframeId: string
|
||||
lane: HTMLElement
|
||||
pointerId: number
|
||||
startX: number
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
const kfDrag = ref<KfDrag | null>(null)
|
||||
|
||||
function onKeyframePointerDown(e: PointerEvent, layerId: string, keyframeId: string) {
|
||||
if (e.button !== 0) return
|
||||
const kfEl = e.currentTarget as HTMLElement
|
||||
const lane = kfEl.parentElement
|
||||
if (!lane) return
|
||||
e.stopPropagation()
|
||||
kfEl.setPointerCapture(e.pointerId)
|
||||
kfDrag.value = {
|
||||
layerId,
|
||||
keyframeId,
|
||||
lane,
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
moved: false,
|
||||
}
|
||||
store.selectLayer(layerId)
|
||||
}
|
||||
|
||||
function onKeyframePointerMove(e: PointerEvent) {
|
||||
const d = kfDrag.value
|
||||
if (!d || d.pointerId !== e.pointerId) return
|
||||
if (!d.moved && Math.abs(e.clientX - d.startX) < 3) return
|
||||
d.moved = true
|
||||
const rect = d.lane.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const newTime = ratio * duration.value
|
||||
store.setKeyframeTime(d.layerId, d.keyframeId, newTime)
|
||||
store.setPlayhead(newTime)
|
||||
}
|
||||
|
||||
function onKeyframePointerUp(e: PointerEvent) {
|
||||
const d = kfDrag.value
|
||||
if (!d || d.pointerId !== e.pointerId) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||||
if (!d.moved) {
|
||||
kfDrag.value = null
|
||||
}
|
||||
}
|
||||
|
||||
interface KfSegment {
|
||||
id: string
|
||||
fromTime: number
|
||||
toTime: number
|
||||
easing: Easing
|
||||
}
|
||||
|
||||
function segmentsOf(kfs: Keyframe[]): KfSegment[] {
|
||||
if (kfs.length < 2) return []
|
||||
const sorted = [...kfs].sort((a, b) => a.time - b.time)
|
||||
const out: KfSegment[] = []
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i]
|
||||
const b = sorted[i + 1]
|
||||
out.push({
|
||||
id: `${a.id}>${b.id}`,
|
||||
fromTime: a.time,
|
||||
toTime: b.time,
|
||||
easing: b.easing,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function segmentStyle(seg: KfSegment) {
|
||||
return {
|
||||
left: `${pxFromTime(seg.fromTime)}px`,
|
||||
width: `${pxFromTime(seg.toTime - seg.fromTime)}px`,
|
||||
}
|
||||
}
|
||||
|
||||
const EASING_OPTIONS: { value: Easing; label: string }[] = [
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
{ value: 'easeIn', label: 'Ease In' },
|
||||
{ value: 'easeOut', label: 'Ease Out' },
|
||||
{ value: 'easeInOut', label: 'Ease In-Out' },
|
||||
]
|
||||
|
||||
interface KfMenuState {
|
||||
layerId: string
|
||||
keyframeId: string
|
||||
time: number
|
||||
x: number
|
||||
y: number
|
||||
currentEasing: Easing
|
||||
isFirst: boolean
|
||||
}
|
||||
|
||||
const menu = ref<KfMenuState | null>(null)
|
||||
const menuRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
function onKeyframeContextMenu(e: MouseEvent, layerId: string, keyframeId: string) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const layer = store.scene.layers.find((l) => l.id === layerId)
|
||||
const kf = layer?.keyframes.find((k) => k.id === keyframeId)
|
||||
if (!layer || !kf) return
|
||||
const sorted = [...layer.keyframes].sort((a, b) => a.time - b.time)
|
||||
menu.value = {
|
||||
layerId,
|
||||
keyframeId: kf.id,
|
||||
time: kf.time,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
currentEasing: kf.easing,
|
||||
isFirst: sorted[0]?.id === kf.id,
|
||||
}
|
||||
store.selectLayer(layerId)
|
||||
}
|
||||
|
||||
function applyEasing(easing: Easing) {
|
||||
if (!menu.value) return
|
||||
store.setKeyframeEasing(menu.value.layerId, menu.value.time, easing)
|
||||
menu.value = null
|
||||
}
|
||||
|
||||
function deleteFromMenu() {
|
||||
if (!menu.value) return
|
||||
store.removeKeyframeAt(menu.value.layerId, menu.value.time)
|
||||
menu.value = null
|
||||
}
|
||||
|
||||
function duplicateFromMenu() {
|
||||
if (!menu.value) return
|
||||
const newId = store.duplicateKeyframe(menu.value.layerId, menu.value.keyframeId)
|
||||
if (newId) {
|
||||
const layer = store.scene.layers.find((l) => l.id === menu.value!.layerId)
|
||||
const newKf = layer?.keyframes.find((k) => k.id === newId)
|
||||
if (newKf) store.setPlayhead(newKf.time)
|
||||
}
|
||||
menu.value = null
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(e: PointerEvent) {
|
||||
if (!menu.value) return
|
||||
if (menuRef.value && e.target instanceof Node && menuRef.value.contains(e.target)) return
|
||||
menu.value = null
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') menu.value = null
|
||||
}
|
||||
|
||||
watch(menu, (val) => {
|
||||
if (val) {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
} else {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
})
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (!scrollEl.value) return
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
const rect = scrollEl.value.getBoundingClientRect()
|
||||
const cursorInViewport = e.clientX - rect.left
|
||||
const laneCursor = cursorInViewport - NAME_WIDTH + scrollEl.value.scrollLeft
|
||||
const currentPps = effectivePxPerSec.value
|
||||
const timeAtCursor = Math.max(0, laneCursor) / currentPps
|
||||
const factor = Math.pow(1.0015, -e.deltaY)
|
||||
const newPps = clamp(currentPps * factor, MIN_PX_PER_SEC, MAX_PX_PER_SEC)
|
||||
autoFit.value = false
|
||||
pxPerSec.value = newPps
|
||||
requestAnimationFrame(() => {
|
||||
if (!scrollEl.value) return
|
||||
const targetLane = timeAtCursor * newPps
|
||||
scrollEl.value.scrollLeft = Math.max(0, targetLane - (cursorInViewport - NAME_WIDTH))
|
||||
})
|
||||
return
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault()
|
||||
scrollEl.value.scrollLeft += e.deltaY !== 0 ? e.deltaY : e.deltaX
|
||||
}
|
||||
}
|
||||
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!scrollEl.value) return
|
||||
ro = new ResizeObserver((entries) => {
|
||||
const rect = entries[0].contentRect
|
||||
viewportWidth.value = rect.width
|
||||
})
|
||||
ro.observe(scrollEl.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect()
|
||||
ro = null
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timeline">
|
||||
<div ref="scrollEl" class="tl-scroll" @wheel="onWheel">
|
||||
<div class="tl-content" :style="{ width: totalWidth + 'px' }">
|
||||
<div class="scrub-bar">
|
||||
<div class="name-spacer">
|
||||
<div class="mode-toggle" title="Zeit-Einheit umschalten">
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: displayMode === 'seconds' }"
|
||||
@click="displayMode = 'seconds'"
|
||||
>s</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: displayMode === 'frames' }"
|
||||
@click="displayMode = 'frames'"
|
||||
>fr</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="scrub-ruler"
|
||||
:style="{ width: laneWidth + 'px' }"
|
||||
@pointerdown="onScrubPointerDown"
|
||||
@pointermove="onScrubPointerMove"
|
||||
@pointerup="onScrubPointerUp"
|
||||
@pointercancel="onScrubPointerUp"
|
||||
>
|
||||
<span
|
||||
v-for="t in ticks"
|
||||
:key="t"
|
||||
class="tick"
|
||||
:class="{ 'tick-start': t === 0, 'tick-end': Math.abs(t - duration) < 1e-6 }"
|
||||
:style="{ left: pxFromTime(t) + 'px' }"
|
||||
>
|
||||
<span class="tick-line"></span>
|
||||
<span class="tick-label">{{ formatTick(t) }}</span>
|
||||
</span>
|
||||
<span class="scrub-handle" :style="{ left: pxFromTime(store.playhead) + 'px' }">
|
||||
<span class="scrub-handle-head"></span>
|
||||
<span class="scrub-handle-line"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tracks">
|
||||
<div v-for="layer in store.scene.layers" :key="layer.id" class="track">
|
||||
<span
|
||||
class="track-name"
|
||||
:class="{ selected: layer.id === store.selectedLayerId }"
|
||||
@click="store.selectLayer(layer.id)"
|
||||
>
|
||||
{{ layer.name }}
|
||||
</span>
|
||||
<div class="track-lane" :style="{ width: laneWidth + 'px' }" @click="onLaneClick">
|
||||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||||
<span
|
||||
v-for="seg in segmentsOf(layer.keyframes)"
|
||||
:key="seg.id"
|
||||
class="kf-seg"
|
||||
:class="`easing-${seg.easing}`"
|
||||
:style="segmentStyle(seg)"
|
||||
/>
|
||||
<span
|
||||
v-for="kf in layer.keyframes"
|
||||
:key="kf.id"
|
||||
class="kf"
|
||||
:style="{ left: pxFromTime(kf.time) + 'px' }"
|
||||
:title="`Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`"
|
||||
@pointerdown="onKeyframePointerDown($event, layer.id, kf.id)"
|
||||
@pointermove="onKeyframePointerMove"
|
||||
@pointerup="onKeyframePointerUp"
|
||||
@pointercancel="onKeyframePointerUp"
|
||||
@click="onKeyframeClick($event, layer.id, kf.time)"
|
||||
@dblclick="onKeyframeDblClick($event, layer.id, kf.time)"
|
||||
@contextmenu="onKeyframeContextMenu($event, layer.id, kf.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!store.scene.layers.length" class="empty">Noch keine Layer.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="menu"
|
||||
ref="menuRef"
|
||||
class="kf-menu"
|
||||
:style="{ left: menu.x + 'px', top: menu.y + 'px' }"
|
||||
>
|
||||
<div class="kf-menu-section">
|
||||
<div class="kf-menu-header">Easing</div>
|
||||
<button
|
||||
v-for="opt in EASING_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="kf-menu-item"
|
||||
:class="{ active: menu.currentEasing === opt.value }"
|
||||
:disabled="menu.isFirst"
|
||||
@click="applyEasing(opt.value)"
|
||||
>
|
||||
<span class="check">
|
||||
<PhCheck v-if="menu.currentEasing === opt.value" :size="12" weight="bold" />
|
||||
</span>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
<div v-if="menu.isFirst" class="kf-menu-note">Erster Keyframe — Easing ohne Effekt</div>
|
||||
</div>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item" @click="duplicateFromMenu">
|
||||
<PhCopy :size="12" />
|
||||
Duplizieren
|
||||
</button>
|
||||
<button class="kf-menu-item danger" @click="deleteFromMenu">
|
||||
<PhTrash :size="12" />
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timeline {
|
||||
--track-name-width: 140px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tl-scroll {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tl-content {
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scrub-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
height: 28px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.name-spacer {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
width: var(--track-name-width);
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 3px 8px;
|
||||
min-width: 22px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.mode-btn:hover:not(:disabled) {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mode-btn + .mode-btn {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.scrub-ruler {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tick.tick-start {
|
||||
transform: translateX(0);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tick.tick-end {
|
||||
transform: translateX(-100%);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.tick-line {
|
||||
width: 1px;
|
||||
height: 6px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.tick-label {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding-top: 3px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.scrub-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: -1px;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scrub-handle-head {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transform: rotate(45deg);
|
||||
margin-top: 2px;
|
||||
box-shadow: 0 0 0 2px var(--panel), 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.scrub-handle-line {
|
||||
flex: 1;
|
||||
width: 1px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 140ms var(--ease);
|
||||
}
|
||||
|
||||
.track-name {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
width: var(--track-name-width);
|
||||
padding: 0 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
border-right: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
transition: color 140ms var(--ease), background 140ms var(--ease);
|
||||
}
|
||||
|
||||
.track-name:hover {
|
||||
background: var(--panel-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.track-name.selected {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.track-lane {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--accent);
|
||||
pointer-events: none;
|
||||
transform: translateX(-0.5px);
|
||||
}
|
||||
|
||||
.kf-seg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 3px;
|
||||
margin-top: -1.5px;
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
background: var(--accent);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.kf-seg.easing-linear {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.kf-seg.easing-easeIn {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(110, 168, 255, 0.15) 0%,
|
||||
rgba(110, 168, 255, 0.25) 30%,
|
||||
rgba(110, 168, 255, 1) 100%
|
||||
);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.kf-seg.easing-easeOut {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(110, 168, 255, 1) 0%,
|
||||
rgba(110, 168, 255, 0.25) 70%,
|
||||
rgba(110, 168, 255, 0.15) 100%
|
||||
);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.kf-seg.easing-easeInOut {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(110, 168, 255, 0.15) 0%,
|
||||
rgba(110, 168, 255, 1) 50%,
|
||||
rgba(110, 168, 255, 0.15) 100%
|
||||
);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.kf {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
margin-left: -5.5px;
|
||||
margin-top: -5.5px;
|
||||
background: var(--accent);
|
||||
transform: rotate(45deg);
|
||||
border-radius: 2px;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
transition: background 120ms var(--ease), transform 120ms var(--ease);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.kf:hover {
|
||||
background: #fff;
|
||||
transform: rotate(45deg) scale(1.15);
|
||||
}
|
||||
|
||||
.kf:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kf-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 180px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kf-menu-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kf-menu-header {
|
||||
padding: 6px 10px 2px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.kf-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kf-menu-item:hover:not(:disabled) {
|
||||
background: var(--panel-alt);
|
||||
}
|
||||
|
||||
.kf-menu-item:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.kf-menu-item.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.kf-menu-item.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.kf-menu-item.danger:hover:not(:disabled) {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.check {
|
||||
width: 14px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.kf-menu-note {
|
||||
padding: 2px 10px 4px;
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.kf-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user