bak
This commit is contained in:
1 parent
cc6b11e61d
commit
b977ee35a8
11 files changed
+1641
-310
No files matched your search
+644
-142
@@ -12,21 +12,56 @@ import {
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene'
|
||||
import type {
|
||||
AnimatableProps,
|
||||
Clip,
|
||||
ClipInstance,
|
||||
Easing,
|
||||
Keyframe,
|
||||
Layer,
|
||||
} from '@/types/scene'
|
||||
|
||||
interface TimelineTrack {
|
||||
interface TimelineLayerTrack {
|
||||
type: 'layer'
|
||||
layerId: string
|
||||
layer: Layer
|
||||
kfs: Keyframe[]
|
||||
kind: 'main' | 'position' | 'rotation'
|
||||
label: string
|
||||
isParent: boolean
|
||||
layerIndex: number
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
interface TimelineClipTrack {
|
||||
type: 'clip'
|
||||
clipTrackId: string
|
||||
name: string
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
type TimelineTrack = TimelineLayerTrack | TimelineClipTrack
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
const duration = computed(() => store.scene.duration)
|
||||
const duration = computed(() => store.activeDuration)
|
||||
const inClipEdit = computed(() => store.activeClipId !== null)
|
||||
const clipInstancesByTrack = computed<Record<string, ClipInstance[]>>(() => {
|
||||
const out: Record<string, ClipInstance[]> = {}
|
||||
for (const ci of store.scene.clipInstances ?? []) {
|
||||
;(out[ci.clipTrackId] ||= []).push(ci)
|
||||
}
|
||||
return out
|
||||
})
|
||||
const clipById = computed(() => {
|
||||
const m = new Map<string, Clip>()
|
||||
for (const c of store.scene.clips ?? []) m.set(c.id, c)
|
||||
return m
|
||||
})
|
||||
const clipTrackById = computed(() => {
|
||||
const m = new Map<string, NonNullable<typeof store.scene.clipTracks>[number]>()
|
||||
for (const t of store.scene.clipTracks ?? []) m.set(t.id, t)
|
||||
return m
|
||||
})
|
||||
|
||||
const NAME_WIDTH = 140
|
||||
const MIN_PX_PER_SEC = 2
|
||||
@@ -163,14 +198,14 @@ function onLaneClick(e: MouseEvent) {
|
||||
store.setPlayhead(snapTime(ratio * duration.value))
|
||||
}
|
||||
|
||||
function onLaneDblClick(e: MouseEvent, track: TimelineTrack) {
|
||||
function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
|
||||
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)
|
||||
const layer = store.activeLayers.find((l) => l.id === track.layerId)
|
||||
if (!layer) return
|
||||
const sampled = sampleLayer(layer, time)
|
||||
const props: Partial<AnimatableProps> = {}
|
||||
@@ -217,64 +252,63 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
|
||||
|
||||
const tracks = computed<TimelineTrack[]>(() => {
|
||||
const out: TimelineTrack[] = []
|
||||
store.scene.layers.forEach((layer, layerIndex) => {
|
||||
if (!layer.separated) {
|
||||
out.push({
|
||||
layerId: layer.id,
|
||||
layer,
|
||||
kfs: layer.keyframes,
|
||||
kind: 'main',
|
||||
label: layer.name,
|
||||
isParent: true,
|
||||
layerIndex,
|
||||
})
|
||||
} else {
|
||||
out.push({
|
||||
layerId: layer.id,
|
||||
layer,
|
||||
kfs: layer.keyframes,
|
||||
kind: 'main',
|
||||
label: layer.name,
|
||||
isParent: true,
|
||||
layerIndex,
|
||||
})
|
||||
out.push({
|
||||
layerId: layer.id,
|
||||
layer,
|
||||
kfs: layer.positionKeyframes ?? [],
|
||||
kind: 'position',
|
||||
label: 'Position',
|
||||
isParent: false,
|
||||
layerIndex,
|
||||
})
|
||||
out.push({
|
||||
layerId: layer.id,
|
||||
layer,
|
||||
kfs: layer.rotationKeyframes ?? [],
|
||||
kind: 'rotation',
|
||||
label: 'Rotation',
|
||||
isParent: false,
|
||||
layerIndex,
|
||||
})
|
||||
if (store.activeClipId) {
|
||||
// Clip-edit scope: only show clip's layers (no clip tracks inside a clip)
|
||||
store.activeLayers.forEach((layer, orderIndex) => {
|
||||
if (!layer.separated) {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||
} else {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: 'Position', isParent: false, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: 'Rotation', isParent: false, orderIndex })
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
const order = store.scene.trackOrder ?? []
|
||||
const layerById = new Map<string, Layer>()
|
||||
for (const l of store.scene.layers) layerById.set(l.id, l)
|
||||
order.forEach((id, orderIndex) => {
|
||||
const layer = layerById.get(id)
|
||||
if (layer) {
|
||||
if (!layer.separated) {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||
} else {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: 'Position', isParent: false, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: 'Rotation', isParent: false, orderIndex })
|
||||
}
|
||||
return
|
||||
}
|
||||
const ct = clipTrackById.value.get(id)
|
||||
if (ct) {
|
||||
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? 'Clip-Spur', orderIndex })
|
||||
}
|
||||
})
|
||||
return out
|
||||
})
|
||||
|
||||
function trackDndId(track: TimelineTrack): string {
|
||||
return track.type === 'layer' ? track.layerId : track.clipTrackId
|
||||
}
|
||||
|
||||
function isTrackHead(track: TimelineTrack): boolean {
|
||||
if (track.type === 'clip') return true
|
||||
return track.isParent
|
||||
}
|
||||
|
||||
function isDropBefore(track: TimelineTrack): boolean {
|
||||
return (
|
||||
dropIndex.value !== null &&
|
||||
track.isParent &&
|
||||
dropIndex.value === track.layerIndex &&
|
||||
dragLayerId.value !== track.layerId
|
||||
)
|
||||
if (dropIndex.value === null) return false
|
||||
if (!isTrackHead(track)) return false
|
||||
if (dragTrackId.value === trackDndId(track)) return false
|
||||
return dropIndex.value === track.orderIndex
|
||||
}
|
||||
|
||||
function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined): boolean {
|
||||
if (dropIndex.value === null) return false
|
||||
if (dragLayerId.value === track.layerId) return false
|
||||
if (dropIndex.value !== track.layerIndex + 1) return false
|
||||
if (nextTrack && nextTrack.layerIndex === track.layerIndex) return false
|
||||
if (dragTrackId.value === trackDndId(track)) return false
|
||||
if (dropIndex.value !== track.orderIndex + 1) return false
|
||||
if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -454,55 +488,230 @@ interface TrackMenuState {
|
||||
separated: boolean
|
||||
}
|
||||
|
||||
const dragLayerId = ref<string | null>(null)
|
||||
const dragTrackId = ref<string | null>(null)
|
||||
const dropIndex = ref<number | null>(null)
|
||||
|
||||
function onTrackDragStart(e: DragEvent, layerId: string) {
|
||||
function onTrackDragStart(e: DragEvent, trackId: string) {
|
||||
if (!e.dataTransfer) return
|
||||
dragLayerId.value = layerId
|
||||
dragTrackId.value = trackId
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
try {
|
||||
e.dataTransfer.setData('text/plain', layerId)
|
||||
e.dataTransfer.setData('text/plain', trackId)
|
||||
} catch {
|
||||
/* Safari older versions */
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function onTrackDragOver(e: DragEvent, layerId: string) {
|
||||
if (!dragLayerId.value) return
|
||||
function onTrackDragOver(e: DragEvent, orderIndex: number) {
|
||||
if (!dragTrackId.value) return
|
||||
if (!e.dataTransfer) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
const midY = rect.top + rect.height / 2
|
||||
const index = store.scene.layers.findIndex((l) => l.id === layerId)
|
||||
if (index < 0) return
|
||||
dropIndex.value = e.clientY < midY ? index : index + 1
|
||||
dropIndex.value = e.clientY < midY ? orderIndex : orderIndex + 1
|
||||
}
|
||||
|
||||
function onTrackDragLeave() {
|
||||
/* dropIndex updates on next dragover, keep as-is to avoid flicker */
|
||||
/* keep dropIndex to avoid flicker */
|
||||
}
|
||||
|
||||
function onTrackDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
const srcId = dragLayerId.value
|
||||
const srcId = dragTrackId.value
|
||||
const target = dropIndex.value
|
||||
dragLayerId.value = null
|
||||
dragTrackId.value = null
|
||||
dropIndex.value = null
|
||||
if (!srcId || target === null) return
|
||||
const from = store.scene.layers.findIndex((l) => l.id === srcId)
|
||||
const order = store.scene.trackOrder ?? []
|
||||
const from = order.indexOf(srcId)
|
||||
if (from < 0) return
|
||||
let to = target
|
||||
if (from < to) to -= 1
|
||||
store.moveLayer(from, to)
|
||||
store.moveTrack(from, to)
|
||||
}
|
||||
|
||||
function onTrackDragEnd() {
|
||||
dragLayerId.value = null
|
||||
dragTrackId.value = null
|
||||
dropIndex.value = null
|
||||
}
|
||||
|
||||
interface ClipDrag {
|
||||
kind: 'new' | 'move'
|
||||
clipId: string
|
||||
instanceId?: string
|
||||
grabOffsetSec: number
|
||||
}
|
||||
|
||||
const clipDrag = ref<ClipDrag | null>(null)
|
||||
|
||||
function pixelTimeFromEvent(e: DragEvent, el: HTMLElement): number {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0) return 0
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
return ratio * duration.value
|
||||
}
|
||||
|
||||
function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) {
|
||||
if (!clipDrag.value) {
|
||||
if (e.dataTransfer?.types.includes('application/x-animatr-clip')) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = clipDrag.value.kind === 'new' ? 'copy' : 'move'
|
||||
}
|
||||
|
||||
function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
|
||||
e.preventDefault()
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const time = pixelTimeFromEvent(e, el)
|
||||
const cd = clipDrag.value
|
||||
if (cd) {
|
||||
const start = Math.max(0, time - cd.grabOffsetSec)
|
||||
if (cd.kind === 'move' && cd.instanceId) {
|
||||
store.moveClipInstance(cd.instanceId, start, clipTrackId)
|
||||
} else {
|
||||
store.addClipInstance(cd.clipId, start, clipTrackId)
|
||||
}
|
||||
clipDrag.value = null
|
||||
return
|
||||
}
|
||||
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
|
||||
if (clipId) {
|
||||
store.addClipInstance(clipId, time, clipTrackId)
|
||||
}
|
||||
}
|
||||
|
||||
function onClipBlockDragStart(e: DragEvent, ci: ClipInstance) {
|
||||
if (clipTrim.value) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!e.dataTransfer) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
const visible = visibleDurationOf(ci)
|
||||
const grabOffsetSec =
|
||||
rect.width > 0 ? ((e.clientX - rect.left) / rect.width) * visible : 0
|
||||
clipDrag.value = { kind: 'move', clipId: ci.clipId, instanceId: ci.id, grabOffsetSec }
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
try {
|
||||
e.dataTransfer.setData('application/x-animatr-clip-move', ci.id)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function onClipBlockDragEnd() {
|
||||
clipDrag.value = null
|
||||
}
|
||||
|
||||
function addClipTrack() {
|
||||
store.addClipTrack()
|
||||
}
|
||||
|
||||
function clipDurationOf(ci: ClipInstance): number {
|
||||
return clipById.value.get(ci.clipId)?.duration ?? 1
|
||||
}
|
||||
|
||||
function visibleDurationOf(ci: ClipInstance): number {
|
||||
return Math.max(
|
||||
0.05,
|
||||
clipDurationOf(ci) - (ci.trimStart ?? 0) - (ci.trimEnd ?? 0),
|
||||
)
|
||||
}
|
||||
|
||||
function clipBlockStyle(ci: ClipInstance) {
|
||||
return {
|
||||
left: pxFromTime(ci.startTime) + 'px',
|
||||
width: Math.max(6, pxFromTime(visibleDurationOf(ci))) + 'px',
|
||||
}
|
||||
}
|
||||
|
||||
function trimTooltip(ci: ClipInstance): string {
|
||||
const name = clipById.value.get(ci.clipId)?.name ?? '?'
|
||||
const dur = visibleDurationOf(ci).toFixed(2)
|
||||
const trims: string[] = []
|
||||
if ((ci.trimStart ?? 0) > 0) trims.push(`Start -${(ci.trimStart ?? 0).toFixed(2)}s`)
|
||||
if ((ci.trimEnd ?? 0) > 0) trims.push(`Ende -${(ci.trimEnd ?? 0).toFixed(2)}s`)
|
||||
const suffix = trims.length ? ` · ${trims.join(' · ')}` : ''
|
||||
return `${name} · ${dur}s${suffix} · Kanten ziehen zum Verkürzen, Doppelklick zum Öffnen`
|
||||
}
|
||||
|
||||
interface ClipTrimState {
|
||||
instanceId: string
|
||||
side: 'start' | 'end'
|
||||
pointerId: number
|
||||
startX: number
|
||||
initialStartTime: number
|
||||
initialTrimStart: number
|
||||
initialTrimEnd: number
|
||||
}
|
||||
|
||||
const clipTrim = ref<ClipTrimState | null>(null)
|
||||
|
||||
function onClipHandlePointerDown(e: PointerEvent, ci: ClipInstance, side: 'start' | 'end') {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const el = e.currentTarget as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
clipTrim.value = {
|
||||
instanceId: ci.id,
|
||||
side,
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
initialStartTime: ci.startTime,
|
||||
initialTrimStart: ci.trimStart ?? 0,
|
||||
initialTrimEnd: ci.trimEnd ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function onClipHandlePointerMove(e: PointerEvent) {
|
||||
const t = clipTrim.value
|
||||
if (!t || t.pointerId !== e.pointerId) return
|
||||
const pps = effectivePxPerSec.value
|
||||
if (pps <= 0) return
|
||||
const deltaSec = (e.clientX - t.startX) / pps
|
||||
if (t.side === 'start') {
|
||||
// shift block right = trim more from start + shift startTime
|
||||
store.trimClipInstance(t.instanceId, {
|
||||
trimStart: t.initialTrimStart + deltaSec,
|
||||
startTime: t.initialStartTime + deltaSec,
|
||||
})
|
||||
} else {
|
||||
// shift right edge right = less trimEnd (reveal more)
|
||||
store.trimClipInstance(t.instanceId, {
|
||||
trimEnd: t.initialTrimEnd - deltaSec,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onClipHandlePointerUp(e: PointerEvent) {
|
||||
const t = clipTrim.value
|
||||
if (!t || t.pointerId !== e.pointerId) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||||
clipTrim.value = null
|
||||
}
|
||||
|
||||
function onClipBlockDblClick(ci: ClipInstance) {
|
||||
store.enterClip(ci.clipId)
|
||||
}
|
||||
|
||||
function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
|
||||
e.preventDefault()
|
||||
if (!confirm('Clip-Instanz entfernen?')) return
|
||||
store.removeClipInstance(ci.id)
|
||||
}
|
||||
|
||||
function exitClip() {
|
||||
store.exitClip()
|
||||
}
|
||||
const trackMenu = ref<TrackMenuState | null>(null)
|
||||
const trackMenuRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -659,87 +868,175 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="tracks">
|
||||
<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,
|
||||
'drop-before': isDropBefore(track),
|
||||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||||
dragging: track.isParent && dragLayerId === track.layerId,
|
||||
}"
|
||||
@dragover="track.isParent ? onTrackDragOver($event, track.layerId) : null"
|
||||
@drop="track.isParent ? onTrackDrop($event) : null"
|
||||
@dragleave="onTrackDragLeave"
|
||||
>
|
||||
<span
|
||||
class="track-name"
|
||||
:class="{
|
||||
selected: track.layerId === store.selectedLayerId && track.isParent,
|
||||
parent: track.isParent,
|
||||
sub: !track.isParent,
|
||||
}"
|
||||
:draggable="track.isParent"
|
||||
@click="store.selectLayer(track.layerId)"
|
||||
@contextmenu="track.isParent ? onTrackContextMenu($event, track.layerId) : null"
|
||||
@dragstart="track.isParent ? onTrackDragStart($event, track.layerId) : null"
|
||||
@dragend="onTrackDragEnd"
|
||||
>
|
||||
<PhDotsSixVertical
|
||||
v-if="track.isParent"
|
||||
:size="12"
|
||||
class="drag-handle"
|
||||
/>
|
||||
<PhArrowsSplit
|
||||
v-if="track.isParent && track.layer.separated"
|
||||
:size="11"
|
||||
class="name-badge"
|
||||
/>
|
||||
<span v-if="!track.isParent" class="sub-indent">↳</span>
|
||||
{{ track.label }}
|
||||
</span>
|
||||
<template v-for="(track, idx) in tracks" :key="track.type === 'layer' ? `l:${track.layerId}:${track.kind}` : `c:${track.clipTrackId}`">
|
||||
<!-- Layer track -->
|
||||
<div
|
||||
class="track-lane"
|
||||
:style="{ width: laneWidth + 'px' }"
|
||||
@click="onLaneClick"
|
||||
@dblclick="onLaneDblClick($event, track)"
|
||||
v-if="track.type === 'layer'"
|
||||
class="track"
|
||||
:class="{
|
||||
'sub-track': !track.isParent,
|
||||
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
|
||||
'drop-before': isDropBefore(track),
|
||||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||||
dragging: track.isParent && dragTrackId === track.layerId,
|
||||
}"
|
||||
@dragover="track.isParent ? onTrackDragOver($event, track.orderIndex) : null"
|
||||
@drop="track.isParent ? onTrackDrop($event) : null"
|
||||
@dragleave="onTrackDragLeave"
|
||||
>
|
||||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||||
<span
|
||||
v-for="seg in segmentsOf(track.kfs)"
|
||||
:key="seg.id"
|
||||
class="kf-seg"
|
||||
:class="[seg.held ? 'held' : `easing-${seg.easing}`]"
|
||||
:style="segmentStyle(seg)"
|
||||
/>
|
||||
<span
|
||||
v-for="kf in track.kfs"
|
||||
:key="kf.id"
|
||||
class="kf"
|
||||
:class="{ hold: kf.hold }"
|
||||
:style="{ left: pxFromTime(kf.time) + 'px' }"
|
||||
:title="
|
||||
kf.hold
|
||||
? `Still Frame ${formatKfTime(kf.time)} — hält bis zum nächsten Keyframe`
|
||||
: `Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`
|
||||
"
|
||||
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id)"
|
||||
@pointermove="onKeyframePointerMove"
|
||||
@pointerup="onKeyframePointerUp"
|
||||
@pointercancel="onKeyframePointerUp"
|
||||
@click="onKeyframeClick($event, track.layerId, kf.time)"
|
||||
@dblclick="onKeyframeDblClick($event, track.layerId, kf.id)"
|
||||
@contextmenu="onKeyframeContextMenu($event, track.layerId, kf.id, track.kfs)"
|
||||
/>
|
||||
class="track-name"
|
||||
:class="{
|
||||
selected: track.layerId === store.selectedLayerId && track.isParent,
|
||||
parent: track.isParent,
|
||||
sub: !track.isParent,
|
||||
}"
|
||||
:draggable="track.isParent"
|
||||
@click="store.selectLayer(track.layerId)"
|
||||
@contextmenu="track.isParent ? onTrackContextMenu($event, track.layerId) : null"
|
||||
@dragstart="track.isParent ? onTrackDragStart($event, track.layerId) : null"
|
||||
@dragend="onTrackDragEnd"
|
||||
>
|
||||
<PhDotsSixVertical v-if="track.isParent" :size="12" class="drag-handle" />
|
||||
<PhArrowsSplit
|
||||
v-if="track.isParent && track.layer.separated"
|
||||
:size="11"
|
||||
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"
|
||||
@dblclick="onLaneDblClick($event, track)"
|
||||
>
|
||||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||||
<span
|
||||
v-for="seg in segmentsOf(track.kfs)"
|
||||
:key="seg.id"
|
||||
class="kf-seg"
|
||||
:class="[seg.held ? 'held' : `easing-${seg.easing}`]"
|
||||
:style="segmentStyle(seg)"
|
||||
/>
|
||||
<span
|
||||
v-for="kf in track.kfs"
|
||||
:key="kf.id"
|
||||
class="kf"
|
||||
:class="{ hold: kf.hold }"
|
||||
:style="{ left: pxFromTime(kf.time) + 'px' }"
|
||||
:title="
|
||||
kf.hold
|
||||
? `Still Frame ${formatKfTime(kf.time)} — hält bis zum nächsten Keyframe`
|
||||
: `Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`
|
||||
"
|
||||
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id)"
|
||||
@pointermove="onKeyframePointerMove"
|
||||
@pointerup="onKeyframePointerUp"
|
||||
@pointercancel="onKeyframePointerUp"
|
||||
@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>
|
||||
|
||||
<!-- Clip track -->
|
||||
<div
|
||||
v-else
|
||||
class="track clip-track"
|
||||
:class="{
|
||||
'drop-before': isDropBefore(track),
|
||||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||||
dragging: dragTrackId === track.clipTrackId,
|
||||
}"
|
||||
@dragover="onTrackDragOver($event, track.orderIndex)"
|
||||
@drop="onTrackDrop($event)"
|
||||
@dragleave="onTrackDragLeave"
|
||||
>
|
||||
<span
|
||||
class="track-name clip-track-name"
|
||||
draggable="true"
|
||||
@dragstart="onTrackDragStart($event, track.clipTrackId)"
|
||||
@dragend="onTrackDragEnd"
|
||||
>
|
||||
<PhDotsSixVertical :size="12" class="drag-handle" />
|
||||
<PhFilmStrip :size="11" class="name-badge" />
|
||||
{{ track.name }}
|
||||
</span>
|
||||
<div
|
||||
class="track-lane clip-lane"
|
||||
:style="{ width: laneWidth + 'px' }"
|
||||
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
|
||||
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
|
||||
>
|
||||
<span
|
||||
v-for="ci in clipInstancesByTrack[track.clipTrackId] ?? []"
|
||||
:key="ci.id"
|
||||
class="clip-block"
|
||||
:class="{
|
||||
'trimmed-start': (ci.trimStart ?? 0) > 0,
|
||||
'trimmed-end': (ci.trimEnd ?? 0) > 0,
|
||||
trimming: clipTrim?.instanceId === ci.id,
|
||||
}"
|
||||
:style="clipBlockStyle(ci)"
|
||||
:title="trimTooltip(ci)"
|
||||
draggable="true"
|
||||
@dragstart.stop="onClipBlockDragStart($event, ci)"
|
||||
@dragend="onClipBlockDragEnd"
|
||||
@dblclick="onClipBlockDblClick(ci)"
|
||||
@contextmenu="onClipBlockContextMenu($event, ci)"
|
||||
>
|
||||
<span
|
||||
class="clip-handle start"
|
||||
draggable="false"
|
||||
@pointerdown="onClipHandlePointerDown($event, ci, 'start')"
|
||||
@pointermove="onClipHandlePointerMove"
|
||||
@pointerup="onClipHandlePointerUp"
|
||||
@pointercancel="onClipHandlePointerUp"
|
||||
@dragstart.prevent.stop
|
||||
@dblclick.stop
|
||||
@click.stop
|
||||
/>
|
||||
<PhFilmStrip :size="11" class="clip-block-icon" />
|
||||
<span class="clip-block-name">{{ clipById.get(ci.clipId)?.name ?? '?' }}</span>
|
||||
<span
|
||||
class="clip-handle end"
|
||||
draggable="false"
|
||||
@pointerdown="onClipHandlePointerDown($event, ci, 'end')"
|
||||
@pointermove="onClipHandlePointerMove"
|
||||
@pointerup="onClipHandlePointerUp"
|
||||
@pointercancel="onClipHandlePointerUp"
|
||||
@dragstart.prevent.stop
|
||||
@dblclick.stop
|
||||
@click.stop
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="!tracks.length" class="empty">Noch keine Tracks.</p>
|
||||
<div v-if="!inClipEdit" class="track clip-track add-clip-track">
|
||||
<button class="add-clip-btn" title="Weitere Clip-Spur" @click="addClipTrack">
|
||||
<PhPlus :size="11" weight="bold" />
|
||||
<span>Clip-Spur hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!tracks.length" class="empty">Noch keine Layer.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="inClipEdit" class="clip-breadcrumb">
|
||||
<button class="breadcrumb-btn" @click="exitClip">
|
||||
← Haupt-Timeline
|
||||
</button>
|
||||
<span class="breadcrumb-current">
|
||||
<PhFilmStrip :size="11" />
|
||||
{{ store.activeClip?.name ?? '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="menu"
|
||||
ref="menuRef"
|
||||
@@ -1257,6 +1554,211 @@ onBeforeUnmount(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.track.clip-track {
|
||||
height: 34px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
rgba(110, 168, 255, 0.04) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.clip-track .track-name {
|
||||
background: var(--panel-alt);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.clip-track-name {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.clip-lane {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.clip-block {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
z-index: 2;
|
||||
transition: background 140ms var(--ease), transform 140ms var(--ease);
|
||||
}
|
||||
|
||||
.clip-block:hover {
|
||||
background: var(--accent-soft-strong);
|
||||
}
|
||||
|
||||
.clip-block:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.clip-block-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.clip-block-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.clip-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
cursor: ew-resize;
|
||||
z-index: 3;
|
||||
touch-action: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(255, 255, 255, 0.35),
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 120ms var(--ease), background 120ms var(--ease);
|
||||
}
|
||||
|
||||
.clip-handle.start {
|
||||
left: 0;
|
||||
border-top-left-radius: var(--radius-sm);
|
||||
border-bottom-left-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.clip-handle.end {
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
rgba(255, 255, 255, 0.35),
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
border-top-right-radius: var(--radius-sm);
|
||||
border-bottom-right-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.clip-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.clip-handle.start::after {
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
.clip-handle.end::after {
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
.clip-block:hover .clip-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.clip-block.trimmed-start .clip-handle.start,
|
||||
.clip-block.trimmed-end .clip-handle.end {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.clip-block.trimmed-start .clip-handle.start::after,
|
||||
.clip-block.trimmed-end .clip-handle.end::after {
|
||||
background: #ffc857;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.clip-block.trimming .clip-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.clip-block.trimming {
|
||||
box-shadow: 0 0 0 1px #ffc857;
|
||||
}
|
||||
|
||||
.clip-handle:hover,
|
||||
.clip-handle:active {
|
||||
background: rgba(255, 255, 255, 0.25) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.add-clip-track {
|
||||
height: 26px;
|
||||
background: transparent;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.add-clip-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
padding: 0 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.add-clip-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.clip-breadcrumb {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 12px;
|
||||
z-index: 15;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px 4px 8px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.breadcrumb-btn {
|
||||
padding: 2px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.breadcrumb-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kf-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
|
||||
Reference in new issue
Block a user