This commit is contained in:
th committed 2026-04-19 20:31:53 +02:00
1 parent e4aa0dce86
commit cc6b11e61d
23 files changed
+2047 -524

No files matched your search

+211 -8
View File
@@ -1,6 +1,14 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhArrowsSplit, PhCheck, PhCopy, PhMagnet, PhTrash } from '@phosphor-icons/vue'
import {
PhArrowsSplit,
PhCheck,
PhCopy,
PhDotsSixVertical,
PhMagnet,
PhPauseCircle,
PhTrash,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
@@ -13,6 +21,7 @@ interface TimelineTrack {
kind: 'main' | 'position' | 'rotation'
label: string
isParent: boolean
layerIndex: number
}
const store = useSceneStore()
@@ -208,7 +217,7 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
const tracks = computed<TimelineTrack[]>(() => {
const out: TimelineTrack[] = []
for (const layer of store.scene.layers) {
store.scene.layers.forEach((layer, layerIndex) => {
if (!layer.separated) {
out.push({
layerId: layer.id,
@@ -217,6 +226,7 @@ const tracks = computed<TimelineTrack[]>(() => {
kind: 'main',
label: layer.name,
isParent: true,
layerIndex,
})
} else {
out.push({
@@ -226,6 +236,7 @@ const tracks = computed<TimelineTrack[]>(() => {
kind: 'main',
label: layer.name,
isParent: true,
layerIndex,
})
out.push({
layerId: layer.id,
@@ -234,6 +245,7 @@ const tracks = computed<TimelineTrack[]>(() => {
kind: 'position',
label: 'Position',
isParent: false,
layerIndex,
})
out.push({
layerId: layer.id,
@@ -242,12 +254,30 @@ const tracks = computed<TimelineTrack[]>(() => {
kind: 'rotation',
label: 'Rotation',
isParent: false,
layerIndex,
})
}
}
})
return out
})
function isDropBefore(track: TimelineTrack): boolean {
return (
dropIndex.value !== null &&
track.isParent &&
dropIndex.value === track.layerIndex &&
dragLayerId.value !== track.layerId
)
}
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
return true
}
interface KfDrag {
layerId: string
keyframeId: string
@@ -305,6 +335,7 @@ interface KfSegment {
fromTime: number
toTime: number
easing: Easing
held: boolean
}
function segmentsOf(kfs: Keyframe[]): KfSegment[] {
@@ -319,6 +350,7 @@ function segmentsOf(kfs: Keyframe[]): KfSegment[] {
fromTime: a.time,
toTime: b.time,
easing: b.easing,
held: !!a.hold,
})
}
return out
@@ -346,6 +378,8 @@ interface KfMenuState {
y: number
currentEasing: Easing
isFirst: boolean
isLast: boolean
isHeld: boolean
}
const menu = ref<KfMenuState | null>(null)
@@ -371,10 +405,18 @@ function onKeyframeContextMenu(
y: e.clientY,
currentEasing: kf.easing,
isFirst: sorted[0]?.id === kf.id,
isLast: sorted[sorted.length - 1]?.id === kf.id,
isHeld: !!kf.hold,
}
store.selectLayer(layerId)
}
function toggleHold() {
if (!menu.value) return
store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
menu.value = null
}
function applyEasing(easing: Easing) {
if (!menu.value) return
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
@@ -411,6 +453,56 @@ interface TrackMenuState {
y: number
separated: boolean
}
const dragLayerId = ref<string | null>(null)
const dropIndex = ref<number | null>(null)
function onTrackDragStart(e: DragEvent, layerId: string) {
if (!e.dataTransfer) return
dragLayerId.value = layerId
e.dataTransfer.effectAllowed = 'move'
try {
e.dataTransfer.setData('text/plain', layerId)
} catch {
/* Safari older versions */
}
}
function onTrackDragOver(e: DragEvent, layerId: string) {
if (!dragLayerId.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
}
function onTrackDragLeave() {
/* dropIndex updates on next dragover, keep as-is to avoid flicker */
}
function onTrackDrop(e: DragEvent) {
e.preventDefault()
const srcId = dragLayerId.value
const target = dropIndex.value
dragLayerId.value = null
dropIndex.value = null
if (!srcId || target === null) return
const from = store.scene.layers.findIndex((l) => l.id === srcId)
if (from < 0) return
let to = target
if (from < to) to -= 1
store.moveLayer(from, to)
}
function onTrackDragEnd() {
dragLayerId.value = null
dropIndex.value = null
}
const trackMenu = ref<TrackMenuState | null>(null)
const trackMenuRef = ref<HTMLDivElement | null>(null)
@@ -574,7 +666,13 @@ onBeforeUnmount(() => {
: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"
@@ -583,9 +681,17 @@ onBeforeUnmount(() => {
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"
@@ -605,15 +711,20 @@ onBeforeUnmount(() => {
v-for="seg in segmentsOf(track.kfs)"
:key="seg.id"
class="kf-seg"
:class="`easing-${seg.easing}`"
: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="`Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`"
: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"
@@ -641,18 +752,37 @@ onBeforeUnmount(() => {
v-for="opt in EASING_OPTIONS"
:key="opt.value"
class="kf-menu-item"
:class="{ active: menu.currentEasing === opt.value }"
:disabled="menu.isFirst"
:class="{ active: menu.currentEasing === opt.value && !menu.isHeld }"
:disabled="menu.isFirst || menu.isHeld"
@click="applyEasing(opt.value)"
>
<span class="check">
<PhCheck v-if="menu.currentEasing === opt.value" :size="12" weight="bold" />
<PhCheck
v-if="menu.currentEasing === opt.value && !menu.isHeld"
:size="12"
weight="bold"
/>
</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>
<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' : ''"
@click="toggleHold"
>
<span class="check">
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
</span>
<PhPauseCircle :size="12" />
Still Frame
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" />
Duplizieren
@@ -872,6 +1002,32 @@ onBeforeUnmount(() => {
height: 30px;
border-bottom: 1px solid var(--border);
transition: background 140ms var(--ease);
position: relative;
}
.track.dragging {
opacity: 0.5;
}
.track.drop-before::before,
.track.drop-after::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
z-index: 5;
pointer-events: none;
box-shadow: 0 0 0 1px rgba(110, 168, 255, 0.35);
}
.track.drop-before::before {
top: -1px;
}
.track.drop-after::after {
bottom: -1px;
}
.track.inactive .track-lane {
@@ -951,6 +1107,24 @@ onBeforeUnmount(() => {
flex-shrink: 0;
}
.drag-handle {
color: var(--text-dim);
margin-right: 4px;
flex-shrink: 0;
cursor: grab;
opacity: 0;
transition: opacity 140ms var(--ease);
}
.track-name:hover .drag-handle,
.track-name.selected .drag-handle {
opacity: 0.7;
}
.drag-handle:active {
cursor: grabbing;
}
.sub-indent {
color: var(--text-dim);
margin-right: 4px;
@@ -1020,6 +1194,17 @@ onBeforeUnmount(() => {
opacity: 0.95;
}
.kf-seg.held {
background: repeating-linear-gradient(
90deg,
var(--text-dim) 0,
var(--text-dim) 2px,
transparent 2px,
transparent 5px
);
opacity: 0.7;
}
.kf {
position: absolute;
top: 50%;
@@ -1046,6 +1231,24 @@ onBeforeUnmount(() => {
cursor: grabbing;
}
.kf.hold {
width: 15px;
height: 15px;
margin-left: -7.5px;
margin-top: -7.5px;
transform: none;
border-radius: 50%;
background: var(--panel);
border: 2px solid var(--accent);
box-shadow: none;
}
.kf.hold:hover {
background: var(--panel);
border-color: #fff;
transform: scale(1.2);
}
.empty {
color: var(--text-dim);
font-style: italic;