bak
This commit is contained in:
1 parent
fcefd258eb
commit
6b6157d786
23 files changed
+1140
-237
No files matched your search
+373
-49
@@ -14,6 +14,8 @@ import {
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
import { confirmDialog } from '@/components/ui'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
AnimatableProps,
|
||||
Clip,
|
||||
@@ -263,8 +265,8 @@ const tracks = computed<TimelineTrack[]>(() => {
|
||||
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 })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex })
|
||||
}
|
||||
})
|
||||
return out
|
||||
@@ -279,14 +281,14 @@ const tracks = computed<TimelineTrack[]>(() => {
|
||||
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 })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.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 })
|
||||
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? t('timeline.clipTrackName'), orderIndex })
|
||||
}
|
||||
})
|
||||
return out
|
||||
@@ -316,33 +318,110 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined)
|
||||
return true
|
||||
}
|
||||
|
||||
interface SelectionKey {
|
||||
layerId: string
|
||||
kind: 'main' | 'position' | 'rotation'
|
||||
keyframeId: string
|
||||
}
|
||||
|
||||
const selectedKfs = ref<SelectionKey[]>([])
|
||||
|
||||
function selectionKey(k: SelectionKey): string {
|
||||
return `${k.layerId}:${k.kind}:${k.keyframeId}`
|
||||
}
|
||||
|
||||
const selectedKfSet = computed(() => {
|
||||
const s = new Set<string>()
|
||||
for (const k of selectedKfs.value) s.add(selectionKey(k))
|
||||
return s
|
||||
})
|
||||
|
||||
function isKfSelected(layerId: string, kind: 'main' | 'position' | 'rotation', keyframeId: string): boolean {
|
||||
return selectedKfSet.value.has(`${layerId}:${kind}:${keyframeId}`)
|
||||
}
|
||||
|
||||
function clearKfSelection() {
|
||||
selectedKfs.value = []
|
||||
}
|
||||
|
||||
interface SelectionSnapshotEntry {
|
||||
layerId: string
|
||||
keyframeId: string
|
||||
kind: 'main' | 'position' | 'rotation'
|
||||
initialTime: number
|
||||
}
|
||||
|
||||
function snapshotSelection(): SelectionSnapshotEntry[] {
|
||||
const out: SelectionSnapshotEntry[] = []
|
||||
for (const s of selectedKfs.value) {
|
||||
const layer = store.activeLayers.find((l) => l.id === s.layerId)
|
||||
if (!layer) continue
|
||||
const arr =
|
||||
s.kind === 'position'
|
||||
? layer.positionKeyframes
|
||||
: s.kind === 'rotation'
|
||||
? layer.rotationKeyframes
|
||||
: layer.keyframes
|
||||
const kf = arr?.find((k) => k.id === s.keyframeId)
|
||||
if (kf) out.push({ ...s, initialTime: kf.time })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface KfDrag {
|
||||
layerId: string
|
||||
keyframeId: string
|
||||
kind: 'main' | 'position' | 'rotation'
|
||||
lane: HTMLElement
|
||||
pointerId: number
|
||||
startX: number
|
||||
moved: boolean
|
||||
initialTime: number
|
||||
snapshot: SelectionSnapshotEntry[]
|
||||
}
|
||||
|
||||
const kfDrag = ref<KfDrag | null>(null)
|
||||
|
||||
function onKeyframePointerDown(e: PointerEvent, layerId: string, keyframeId: string) {
|
||||
function onKeyframePointerDown(
|
||||
e: PointerEvent,
|
||||
layerId: string,
|
||||
keyframeId: string,
|
||||
kind: 'main' | 'position' | 'rotation',
|
||||
) {
|
||||
if (e.button !== 0) return
|
||||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||||
if (layer?.locked) return
|
||||
const kfEl = e.currentTarget as HTMLElement
|
||||
const lane = kfEl.parentElement
|
||||
if (!lane) return
|
||||
|
||||
// If this keyframe isn't in the current selection, replace selection with just this one.
|
||||
const key = `${layerId}:${kind}:${keyframeId}`
|
||||
if (!selectedKfSet.value.has(key)) {
|
||||
selectedKfs.value = [{ layerId, kind, keyframeId }]
|
||||
}
|
||||
|
||||
const arr =
|
||||
kind === 'position'
|
||||
? layer?.positionKeyframes
|
||||
: kind === 'rotation'
|
||||
? layer?.rotationKeyframes
|
||||
: layer?.keyframes
|
||||
const srcKf = arr?.find((k) => k.id === keyframeId)
|
||||
const initialTime = srcKf?.time ?? 0
|
||||
|
||||
e.stopPropagation()
|
||||
kfEl.setPointerCapture(e.pointerId)
|
||||
kfDrag.value = {
|
||||
layerId,
|
||||
keyframeId,
|
||||
kind,
|
||||
lane,
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
moved: false,
|
||||
initialTime,
|
||||
snapshot: snapshotSelection(),
|
||||
}
|
||||
store.selectLayer(layerId)
|
||||
}
|
||||
@@ -355,9 +434,50 @@ 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 = snapTime(ratio * duration.value, d.keyframeId)
|
||||
store.setKeyframeTime(d.layerId, d.keyframeId, newTime)
|
||||
store.setPlayhead(newTime)
|
||||
const rawNewTime = ratio * duration.value
|
||||
// For snapping, ignore all currently selected kfs so we don't snap to ourselves
|
||||
const ignoreIds = new Set<string>()
|
||||
for (const s of d.snapshot) ignoreIds.add(s.keyframeId)
|
||||
const snappedPrimary = snapToCandidates(
|
||||
rawNewTime,
|
||||
allKeyframeTimesExcluding(ignoreIds),
|
||||
{
|
||||
enabled: snapEnabled.value,
|
||||
pxPerSec: effectivePxPerSec.value,
|
||||
pixelThreshold: SNAP_PX,
|
||||
duration: duration.value,
|
||||
},
|
||||
)
|
||||
// Group delta clamped so nothing leaves [0, duration]
|
||||
let delta = snappedPrimary - d.initialTime
|
||||
let minDelta = -Infinity
|
||||
let maxDelta = Infinity
|
||||
for (const s of d.snapshot) {
|
||||
if (-s.initialTime > minDelta) minDelta = -s.initialTime
|
||||
if (duration.value - s.initialTime < maxDelta) maxDelta = duration.value - s.initialTime
|
||||
}
|
||||
delta = Math.max(minDelta, Math.min(maxDelta, delta))
|
||||
|
||||
if (d.snapshot.length <= 1) {
|
||||
store.setKeyframeTime(d.layerId, d.keyframeId, d.initialTime + delta)
|
||||
} else {
|
||||
for (const s of d.snapshot) {
|
||||
store.setKeyframeTime(s.layerId, s.keyframeId, s.initialTime + delta)
|
||||
}
|
||||
}
|
||||
store.setPlayhead(d.initialTime + delta)
|
||||
}
|
||||
|
||||
function allKeyframeTimesExcluding(ignoreIds: Set<string>): number[] {
|
||||
const out: number[] = []
|
||||
for (const layer of store.activeLayers) {
|
||||
for (const k of layer.keyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
|
||||
if (layer.positionKeyframes)
|
||||
for (const k of layer.positionKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
|
||||
if (layer.rotationKeyframes)
|
||||
for (const k of layer.rotationKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function onKeyframePointerUp(e: PointerEvent) {
|
||||
@@ -404,10 +524,10 @@ function segmentStyle(seg: KfSegment) {
|
||||
}
|
||||
|
||||
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' },
|
||||
{ value: 'linear', label: t('timeline.easingLinear') },
|
||||
{ value: 'easeIn', label: t('timeline.easingEaseIn') },
|
||||
{ value: 'easeOut', label: t('timeline.easingEaseOut') },
|
||||
{ value: 'easeInOut', label: t('timeline.easingEaseInOut') },
|
||||
]
|
||||
|
||||
interface KfMenuState {
|
||||
@@ -498,16 +618,16 @@ interface TrackMenuState {
|
||||
}
|
||||
|
||||
const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
|
||||
{ value: null, label: 'Keine' },
|
||||
{ value: '#ff6b6b', label: 'Rot' },
|
||||
{ value: '#ffa94d', label: 'Orange' },
|
||||
{ value: '#ffd43b', label: 'Gelb' },
|
||||
{ value: '#51cf66', label: 'Grün' },
|
||||
{ value: '#4dc4ff', label: 'Türkis' },
|
||||
{ value: '#6ea8ff', label: 'Blau' },
|
||||
{ value: '#b197fc', label: 'Lila' },
|
||||
{ value: '#ff8cc6', label: 'Pink' },
|
||||
{ value: '#adb5bd', label: 'Grau' },
|
||||
{ value: null, label: t('timeline.colors.none') },
|
||||
{ value: '#ff6b6b', label: t('timeline.colors.red') },
|
||||
{ value: '#ffa94d', label: t('timeline.colors.orange') },
|
||||
{ value: '#ffd43b', label: t('timeline.colors.yellow') },
|
||||
{ value: '#51cf66', label: t('timeline.colors.green') },
|
||||
{ value: '#4dc4ff', label: t('timeline.colors.cyan') },
|
||||
{ value: '#6ea8ff', label: t('timeline.colors.blue') },
|
||||
{ value: '#b197fc', label: t('timeline.colors.purple') },
|
||||
{ value: '#ff8cc6', label: t('timeline.colors.pink') },
|
||||
{ value: '#adb5bd', label: t('timeline.colors.gray') },
|
||||
]
|
||||
|
||||
const dragTrackId = ref<string | null>(null)
|
||||
@@ -559,6 +679,105 @@ function onTrackDragEnd() {
|
||||
dropIndex.value = null
|
||||
}
|
||||
|
||||
interface BoxSelectState {
|
||||
pointerId: number
|
||||
originClientX: number
|
||||
originClientY: number
|
||||
currentClientX: number
|
||||
currentClientY: number
|
||||
tracksEl: HTMLElement
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
const boxSelect = ref<BoxSelectState | null>(null)
|
||||
|
||||
const boxRectStyle = computed(() => {
|
||||
const b = boxSelect.value
|
||||
if (!b || !b.moved) return null
|
||||
const tRect = b.tracksEl.getBoundingClientRect()
|
||||
const left = Math.min(b.originClientX, b.currentClientX) - tRect.left
|
||||
const top = Math.min(b.originClientY, b.currentClientY) - tRect.top
|
||||
const width = Math.abs(b.currentClientX - b.originClientX)
|
||||
const height = Math.abs(b.currentClientY - b.originClientY)
|
||||
return {
|
||||
left: left + 'px',
|
||||
top: top + 'px',
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
}
|
||||
})
|
||||
|
||||
function onTracksPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target) return
|
||||
if (
|
||||
target.closest(
|
||||
'.kf, .clip-block, .clip-handle, .track-name, .drag-handle, .add-clip-btn, .playhead',
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const tracksEl = e.currentTarget as HTMLElement
|
||||
tracksEl.setPointerCapture(e.pointerId)
|
||||
boxSelect.value = {
|
||||
pointerId: e.pointerId,
|
||||
originClientX: e.clientX,
|
||||
originClientY: e.clientY,
|
||||
currentClientX: e.clientX,
|
||||
currentClientY: e.clientY,
|
||||
tracksEl,
|
||||
moved: false,
|
||||
}
|
||||
}
|
||||
|
||||
function onTracksPointerMove(e: PointerEvent) {
|
||||
const b = boxSelect.value
|
||||
if (!b || b.pointerId !== e.pointerId) return
|
||||
b.currentClientX = e.clientX
|
||||
b.currentClientY = e.clientY
|
||||
if (!b.moved) {
|
||||
const dx = b.currentClientX - b.originClientX
|
||||
const dy = b.currentClientY - b.originClientY
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) b.moved = true
|
||||
}
|
||||
}
|
||||
|
||||
function onTracksPointerUp(e: PointerEvent) {
|
||||
const b = boxSelect.value
|
||||
if (!b || b.pointerId !== e.pointerId) return
|
||||
if (b.tracksEl.hasPointerCapture(e.pointerId)) b.tracksEl.releasePointerCapture(e.pointerId)
|
||||
if (b.moved) {
|
||||
commitBoxSelection(b)
|
||||
} else {
|
||||
// bare click on empty space → clear selection
|
||||
clearKfSelection()
|
||||
}
|
||||
boxSelect.value = null
|
||||
}
|
||||
|
||||
function commitBoxSelection(b: BoxSelectState) {
|
||||
const left = Math.min(b.originClientX, b.currentClientX)
|
||||
const right = Math.max(b.originClientX, b.currentClientX)
|
||||
const top = Math.min(b.originClientY, b.currentClientY)
|
||||
const bottom = Math.max(b.originClientY, b.currentClientY)
|
||||
|
||||
const nodes = b.tracksEl.querySelectorAll<HTMLElement>('.kf[data-kf-id]')
|
||||
const next: SelectionKey[] = []
|
||||
for (const node of Array.from(nodes)) {
|
||||
const r = node.getBoundingClientRect()
|
||||
const cx = r.left + r.width / 2
|
||||
const cy = r.top + r.height / 2
|
||||
if (cx < left || cx > right || cy < top || cy > bottom) continue
|
||||
const layerId = node.dataset.layerId
|
||||
const kind = node.dataset.kind as 'main' | 'position' | 'rotation' | undefined
|
||||
const keyframeId = node.dataset.kfId
|
||||
if (!layerId || !kind || !keyframeId) continue
|
||||
next.push({ layerId, kind, keyframeId })
|
||||
}
|
||||
selectedKfs.value = next
|
||||
}
|
||||
|
||||
function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): number {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0) return 0
|
||||
@@ -665,10 +884,10 @@ 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`
|
||||
if ((ci.trimStart ?? 0) > 0) trims.push(t('timeline.trimStart', { value: (ci.trimStart ?? 0).toFixed(2) }))
|
||||
if ((ci.trimEnd ?? 0) > 0) trims.push(t('timeline.trimEnd', { value: (ci.trimEnd ?? 0).toFixed(2) }))
|
||||
const trimsStr = trims.length ? ` · ${trims.join(' · ')}` : ''
|
||||
return t('timeline.trimTooltip', { name, duration: dur, trims: trimsStr })
|
||||
}
|
||||
|
||||
interface ClipTrimState {
|
||||
@@ -734,9 +953,21 @@ function onClipBlockDblClick(ci: ClipInstance) {
|
||||
store.enterClip(ci.clipId)
|
||||
}
|
||||
|
||||
function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
|
||||
async function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
|
||||
e.preventDefault()
|
||||
if (!confirm('Clip-Instanz entfernen?')) return
|
||||
const clip = clipById.value.get(ci.clipId)
|
||||
const ok = await confirmDialog({
|
||||
title: t('timeline.removeInstanceDialog.title'),
|
||||
message: clip
|
||||
? t('timeline.removeInstanceDialog.message', {
|
||||
name: clip.name,
|
||||
time: ci.startTime.toFixed(2),
|
||||
})
|
||||
: t('timeline.removeInstanceDialog.generic'),
|
||||
confirmLabel: t('timeline.removeInstanceDialog.confirm'),
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
store.removeClipInstance(ci.id)
|
||||
}
|
||||
|
||||
@@ -829,6 +1060,12 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && selectedKfs.value.length) {
|
||||
selectedKfs.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch([menu, trackMenu], ([m, tm]) => {
|
||||
if (m || tm) {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
@@ -874,6 +1111,7 @@ onMounted(() => {
|
||||
viewportWidth.value = rect.width
|
||||
})
|
||||
ro.observe(scrollEl.value)
|
||||
document.addEventListener('keydown', onDocumentKeyDown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -881,6 +1119,7 @@ onBeforeUnmount(() => {
|
||||
ro = null
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('keydown', onDocumentKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -890,7 +1129,7 @@ onBeforeUnmount(() => {
|
||||
<div class="tl-content" :style="{ width: totalWidth + 'px' }">
|
||||
<div class="scrub-bar">
|
||||
<div class="name-spacer">
|
||||
<div class="mode-toggle" title="Zeit-Einheit umschalten">
|
||||
<div class="mode-toggle" :title="t('timeline.toggleTimeUnit')">
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: displayMode === 'seconds' }"
|
||||
@@ -905,7 +1144,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
class="snap-btn"
|
||||
:class="{ active: snapEnabled }"
|
||||
:title="snapEnabled ? 'Snapping aktiv (Playhead & Keyframes rasten aneinander ein)' : 'Snapping aus'"
|
||||
:title="snapEnabled ? t('timeline.snapOn') : t('timeline.snapOff')"
|
||||
@click="snapEnabled = !snapEnabled"
|
||||
>
|
||||
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
|
||||
@@ -935,7 +1174,14 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tracks">
|
||||
<div
|
||||
class="tracks"
|
||||
@pointerdown="onTracksPointerDown"
|
||||
@pointermove="onTracksPointerMove"
|
||||
@pointerup="onTracksPointerUp"
|
||||
@pointercancel="onTracksPointerUp"
|
||||
>
|
||||
<div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div>
|
||||
<template v-for="(track, idx) in tracks" :key="track.type === 'layer' ? `l:${track.layerId}:${track.kind}` : `c:${track.clipTrackId}`">
|
||||
<!-- Layer track -->
|
||||
<div
|
||||
@@ -1000,14 +1246,20 @@ onBeforeUnmount(() => {
|
||||
v-for="kf in track.kfs"
|
||||
:key="kf.id"
|
||||
class="kf"
|
||||
:class="{ hold: kf.hold }"
|
||||
:class="{
|
||||
hold: kf.hold,
|
||||
selected: isKfSelected(track.layerId, track.kind, kf.id),
|
||||
}"
|
||||
:style="{ left: pxFromTime(kf.time) + 'px' }"
|
||||
:data-layer-id="track.layerId"
|
||||
:data-kind="track.kind"
|
||||
:data-kf-id="kf.id"
|
||||
: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`
|
||||
? t('timeline.stillFrameTooltip', { time: formatKfTime(kf.time) })
|
||||
: t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })
|
||||
"
|
||||
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id)"
|
||||
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id, track.kind)"
|
||||
@pointermove="onKeyframePointerMove"
|
||||
@pointerup="onKeyframePointerUp"
|
||||
@pointercancel="onKeyframePointerUp"
|
||||
@@ -1104,11 +1356,11 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="!tracks.length" class="empty">Noch keine Tracks.</p>
|
||||
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
|
||||
<div v-if="!inClipEdit" class="track clip-track add-clip-track">
|
||||
<button class="add-clip-btn" title="Weitere Clip-Spur" @click="addClipTrack">
|
||||
<button class="add-clip-btn" :title="t('timeline.addClipTrackTooltip')" @click="addClipTrack">
|
||||
<PhPlus :size="11" weight="bold" />
|
||||
<span>Clip-Spur hinzufügen</span>
|
||||
<span>{{ t('timeline.addClipTrack') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1141,31 +1393,31 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
<div v-if="menu.isFirst" class="kf-menu-note">Erster Keyframe — Easing ohne Effekt</div>
|
||||
<div v-else-if="menu.isHeld" class="kf-menu-note">Still Frame — Easing deaktiviert</div>
|
||||
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
|
||||
<div v-else-if="menu.isHeld" class="kf-menu-note">{{ t('timeline.holdDisabledHint') }}</div>
|
||||
</div>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button
|
||||
class="kf-menu-item"
|
||||
:class="{ active: menu.isHeld }"
|
||||
:disabled="menu.isLast"
|
||||
:title="menu.isLast ? 'Letzter Keyframe — Still Frame ohne Effekt' : ''"
|
||||
:title="menu.isLast ? t('timeline.lastKfHint') : ''"
|
||||
@click="toggleHold"
|
||||
>
|
||||
<span class="check">
|
||||
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
|
||||
</span>
|
||||
<PhPauseCircle :size="12" />
|
||||
Still Frame
|
||||
{{ t('timeline.stillFrame') }}
|
||||
</button>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item" @click="duplicateFromMenu">
|
||||
<PhCopy :size="12" />
|
||||
Duplizieren
|
||||
{{ t('timeline.duplicate') }}
|
||||
</button>
|
||||
<button class="kf-menu-item danger" @click="deleteFromMenu">
|
||||
<PhTrash :size="12" />
|
||||
Löschen
|
||||
{{ t('timeline.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1176,7 +1428,7 @@ onBeforeUnmount(() => {
|
||||
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
|
||||
>
|
||||
<div class="kf-menu-section">
|
||||
<div class="kf-menu-header">Farbe</div>
|
||||
<div class="kf-menu-header">{{ t('timeline.color') }}</div>
|
||||
<div class="color-tiles">
|
||||
<button
|
||||
v-for="c in TRACK_COLORS"
|
||||
@@ -1201,7 +1453,7 @@ onBeforeUnmount(() => {
|
||||
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
|
||||
</span>
|
||||
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" />
|
||||
{{ trackMenu.locked ? 'Entsperren' : 'Sperren' }}
|
||||
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
|
||||
</button>
|
||||
<template v-if="trackMenu.kind === 'layer'">
|
||||
<div class="kf-menu-divider"></div>
|
||||
@@ -1213,13 +1465,13 @@ onBeforeUnmount(() => {
|
||||
<span class="check">
|
||||
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
|
||||
</span>
|
||||
Separate Position and Rotation
|
||||
{{ t('timeline.separate') }}
|
||||
</button>
|
||||
</template>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item danger" @click="deleteTrack">
|
||||
<PhTrash :size="12" />
|
||||
{{ trackMenu.kind === 'layer' ? 'Layer löschen' : 'Clip-Spur löschen' }}
|
||||
{{ trackMenu.kind === 'layer' ? t('timeline.deleteLayer') : t('timeline.deleteClipTrack') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1405,6 +1657,26 @@ onBeforeUnmount(() => {
|
||||
.tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.box-select-rect {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
border: 1px solid var(--accent);
|
||||
background: rgba(110, 168, 255, 0.12);
|
||||
pointer-events: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.kf.selected {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.kf.hold.selected {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.track {
|
||||
@@ -1495,6 +1767,58 @@ onBeforeUnmount(() => {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Neutralize hover states on locked tracks */
|
||||
.track.locked .track-name:hover {
|
||||
background: var(--panel);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.track.locked .track-name.selected:hover {
|
||||
background:
|
||||
linear-gradient(var(--accent-soft), var(--accent-soft)),
|
||||
var(--panel);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.track.locked.sub-track .track-name:hover {
|
||||
background: var(--panel-alt);
|
||||
}
|
||||
|
||||
.track.locked.inactive:hover .track-lane {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.track.locked .clip-block:hover {
|
||||
background: color-mix(in srgb, var(--track-color, var(--accent)) 14%, transparent);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.track.locked .clip-block:hover .clip-handle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.track.locked .clip-block.trimmed-start:hover .clip-handle.start,
|
||||
.track.locked .clip-block.trimmed-end:hover .clip-handle.end {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.track.locked .clip-handle:hover,
|
||||
.track.locked .clip-handle:active {
|
||||
background: transparent !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.track.locked .kf:hover {
|
||||
background: var(--track-color, var(--accent));
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.track.locked .kf.hold:hover {
|
||||
background: var(--panel);
|
||||
border-color: var(--track-color, var(--accent));
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.track-name {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
|
||||
Reference in new issue
Block a user