bak
This commit is contained in:
1 parent
e4aa0dce86
commit
cc6b11e61d
23 files changed
+2047
-524
No files matched your search
+211
-8
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user