This commit is contained in:
th committed 2026-04-19 19:07:51 +02:00
1 parent c6863f2bfc
commit 94e9846c13
11 files changed
+985 -136

No files matched your search

+158 -9
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import {
PhCircle,
PhImage,
PhImageSquare,
PhListBullets,
PhPlus,
PhSquare,
PhTextT,
@@ -17,6 +19,17 @@ const store = useSceneStore()
const fileInput = ref<HTMLInputElement | null>(null)
const uploadError = ref<string | null>(null)
const STORAGE_KEY = 'animatr.assetThumbs'
const storedThumbs = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
const showThumbs = ref(storedThumbs !== 'false')
watch(showThumbs, (v) => {
try {
localStorage.setItem(STORAGE_KEY, String(v))
} catch {
/* ignore */
}
})
function addSampleRect() {
const asset = store.addAsset({
kind: 'rect',
@@ -99,6 +112,28 @@ const assetsWithUsage = computed(() =>
inUse: store.scene.layers.some((l) => l.assetId === a.id),
})),
)
const THUMB_SIZE = 40
function rectPreviewStyle(asset: Extract<Asset, { kind: 'rect' }>) {
const scale = Math.min(THUMB_SIZE / asset.width, THUMB_SIZE / asset.height) * 0.9
return {
width: `${Math.max(6, asset.width * scale)}px`,
height: `${Math.max(6, asset.height * scale)}px`,
background: asset.fill,
borderRadius: '2px',
} as const
}
function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
const d = Math.min(THUMB_SIZE, asset.radius * 2) * 0.9
return {
width: `${d}px`,
height: `${d}px`,
background: asset.fill,
borderRadius: '50%',
} as const
}
</script>
<template>
@@ -131,11 +166,51 @@ const assetsWithUsage = computed(() =>
/>
<p v-if="uploadError" class="error">{{ uploadError }}</p>
<h4>Bibliothek</h4>
<ul class="assets">
<div class="lib-header">
<h4>Bibliothek</h4>
<button
class="thumb-toggle"
:title="showThumbs ? 'Thumbnails ausblenden' : 'Thumbnails anzeigen'"
@click="showThumbs = !showThumbs"
>
<PhImageSquare v-if="showThumbs" :size="13" />
<PhListBullets v-else :size="13" />
</button>
</div>
<ul class="assets" :class="{ compact: !showThumbs }">
<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>
<template v-if="showThumbs">
<div class="thumb">
<img
v-if="item.asset.kind === 'image'"
:src="item.asset.src"
:alt="item.asset.name"
draggable="false"
/>
<div
v-else-if="item.asset.kind === 'rect'"
class="shape-preview"
:style="rectPreviewStyle(item.asset)"
/>
<div
v-else-if="item.asset.kind === 'circle'"
class="shape-preview"
:style="circlePreviewStyle(item.asset)"
/>
<span
v-else-if="item.asset.kind === 'text'"
class="text-preview"
:style="{ color: item.asset.fill }"
>Aa</span>
</div>
</template>
<template v-else>
<component :is="assetIconMap[item.asset.kind]" :size="14" class="asset-icon" />
</template>
<div class="meta">
<span class="name" :title="item.asset.name">{{ item.asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ item.asset.kind }}</span>
</div>
<button
v-if="!item.inUse"
class="add-layer"
@@ -165,7 +240,7 @@ h3 {
}
h4 {
margin: 6px 0 4px;
margin: 0;
font-size: 10px;
text-transform: uppercase;
color: var(--text-dim);
@@ -211,6 +286,29 @@ h4 {
border-radius: var(--radius-sm);
}
.lib-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
}
.thumb-toggle {
width: 22px;
height: 22px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: var(--radius-sm);
}
.thumb-toggle:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.assets {
list-style: none;
margin: 0;
@@ -223,7 +321,7 @@ h4 {
.assets li {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
padding: 6px 8px;
border-radius: var(--radius-sm);
background: var(--panel-alt);
@@ -236,7 +334,43 @@ h4 {
}
.assets li.unused {
opacity: 0.85;
opacity: 0.9;
}
.assets.compact li {
gap: 8px;
padding: 6px 8px;
}
.thumb {
width: 40px;
height: 40px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
pointer-events: none;
}
.shape-preview {
display: block;
}
.text-preview {
font-weight: 600;
font-size: 18px;
line-height: 1;
letter-spacing: -0.02em;
}
.asset-icon {
@@ -244,13 +378,27 @@ h4 {
flex-shrink: 0;
}
.name {
.meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.add-layer {
width: 22px;
height: 22px;
@@ -259,6 +407,7 @@ h4 {
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
flex-shrink: 0;
}
.add-layer:hover:not(:disabled) {
+86 -3
View File
@@ -160,6 +160,8 @@ function circleConfig(
return {
...baseNodeConfig(layer, props),
radius: asset.radius,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: props.fill ?? asset.fill,
}
}
@@ -169,6 +171,8 @@ function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props
...baseNodeConfig(layer, props),
text: asset.text,
fontSize: asset.fontSize,
offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0,
fill: props.fill ?? asset.fill,
}
}
@@ -201,7 +205,7 @@ const pivotInfo = computed<PivotInfo | 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
if (!asset) return null
return { layerId: layer.id, sampled: sampleLayer(layer, store.playhead) }
})
@@ -249,7 +253,7 @@ function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
}
const transformerConfig = {
rotateEnabled: true,
rotateEnabled: false,
enabledAnchors: [
'top-left',
'top-center',
@@ -303,6 +307,7 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
}
}
if (target.findAncestor?.('.pivot-handle')) return
if (target.findAncestor?.('.rotation-handle')) return
if (target === target.getStage()) {
store.selectLayer(null)
return
@@ -339,6 +344,61 @@ function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
})
}
const ROT_ARM_PX = 44
const rotationHandleConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
return {
x: info.sampled.x + arm * Math.sin(rad),
y: info.sampled.y - arm * Math.cos(rad),
draggable: true,
name: 'rotation-handle',
}
})
const rotationArmConfig = computed(() => {
const info = pivotInfo.value
if (!info) return null
const arm = ROT_ARM_PX / zoom.value
const rad = (info.sampled.rotation * Math.PI) / 180
return {
points: [
info.sampled.x,
info.sampled.y,
info.sampled.x + arm * Math.sin(rad),
info.sampled.y - arm * Math.cos(rad),
],
stroke: '#6ea8ff',
strokeWidth: 1.25 / zoom.value,
dash: [3 / zoom.value, 3 / zoom.value],
listening: false,
}
})
const rotationKnobConfig = computed(() => ({
radius: 6 / zoom.value,
fill: '#ffffff',
stroke: '#6ea8ff',
strokeWidth: 2 / zoom.value,
}))
function onRotationHandleDragMove(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 (dx === 0 && dy === 0) return
const rad = Math.atan2(dx, -dy)
let deg = (rad * 180) / Math.PI
if (deg > 180) deg -= 360
if (deg < -180) deg += 360
store.upsertKeyframe(info.layerId, store.playhead, { rotation: deg })
}
let ro: ResizeObserver | null = null
function onWheel(e: WheelEvent) {
@@ -363,8 +423,14 @@ let panStartViewX = 0
let panStartViewY = 0
let panPointerId = -1
function isPanTrigger(e: PointerEvent | MouseEvent) {
if (e.button === 1) return true
if (e.button === 0 && e.ctrlKey) return true
return false
}
function onContainerPointerDown(e: PointerEvent) {
if (e.button !== 1) return
if (!isPanTrigger(e)) return
e.preventDefault()
panning = true
panStartX = e.clientX
@@ -376,6 +442,13 @@ function onContainerPointerDown(e: PointerEvent) {
if (container.value) container.value.style.cursor = 'grabbing'
}
function onContainerMouseDownCapture(e: MouseEvent) {
if (isPanTrigger(e)) {
e.stopPropagation()
e.preventDefault()
}
}
function onContainerPointerMove(e: PointerEvent) {
if (!panning || e.pointerId !== panPointerId) return
viewX.value = panStartViewX + (e.clientX - panStartX)
@@ -416,6 +489,7 @@ onMounted(() => {
ro.observe(container.value)
container.value.addEventListener('wheel', onWheel, { passive: false })
container.value.addEventListener('mousedown', onContainerMouseDownCapture, { capture: true })
container.value.addEventListener('pointerdown', onContainerPointerDown)
container.value.addEventListener('pointermove', onContainerPointerMove)
container.value.addEventListener('pointerup', onContainerPointerUp)
@@ -426,6 +500,7 @@ onBeforeUnmount(() => {
ro?.disconnect()
ro = null
container.value?.removeEventListener('wheel', onWheel)
container.value?.removeEventListener('mousedown', onContainerMouseDownCapture, { capture: true } as EventListenerOptions)
container.value?.removeEventListener('pointerdown', onContainerPointerDown)
container.value?.removeEventListener('pointermove', onContainerPointerMove)
container.value?.removeEventListener('pointerup', onContainerPointerUp)
@@ -466,6 +541,7 @@ onBeforeUnmount(() => {
/>
</template>
<v-rect :config="canvasBorderConfig" />
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
<v-group
v-if="pivotHandleConfig"
:config="pivotHandleConfig"
@@ -474,6 +550,13 @@ onBeforeUnmount(() => {
<v-circle :config="pivotRingConfig" />
<v-circle :config="pivotDotConfig" />
</v-group>
<v-group
v-if="rotationHandleConfig"
:config="rotationHandleConfig"
@dragmove="onRotationHandleDragMove"
>
<v-circle :config="rotationKnobConfig" />
</v-group>
</v-group>
<v-transformer ref="transformerRef" :config="transformerConfig" />
</v-layer>
+24 -6
View File
@@ -3,12 +3,13 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhCode,
PhDownloadSimple,
PhEngine,
PhFileHtml,
PhFilmSlate,
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadExport } from '@/lib/exportPlayer'
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadFromServer } from '@/lib/serverExport'
@@ -31,8 +32,13 @@ function sceneClone() {
return JSON.parse(JSON.stringify(store.scene))
}
function exportJs() {
downloadExport(sceneClone())
function exportScene() {
downloadSceneExport(sceneClone())
close()
}
function exportRuntime() {
downloadRuntime()
close()
}
@@ -133,14 +139,26 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</header>
<div v-if="!progress" class="options">
<button class="opt" @click="exportJs">
<button class="opt" @click="exportRuntime">
<div class="opt-icon"><PhEngine :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
animatr-runtime.js
<span class="badge">1× pro Seite</span>
</div>
<div class="opt-desc">Engine — einmal einbinden, beliebig viele Szenen teilen sich die Runtime</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportScene">
<div class="opt-icon"><PhCode :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
animation.js
<span class="badge">Web Embed</span>
<span class="badge">Szene</span>
</div>
<div class="opt-desc">Selbstständiges JS — in eine eigene Seite einbinden</div>
<div class="opt-desc">Nur Szenen-Daten — benötigt geladene Runtime</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
+353 -36
View File
@@ -1,8 +1,18 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhCheck, PhCopy, PhTrash } from '@phosphor-icons/vue'
import { PhArrowsSplit, PhCheck, PhCopy, PhMagnet, PhTrash } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import type { Easing, Keyframe } from '@/types/scene'
import { sampleLayer } from '@/lib/interpolate'
import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene'
interface TimelineTrack {
layerId: string
layer: Layer
kfs: Keyframe[]
kind: 'main' | 'position' | 'rotation'
label: string
isParent: boolean
}
const store = useSceneStore()
@@ -37,6 +47,49 @@ const totalWidth = computed(() => NAME_WIDTH + laneWidth.value)
type DisplayMode = 'seconds' | 'frames'
const displayMode = ref<DisplayMode>('seconds')
const snapEnabled = ref(true)
const SNAP_PX = 8
function allKeyframeTimes(): number[] {
const out: number[] = []
for (const layer of store.scene.layers) {
for (const k of layer.keyframes) out.push(k.time)
if (layer.positionKeyframes) for (const k of layer.positionKeyframes) out.push(k.time)
if (layer.rotationKeyframes) for (const k of layer.rotationKeyframes) out.push(k.time)
}
return out
}
function snapTime(t: number, ignoreKeyframeId?: string): number {
if (!snapEnabled.value) return t
const pps = effectivePxPerSec.value
if (pps <= 0) return t
const thresholdTime = SNAP_PX / pps
let best = t
let bestDist = thresholdTime
const times = ignoreKeyframeId ? keyframeTimesExcept(ignoreKeyframeId) : allKeyframeTimes()
for (const kt of times) {
const d = Math.abs(kt - t)
if (d <= bestDist) {
bestDist = d
best = kt
}
}
return clamp(best, 0, duration.value)
}
function keyframeTimesExcept(id: string): number[] {
const out: number[] = []
for (const layer of store.scene.layers) {
for (const k of layer.keyframes) if (k.id !== id) out.push(k.time)
if (layer.positionKeyframes)
for (const k of layer.positionKeyframes) if (k.id !== id) out.push(k.time)
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (k.id !== id) out.push(k.time)
}
return out
}
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]
@@ -90,7 +143,7 @@ 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)
store.setPlayhead(snapTime(ratio * duration.value))
}
function onScrubPointerDown(e: PointerEvent) {
@@ -116,7 +169,42 @@ function onLaneClick(e: MouseEvent) {
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)
store.setPlayhead(snapTime(ratio * duration.value))
}
function onLaneDblClick(e: MouseEvent, track: TimelineTrack) {
e.stopPropagation()
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))
const time = snapTime(ratio * duration.value)
const layer = store.scene.layers.find((l) => l.id === track.layerId)
if (!layer) return
const sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = {}
if (track.kind === 'position') {
props.x = sampled.x
props.y = sampled.y
} else if (track.kind === 'rotation') {
props.rotation = sampled.rotation
} else if (layer.separated) {
props.scaleX = sampled.scaleX
props.scaleY = sampled.scaleY
props.opacity = sampled.opacity
if (sampled.fill) props.fill = sampled.fill
} else {
props.x = sampled.x
props.y = sampled.y
props.scaleX = sampled.scaleX
props.scaleY = sampled.scaleY
props.rotation = sampled.rotation
props.opacity = sampled.opacity
if (sampled.fill) props.fill = sampled.fill
}
store.selectLayer(track.layerId)
store.setPlayhead(time)
store.upsertKeyframe(track.layerId, time, props)
}
function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
@@ -130,12 +218,54 @@ function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
store.setPlayhead(time)
}
function onKeyframeDblClick(e: MouseEvent, layerId: string, time: number) {
function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string) {
e.stopPropagation()
if (kfDrag.value?.moved) return
store.removeKeyframeAt(layerId, time)
store.removeKeyframe(layerId, keyframeId)
}
const tracks = computed<TimelineTrack[]>(() => {
const out: TimelineTrack[] = []
for (const layer of store.scene.layers) {
if (!layer.separated) {
out.push({
layerId: layer.id,
layer,
kfs: layer.keyframes,
kind: 'main',
label: layer.name,
isParent: true,
})
} else {
out.push({
layerId: layer.id,
layer,
kfs: layer.keyframes,
kind: 'main',
label: layer.name,
isParent: true,
})
out.push({
layerId: layer.id,
layer,
kfs: layer.positionKeyframes ?? [],
kind: 'position',
label: 'Position',
isParent: false,
})
out.push({
layerId: layer.id,
layer,
kfs: layer.rotationKeyframes ?? [],
kind: 'rotation',
label: 'Rotation',
isParent: false,
})
}
}
return out
})
interface KfDrag {
layerId: string
keyframeId: string
@@ -173,7 +303,7 @@ function onKeyframePointerMove(e: PointerEvent) {
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
const newTime = snapTime(ratio * duration.value, d.keyframeId)
store.setKeyframeTime(d.layerId, d.keyframeId, newTime)
store.setPlayhead(newTime)
}
@@ -239,13 +369,18 @@ interface KfMenuState {
const menu = ref<KfMenuState | null>(null)
const menuRef = ref<HTMLDivElement | null>(null)
function onKeyframeContextMenu(e: MouseEvent, layerId: string, keyframeId: string) {
function onKeyframeContextMenu(
e: MouseEvent,
layerId: string,
keyframeId: string,
kfs: Keyframe[],
) {
e.preventDefault()
e.stopPropagation()
const layer = store.scene.layers.find((l) => l.id === layerId)
const kf = layer?.keyframes.find((k) => k.id === keyframeId)
const kf = kfs.find((k) => k.id === keyframeId)
if (!layer || !kf) return
const sorted = [...layer.keyframes].sort((a, b) => a.time - b.time)
const sorted = [...kfs].sort((a, b) => a.time - b.time)
menu.value = {
layerId,
keyframeId: kf.id,
@@ -260,13 +395,13 @@ function onKeyframeContextMenu(e: MouseEvent, layerId: string, keyframeId: strin
function applyEasing(easing: Easing) {
if (!menu.value) return
store.setKeyframeEasing(menu.value.layerId, menu.value.time, easing)
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
menu.value = null
}
function deleteFromMenu() {
if (!menu.value) return
store.removeKeyframeAt(menu.value.layerId, menu.value.time)
store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
menu.value = null
}
@@ -275,24 +410,76 @@ function duplicateFromMenu() {
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)
const all = layer
? [
...layer.keyframes,
...(layer.positionKeyframes ?? []),
...(layer.rotationKeyframes ?? []),
]
: []
const newKf = all.find((k) => k.id === newId)
if (newKf) store.setPlayhead(newKf.time)
}
menu.value = null
}
interface TrackMenuState {
layerId: string
x: number
y: number
separated: boolean
}
const trackMenu = ref<TrackMenuState | null>(null)
const trackMenuRef = ref<HTMLDivElement | null>(null)
function onTrackContextMenu(e: MouseEvent, layerId: string) {
e.preventDefault()
e.stopPropagation()
const layer = store.scene.layers.find((l) => l.id === layerId)
if (!layer) return
store.selectLayer(layerId)
trackMenu.value = {
layerId,
x: e.clientX,
y: e.clientY,
separated: !!layer.separated,
}
}
function toggleSeparated() {
if (!trackMenu.value) return
store.setLayerSeparated(trackMenu.value.layerId, !trackMenu.value.separated)
trackMenu.value = null
}
function deleteLayer() {
if (!trackMenu.value) return
store.removeLayer(trackMenu.value.layerId)
trackMenu.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
if (menu.value && menuRef.value && e.target instanceof Node) {
if (!menuRef.value.contains(e.target)) menu.value = null
} else if (menu.value) {
menu.value = null
}
if (trackMenu.value && trackMenuRef.value && e.target instanceof Node) {
if (!trackMenuRef.value.contains(e.target)) trackMenu.value = null
} else if (trackMenu.value) {
trackMenu.value = null
}
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') menu.value = null
if (e.key === 'Escape') {
menu.value = null
trackMenu.value = null
}
}
watch(menu, (val) => {
if (val) {
watch([menu, trackMenu], ([m, tm]) => {
if (m || tm) {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
document.addEventListener('keydown', onKeyDown)
} else {
@@ -364,6 +551,14 @@ onBeforeUnmount(() => {
@click="displayMode = 'frames'"
>fr</button>
</div>
<button
class="snap-btn"
:class="{ active: snapEnabled }"
:title="snapEnabled ? 'Snapping aktiv (Playhead & Keyframes rasten aneinander ein)' : 'Snapping aus'"
@click="snapEnabled = !snapEnabled"
>
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
</button>
</div>
<div
class="scrub-ruler"
@@ -390,40 +585,64 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="tracks">
<div v-for="layer in store.scene.layers" :key="layer.id" class="track">
<div
v-for="(track, idx) in tracks"
:key="track.layerId + ':' + track.kind"
class="track"
:class="{
'sub-track': !track.isParent,
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
}"
>
<span
class="track-name"
:class="{ selected: layer.id === store.selectedLayerId }"
@click="store.selectLayer(layer.id)"
:class="{
selected: track.layerId === store.selectedLayerId && track.isParent,
parent: track.isParent,
sub: !track.isParent,
}"
@click="store.selectLayer(track.layerId)"
@contextmenu="track.isParent ? onTrackContextMenu($event, track.layerId) : null"
>
{{ layer.name }}
<PhArrowsSplit
v-if="track.isParent && track.layer.separated"
:size="11"
class="name-badge"
/>
<span v-if="!track.isParent" class="sub-indent">↳</span>
{{ track.label }}
</span>
<div class="track-lane" :style="{ width: laneWidth + 'px' }" @click="onLaneClick">
<div
class="track-lane"
:style="{ width: laneWidth + 'px' }"
@click="onLaneClick"
@dblclick="onLaneDblClick($event, track)"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="seg in segmentsOf(layer.keyframes)"
v-for="seg in segmentsOf(track.kfs)"
:key="seg.id"
class="kf-seg"
:class="`easing-${seg.easing}`"
:style="segmentStyle(seg)"
/>
<span
v-for="kf in layer.keyframes"
v-for="kf in track.kfs"
: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)"
@pointerdown="onKeyframePointerDown($event, track.layerId, 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)"
@click="onKeyframeClick($event, track.layerId, kf.time)"
@dblclick="onKeyframeDblClick($event, track.layerId, kf.id)"
@contextmenu="onKeyframeContextMenu($event, track.layerId, kf.id, track.kfs)"
/>
</div>
</div>
<p v-if="!store.scene.layers.length" class="empty">Noch keine Layer.</p>
<p v-if="!tracks.length" class="empty">Noch keine Layer.</p>
</div>
</div>
</div>
@@ -461,6 +680,25 @@ onBeforeUnmount(() => {
Löschen
</button>
</div>
<div
v-if="trackMenu"
ref="trackMenuRef"
class="kf-menu"
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
>
<button class="kf-menu-item" @click="toggleSeparated">
<span class="check">
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
</span>
Separate Position and Rotation
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteLayer">
<PhTrash :size="12" />
Layer löschen
</button>
</div>
</div>
</template>
@@ -506,9 +744,36 @@ onBeforeUnmount(() => {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
}
.snap-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 20px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text-dim);
cursor: pointer;
transition: background 140ms var(--ease), color 140ms var(--ease);
}
.snap-btn:hover {
background: var(--panel-alt);
color: var(--text);
}
.snap-btn.active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent-soft);
}
.mode-toggle {
display: inline-flex;
border: 1px solid var(--border);
@@ -600,13 +865,12 @@ onBeforeUnmount(() => {
}
.scrub-handle-head {
width: 11px;
height: 11px;
width: 12px;
height: 14px;
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);
box-shadow: 0 0 0 1px var(--panel), 0 2px 6px rgba(0, 0, 0, 0.4);
}
.scrub-handle-line {
@@ -628,6 +892,15 @@ onBeforeUnmount(() => {
transition: background 140ms var(--ease);
}
.track.inactive .track-lane {
opacity: 0.4;
transition: opacity 140ms var(--ease);
}
.track.inactive:hover .track-lane {
opacity: 0.75;
}
.track-name {
position: sticky;
left: 0;
@@ -656,7 +929,51 @@ onBeforeUnmount(() => {
.track-name.selected {
color: var(--accent);
background: var(--accent-soft);
background:
linear-gradient(var(--accent-soft), var(--accent-soft)),
var(--panel);
}
.track.sub-track .track-name {
padding-left: 24px;
font-size: 11px;
color: var(--text-dim);
background: var(--panel-alt);
}
.track.sub-track .track-name:hover {
background: var(--panel-hover);
}
.track.sub-track {
height: 26px;
}
.track.sub-track .track-lane {
background: linear-gradient(
to bottom,
transparent 0%,
transparent 40%,
var(--panel-alt) 40%,
var(--panel-alt) 60%,
transparent 60%
);
background-size: 100% 2px;
background-repeat: no-repeat;
background-position: center;
}
.name-badge {
color: var(--accent);
margin-right: 4px;
flex-shrink: 0;
}
.sub-indent {
color: var(--text-dim);
margin-right: 4px;
font-size: 10px;
opacity: 0.6;
}
.track-lane {