This commit is contained in:
th committed 2026-04-19 17:53:36 +02:00
commit c6863f2bfc
34 files changed
+6909

No files matched your search

+275
View File
@@ -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>
+581
View File
@@ -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>
+540
View File
@@ -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>
+460
View File
@@ -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>
+288
View File
@@ -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>
+228
View File
@@ -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>
+839
View File
@@ -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>