bak
This commit is contained in:
1 parent
c6863f2bfc
commit
94e9846c13
11 files changed
+985
-136
No files matched your search
+353
-36
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user