Files
SpineParticlesWeb/src/views/Timeline.vue
T
2026-10-28 14:32:52 +08:00

602 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="timeline">
<!-- 控制按钮 -->
<Teleport defer to="#timeline-controls-host">
<div class="tl-controls">
<button class="tl-btn" data-matomo-event="timeline.play-toggle" :class="{ on: store.timeline.playing }" @click="store.togglePlay()" :title="store.timeline.playing ? $t('timeline.pause') : $t('timeline.play')">
{{ store.timeline.playing ? '❚❚' : '▶' }}
</button>
<button class="tl-btn" data-matomo-event="timeline.to-start" @click="store.toStart()" :title="$t('timeline.toStart')">⏮</button>
<button class="tl-btn" data-matomo-event="timeline.previous-frame" @click="store.previousFrame()" :title="$t('timeline.previous')">◀</button>
<button class="tl-btn" data-matomo-event="timeline.next-frame" @click="store.nextFrame()" :title="$t('timeline.next')">▶</button>
<button class="tl-btn" data-matomo-event="timeline.to-end" @click="store.toEnd()" :title="$t('timeline.toEnd')">⏭</button>
<button class="tl-btn" data-matomo-event="timeline.loop-toggle" :class="{ on: store.timeline.loop }" :title="$t('timeline.continuousLoop')" :aria-label="$t('timeline.continuousLoop')" @click="store.toggleLoop()">⟳</button>
<div class="tl-ab-loop">
<button class="tl-ab-toggle" data-matomo-event="timeline.ab-toggle" :class="{ on: store.timeline.abLoop }" :title="$t('timeline.abLoop')" @click="store.toggleABLoop()">
<span>{{ $t('timeline.abLoopShort') }}</span><i class="tl-switch"><b></b></i>
</button>
<div v-if="store.timeline.abLoop" class="tl-ab-popover" @pointerdown.stop>
<div class="tl-ab-title">{{ $t('timeline.abRange') }}</div>
<label class="tl-range" :title="$t('timeline.playbackStart')"><span>A</span><input v-model="playbackStartDraft" type="number" min="0" :max="Math.max(0, store.timeline.playbackEnd - 1)" :aria-label="$t('timeline.startFrame')" @change="commitPlaybackStart" @keydown.enter.prevent="commitPlaybackStart" /></label>
<label class="tl-range" :title="$t('timeline.playbackEnd')"><span>B</span><input v-model="playbackEndDraft" type="number" :min="store.timeline.playbackStart + 1" :max="Math.max(1, store.timeline.totalFrames - 1)" :aria-label="$t('timeline.endFrame')" @change="commitPlaybackEnd" @keydown.enter.prevent="commitPlaybackEnd" /></label>
</div>
</div>
<span class="tl-frame">{{ $t('timeline.frameStatus', { current: store.timeline.frame, total: Math.max(0, store.timeline.totalFrames - 1) }) }}</span>
<span class="tl-legend"><i class="legend-emission"></i>{{ $t('timeline.emission') }} <i class="legend-life"></i>{{ $t('timeline.particleLife') }} <i class="legend-spine"></i>{{ $t('timeline.spineAnimation') }}</span>
<div ref="animationMenuRef" class="tl-anim tl-owner-animation">
<span>{{ $t('timeline.ownerAnimation') }}</span>
<button class="tl-animation-trigger" :disabled="!activeParticleSystem" :title="$t('timeline.setOwnerAnimation')" @click.stop="toggleAnimationMenu">
<span>{{ activeParticleSystem?.animation || store.timeline.animation }}</span><i>⌄</i>
</button>
<div v-if="animationMenuOpen" class="tl-animation-menu" @pointerdown.stop>
<div class="tl-animation-menu-title">{{ $t('timeline.chooseOwnerAnimation') }}</div>
<button
v-for="animation in store.timeline.animations"
:key="animation"
class="tl-animation-option"
:class="{ on: activeParticleSystem?.animation === animation }"
@click="selectOwnerAnimation(animation)"
>
<span>{{ animation }}</span><i v-if="activeParticleSystem?.animation === animation">✓</i>
</button>
<div class="tl-animation-editor">
<input v-model="animationNameDraft" class="tl-animation-name" maxlength="64" :aria-label="$t('timeline.animationName')" @keydown.enter="renameCurrentAnimation" />
<button :title="$t('timeline.renameAnimation')" @click="renameCurrentAnimation">{{ $t('timeline.rename') }}</button>
</div>
<div v-if="animationError" class="tl-animation-error">{{ animationError }}</div>
<div class="tl-animation-actions">
<button @click="addAnimation">{{ $t('timeline.newAnimation') }}</button>
<button :disabled="store.timeline.animations.length <= 1" @click="deleteCurrentAnimation">{{ $t('common.delete') }}</button>
</div>
<div v-if="store.timeline.animations.length <= 1" class="tl-animation-hint">{{ $t('timeline.keepOneAnimation') }}</div>
</div>
</div>
<label class="tl-anim tl-spine-preview">{{ $t('timeline.spinePreview') }}
<select
class="tl-sel"
:value="previewSpine?.selectedAnimation || ''"
:disabled="!previewSpine"
@change="onSpinePreviewAnimationChange"
>
<option value="">{{ previewSpine ? $t('timeline.none') : $t('timeline.noSpineAnimation') }}</option>
<option v-for="animation in previewSpine?.animations || []" :key="animation.name" :value="animation.name">
{{ animation.name }}({{ animation.duration.toFixed(2) }}s)
</option>
</select>
</label>
<span class="tl-zoom">{{ zoomLabel }}</span>
</div>
</Teleport>
<!-- 时间轴视口 -->
<div
class="tl-viewport"
ref="vpRef"
@wheel.prevent="onWheel"
@pointerdown="onScrubDown"
>
<!-- 帧刻度 -->
<div class="tl-ticks">
<div
v-for="t in ticks"
:key="t.f"
class="tl-tickline"
:style="{ left: t.px + 'px' }"
>
<span class="tl-ticklabel">{{ t.f }}f</span>
</div>
</div>
<!-- 播放头 -->
<div class="tl-playhead" :style="{ left: playPx + 'px' }"></div>
<!-- 每系统一条轨道(粒子条) -->
<div class="tl-lanes">
<div v-for="(sys, i) in store.systems" :key="sys.id" class="tl-lane">
<span class="lane-ico" :class="{ 'sub-lane-ico': isSubEmitter(sys) }" :style="{ left: startMarkerPx(sys) + 'px' }">●</span>
<!-- 粒子条:left=delay;红色底条=系统存在粒子的总时长;绿色覆盖层=持续发射时长 -->
<div
class="lane-bar"
:class="{ 'sub-emitter-bar': isSubEmitter(sys) }"
:style="{ left: barLeftPx(sys) + 'px', width: barWidthPx(sys) + 'px' }"
@pointerdown.stop="onBarDown($event, sys)"
>
<span class="lane-name">{{ (sys.config as any).name || $t('timeline.systemName', { index: i + 1 }) }}</span>
<span class="bar-life" :class="{ 'loop-life': !isSubEmitter(sys) && isLoopStream(sys) }" :style="{ width: lifeWidthPx(sys) + 'px' }"></span>
<template v-if="!isSubEmitter(sys) && isLoopStream(sys)">
<span v-if="loopStartWidthPx(sys)" class="bar-loop-transition bar-loop-start" :style="{ width: loopStartWidthPx(sys) + 'px' }"></span>
<span class="bar-emission bar-loop" :style="{ left: loopStartWidthPx(sys) + 'px', width: emissionWidthPx(sys) + 'px' }">
<span class="bar-handle" :title="$t('timeline.dragLoopDuration')" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
</span>
<span v-if="loopEndWidthPx(sys)" class="bar-loop-transition bar-loop-end" :style="{ left: (loopStartWidthPx(sys) + emissionWidthPx(sys)) + 'px', width: loopEndWidthPx(sys) + 'px' }"></span>
</template>
<span v-else-if="!isSubEmitter(sys) && sys.config.mode === 'stream'" class="bar-emission" :style="{ width: emissionWidthPx(sys) + 'px' }">
<span class="bar-handle" :title="$t('timeline.dragEmissionDuration')" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
</span>
</div>
</div>
<!-- Spine 动画条:紫色长度由动画数据决定,只允许整体拖动开始位置。 -->
<div v-for="spine in spineLanes" :key="`spine-${spine.id}`" class="tl-lane spine-lane">
<span class="lane-ico spine-ico">◆</span>
<div
class="spine-bar"
:style="{ left: spineLeftPx(spine) + 'px', width: spineWidthPx(spine) + 'px' }"
:title="$t('timeline.dragSpineStart')"
@pointerdown.stop="onSpineBarDown($event, spine)"
>
<span class="lane-name">{{ spine.name }} · {{ spine.selectedAnimation }}</span>
</div>
</div>
<div v-if="!store.systems.length && !spineLanes.length" class="tl-empty">{{ $t('timeline.noAnimationData') }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { useParticleStore } from '../store/particleStore'
import type { SpineSceneObject } from '../spine/spineTypes'
import { loopSegmentTiming } from '../core/loopSegmentTiming'
import { collectSubEmitterIds } from '../core/subEmitterIds'
import { subEmitterTriggerTimings } from '../core/subEmitterTiming'
type SysLike = { id: number; config: any }
const store = useParticleStore()
const { t } = useI18n()
const vpRef = ref<HTMLElement | null>(null)
const viewportWidth = ref(0)
let viewportResizeObserver: ResizeObserver | null = null
const animationMenuRef = ref<HTMLElement | null>(null)
const animationMenuOpen = ref(false)
const animationNameDraft = ref('animation')
const animationError = ref('')
// 兼容旧场景与开发热更新:补齐动画列表和粒子系统归属。
watchEffect(() => {
if (!Array.isArray(store.timeline.animations) || !store.timeline.animations.length) store.timeline.animations = ['animation']
if (!store.timeline.animations.includes(store.timeline.animation)) store.timeline.animation = store.timeline.animations[0]
for (const system of store.systems) {
if (!system.animation || !store.timeline.animations.includes(system.animation)) system.animation = store.timeline.animations[0]
}
})
const activeParticleSystem = computed(() => store.systems.find((system) => system.id === store.activeId) || null)
function toggleAnimationMenu() {
if (!activeParticleSystem.value) return
animationMenuOpen.value = !animationMenuOpen.value
animationNameDraft.value = activeParticleSystem.value.animation
animationError.value = ''
}
function selectOwnerAnimation(animation: string) {
const system = activeParticleSystem.value
if (!system) return
store.setSystemAnimation(system.id, animation)
animationNameDraft.value = animation
animationError.value = ''
}
function addAnimation() {
const name = store.addAnimation()
animationNameDraft.value = name
animationError.value = ''
}
function renameCurrentAnimation() {
const current = activeParticleSystem.value?.animation || store.timeline.animation
const next = animationNameDraft.value.trim()
if (!next) { animationError.value = t('timeline.animationNameEmpty'); return }
if (next !== current && store.timeline.animations.includes(next)) { animationError.value = t('timeline.animationNameDuplicate'); return }
if (!store.renameAnimation(current, next)) { animationError.value = t('timeline.animationRenameFailed'); return }
animationNameDraft.value = next
animationError.value = ''
}
function deleteCurrentAnimation() {
const current = activeParticleSystem.value?.animation || store.timeline.animation
if (!store.removeAnimation(current)) { animationError.value = t('timeline.keepOneAnimation'); return }
const next = activeParticleSystem.value?.animation || store.timeline.animation
animationNameDraft.value = next
animationError.value = ''
}
function onDocumentPointerDown(event: PointerEvent) {
if (!animationMenuRef.value?.contains(event.target as Node)) animationMenuOpen.value = false
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown)
const viewport = vpRef.value
if (!viewport) return
viewportWidth.value = viewport.clientWidth
viewportResizeObserver = new ResizeObserver(([entry]) => {
viewportWidth.value = Math.max(1, entry.contentRect.width)
})
viewportResizeObserver.observe(viewport)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown)
viewportResizeObserver?.disconnect()
viewportResizeObserver = null
})
// 时间轴与固定步长模拟统一读取 Store;默认 1 秒 = 30 帧,与 Spine 对齐。
const fps = computed(() => Math.max(1, store.timeline.fps || 30))
/** 视口当前能显示的帧数(滚轮缩放它),默认至少显示完整总时长。 */
const viewFrames = ref(Math.max(120, store.timeline.totalFrames))
// 拖动期间只更新本地预览值,松手后再一次性写回配置。
// 避免 pointermove 每一帧都触发 Stage 清缓存、重置粒子模拟。
const draftDelays = ref<Record<number, number>>({})
const draftDurations = ref<Record<number, number>>({})
const draftLoopDurations = ref<Record<number, number>>({})
const draftSpineStarts = ref<Record<number, number>>({})
const playbackStartDraft = ref(String(store.timeline.playbackStart))
const playbackEndDraft = ref(String(store.timeline.playbackEnd))
watch(() => store.timeline.playbackStart, (value) => { playbackStartDraft.value = String(value) })
watch(() => store.timeline.playbackEnd, (value) => { playbackEndDraft.value = String(value) })
const totalFrames = computed(() => store.timeline.totalFrames)
// 子发射器延长时间轴后自动扩大预览范围;用户手动放大后保持其缩放。
watch(totalFrames, (value) => {
if (value > viewFrames.value) viewFrames.value = Math.min(2000, value)
})
const zoomLabel = computed(() => (viewFrames.value <= 30 ? t('timeline.zoomIn') : viewFrames.value >= 300 ? t('timeline.zoomOut') : t('timeline.zoomFit')))
const spineLanes = computed(() => store.spines.filter((spine) =>
spine.selectedAnimation && spine.animations.some((animation) => animation.name === spine.selectedAnimation && animation.duration > 0),
))
const previewSpine = computed(() => {
const active = store.activeObjectType === 'spine'
? store.spines.find((spine) => spine.id === store.activeObjectId && spine.animations.length)
: null
return active || store.spines.find((spine) => spine.animations.length) || null
})
function onSpinePreviewAnimationChange(event: Event) {
const spine = previewSpine.value
if (!spine) return
spine.selectedAnimation = (event.target as HTMLSelectElement).value
store.recalcTotalFrames()
store.timeline.frame = 0
}
function commitPlaybackStart() {
store.setPlaybackStart(Number(playbackStartDraft.value))
playbackStartDraft.value = String(store.timeline.playbackStart)
}
function commitPlaybackEnd() {
store.setPlaybackEnd(Number(playbackEndDraft.value))
playbackEndDraft.value = String(store.timeline.playbackEnd)
}
// 每像素代表的帧数(视口宽度内放下 viewFrames 帧)
function pxPerF() {
const vw = viewportWidth.value || vpRef.value?.clientWidth || 600
return viewFrames.value / vw
}
// 帧刻度:从第 0 帧开始,撑满 viewFrames;取整数帧,间隔随缩放自适应
const ticks = computed(() => {
const out: { f: number; px: number }[] = []
const endF = viewFrames.value
let step = 1
if (viewFrames.value <= 10) step = 1
else if (viewFrames.value <= 50) step = 5
else if (viewFrames.value <= 200) step = 10
else if (viewFrames.value <= 500) step = 50
else step = 100
for (let f = 0; f <= endF; f += step) {
out.push({ f, px: f / pxPerF() })
}
return out
})
const playPx = computed(() => store.timeline.frame / pxPerF())
const subEmitterIds = computed(() => collectSubEmitterIds(store.systems))
const subEmitterTiming = computed(() => subEmitterTriggerTimings(store.systems as any, fps.value))
function isSubEmitter(sys: SysLike) {
return subEmitterIds.value.has(sys.id)
}
// 圆点标记系统启动时刻;子发射器的 delay 只移动时间块,不移动这个标记。
function startMarkerPx(sys: SysLike) {
const start = isSubEmitter(sys) ? subEmitterTiming.value.get(sys.id)?.firstTrigger ?? 0 : 0
return (start * fps.value) / pxPerF() + 4
}
function barLeftPx(sys: SysLike) {
const s = (sys.config as any)
if (isSubEmitter(sys)) {
// 子发射器从第一次父事件 + 自身延迟开始;拖左最多到事件发生位置。
const firstTrigger = subEmitterTiming.value.get(sys.id)?.firstTrigger ?? 0
const delay = draftDelays.value[sys.id] ?? Math.max(0, Number(s.delay) || 0)
return ((firstTrigger + delay) * fps.value) / pxPerF()
}
// 左端 = 延迟发射时长(delay);左端之前是空白=延迟
const delay = draftDelays.value[sys.id] ?? Math.max(0, Number(s.delay) || 0)
return (delay * fps.value) / pxPerF()
}
function maxParticleLife(sys: SysLike) {
const s = (sys.config as any)
const lifeMin = Math.max(0, Number(s.lifeMin) || 0)
const lifeMax = Math.max(0, Number(s.lifeMax) || 0)
return s.lifeMode === 'fixed' ? lifeMin : Math.max(lifeMin, lifeMax)
}
function barWidthPx(sys: SysLike): number {
if (isSubEmitter(sys)) {
const timing = subEmitterTiming.value.get(sys.id)
const start = timing?.firstTrigger ?? 0
const end = timing?.end ?? start
return (Math.max(0, end - start) * fps.value) / pxPerF()
}
return lifeWidthPx(sys)
}
function emissionWidthPx(sys: SysLike) {
if (isSubEmitter(sys)) return 0
if ((sys.config as any).mode !== 'stream') return 0
if ((sys.config as any).streamBehavior === 'loop') {
const config = sys.config as any
const durationFrames = draftLoopDurations.value[sys.id] ?? loopSegmentTiming(config, fps.value).loop
return durationFrames / pxPerF()
}
const duration = draftDurations.value[sys.id] ?? Math.max(1 / fps.value, Number((sys.config as any).duration) || 1)
return (duration * fps.value) / pxPerF()
}
function lifeWidthPx(sys: SysLike): number {
if (isSubEmitter(sys)) return barWidthPx(sys)
const config = sys.config as any
if (config.mode === 'stream' && config.streamBehavior === 'loop') {
return loopStartWidthPx(sys) + emissionWidthPx(sys) + loopEndWidthPx(sys)
}
// 红色底条表示整个系统“仍有粒子存在”的时长:持续发射窗口 + 最长粒子生命。
// 爆发模式的发射窗口为 0,因此只保留最长粒子生命。
return emissionWidthPx(sys) + (maxParticleLife(sys) * fps.value) / pxPerF()
}
function isLoopStream(sys: SysLike) {
const config = sys.config as any
return config.mode === 'stream' && config.streamBehavior === 'loop'
}
function loopStartWidthPx(sys: SysLike) {
return isLoopStream(sys) ? loopSegmentTiming(sys.config as any, fps.value).start / pxPerF() : 0
}
function loopEndWidthPx(sys: SysLike) {
return isLoopStream(sys) ? loopSegmentTiming(sys.config as any, fps.value).end / pxPerF() : 0
}
function selectedSpineDuration(spine: SpineSceneObject) {
return spine.animations.find((animation) => animation.name === spine.selectedAnimation)?.duration || 0
}
function spineLeftPx(spine: SpineSceneObject) {
const start = draftSpineStarts.value[spine.id] ?? Math.max(0, Number(spine.timeOffset) || 0)
return (start * fps.value) / pxPerF()
}
function spineWidthPx(spine: SpineSceneObject) {
return (selectedSpineDuration(spine) * fps.value) / pxPerF()
}
// 滚轮缩放:只改变视口显示的帧数(左侧恒为 0 帧),不改变播放时长/totalFrames
function onWheel(e: WheelEvent) {
const factor = e.deltaY < 0 ? 0.8 : 1.25 // 滚上放大(显示更少帧),滚下缩小(显示更多帧)
let nv = viewFrames.value * factor
nv = Math.min(2000, Math.max(10, nv)) // 10f ~ 2000f
// 整数帧,最小值 0 起
viewFrames.value = Math.round(nv)
}
// 拖动时间轴滑动(scrub):更新 frame(0..totalFrames-1)
function onScrubDown(e: PointerEvent) {
const vp = vpRef.value!
// 用户接管时间轴时立即暂停播放,否则每帧推进会和拖动抢播放头。
store.pause()
const move = (ev: PointerEvent) => {
const rect = vp.getBoundingClientRect()
const f = Math.floor(((ev.clientX - rect.left) / rect.width) * viewFrames.value)
store.timeline.frame = Math.max(0, Math.min(f, totalFrames.value - 1))
}
move(e)
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
// 拖动粒子条:整体移动 = 改 delay
function onBarDown(e: PointerEvent, sys: SysLike) {
e.stopPropagation()
const startX = e.clientX
const startDelay = Math.max(0, Number((sys.config as any).delay) || 0)
const secPerPx = pxPerF() / fps.value
const move = (ev: PointerEvent) => {
const dSec = (ev.clientX - startX) * secPerPx
const nd = Math.max(0, Math.min(startDelay + dSec, 600))
draftDelays.value[sys.id] = Math.round(nd * fps.value) / fps.value
}
const finish = (commit: boolean) => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', cancel)
const value = draftDelays.value[sys.id]
delete draftDelays.value[sys.id]
if (commit && value != null && value !== startDelay) (sys.config as any).delay = value
}
const up = () => finish(true)
const cancel = () => finish(false)
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', cancel)
}
// 拖动粒子条右缘:调整发射时长 duration
function onBarResizeDown(e: PointerEvent, sys: SysLike) {
e.stopPropagation()
const loopStream = isLoopStream(sys)
const startDur = Math.max(0, Number((sys.config as any).duration) || 0)
const startLoopFrames = loopSegmentTiming(sys.config as any, fps.value).loop
const secPerPx = pxPerF() / fps.value
const barLeft = (e.currentTarget as HTMLElement).closest('.lane-bar')?.getBoundingClientRect().left ?? e.clientX
const startOffsetPx = loopStartWidthPx(sys)
let moved = false
const move = (ev: PointerEvent) => {
moved = true
// 使用右端相对粒子条左端的绝对距离换算发射时长。
// 因此拖到 60f 就得到 60f,而不是减去粒子生命周期后的剩余帧数。
if (loopStream) {
const frames = Math.min(1800, Math.max(1, Math.round((ev.clientX - barLeft - startOffsetPx) / pxPerF())))
draftLoopDurations.value[sys.id] = frames
} else {
const nd = Math.max(1 / fps.value, Math.min((ev.clientX - barLeft) * secPerPx, 600))
draftDurations.value[sys.id] = Math.round(nd * fps.value) / fps.value
}
}
const finish = (commit: boolean) => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', cancel)
const value = draftDurations.value[sys.id]
const loopValue = draftLoopDurations.value[sys.id]
delete draftDurations.value[sys.id]
delete draftLoopDurations.value[sys.id]
if (commit && moved && loopStream && loopValue != null && loopValue !== startLoopFrames) (sys.config as any).loopDurationFrames = loopValue
else if (commit && moved && !loopStream && value != null && value !== startDur) (sys.config as any).duration = value
}
const up = () => finish(true)
const cancel = () => finish(false)
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', cancel)
}
// Spine 动画长度来自文件,不能缩放;拖动整条只修改延迟播放时间。
function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
e.stopPropagation()
const startX = e.clientX
const startTime = Math.max(0, Number(spine.timeOffset) || 0)
const secPerPx = pxPerF() / fps.value
const move = (ev: PointerEvent) => {
const seconds = Math.max(0, Math.min(startTime + (ev.clientX - startX) * secPerPx, 600))
draftSpineStarts.value[spine.id] = Math.round(seconds * fps.value) / fps.value
}
const finish = (commit: boolean) => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', cancel)
const value = draftSpineStarts.value[spine.id]
delete draftSpineStarts.value[spine.id]
if (commit && value != null && value !== startTime) {
spine.timeOffset = value
store.recalcTotalFrames()
}
}
const up = () => finish(true)
const cancel = () => finish(false)
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', cancel)
}
</script>
<style scoped>
.timeline { height: 100%; background: #12121c; border-top: 1px solid #2a2a3c; display: flex; flex-direction: column; padding: 6px 10px; gap: 4px; }
.tl-controls { display: flex; width: 100%; align-items: center; gap: 8px; flex-wrap: wrap; }
.tl-btn { width: 30px; height: 26px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
.tl-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.tl-ab-loop { position: relative; }
.tl-ab-toggle { display: flex; height: 26px; align-items: center; gap: 7px; padding: 0 7px; border: 1px solid #343a51; border-radius: 6px; background: #1e1e2e; color: #8f98ad; font-size: 10px; cursor: pointer; }
.tl-ab-toggle.on { border-color: #716eff; background: #2e2d50; color: #eef; }
.tl-switch { position: relative; width: 22px; height: 12px; border-radius: 99px; background: #343a4b; }
.tl-switch b { position: absolute; top: 2px; left: 2px; width: 8px; height: 8px; border-radius: 50%; background: #8992a5; transition: transform .15s ease, background .15s ease; }
.tl-ab-toggle.on .tl-switch { background: #716eff; }
.tl-ab-toggle.on .tl-switch b { transform: translateX(10px); background: #fff; }
.tl-ab-popover { position: absolute; z-index: 40; left: 0; bottom: calc(100% + 8px); display: flex; align-items: center; gap: 10px; padding: 9px 10px; border: 1px solid #4b4c73; border-radius: 8px; background: #171724; box-shadow: 0 10px 26px rgba(0,0,0,.5); }
.tl-ab-title { color: #b7bdd2; font-size: 10px; white-space: nowrap; }
.tl-range { display: flex; height: 25px; align-items: center; gap: 3px; color: #778198; font-size: 10px; }
.tl-range input { width: 43px; height: 25px; padding: 0 5px; box-sizing: border-box; border: 1px solid #3a3a51; border-radius: 5px; outline: none; background: #181824; color: #e1e4ed; font-size: 11px; text-align: center; }
.tl-range input:focus { border-color: #716eff; }
.tl-frame { display: inline-block; flex: 0 0 92px; width: 92px; box-sizing: border-box; font-size: 12px; color: #aab; text-align: right; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; white-space: nowrap; }
.tl-frame b { display: inline; color: inherit; font-weight: 400; }
.tl-legend { display: flex; align-items: center; gap: 4px; color: #778198; font-size: 10px; }
.tl-legend i { display: inline-block; width: 10px; height: 7px; border-radius: 2px; }
.legend-emission { margin-left: 4px; background: #2f8b62; }
.legend-life { margin-left: 5px; background: #a54551; }
.legend-spine { margin-left: 5px; background: #7448b8; }
.tl-anim { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #aab; }
.tl-owner-animation { position: relative; }
.tl-animation-trigger {
display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 116px; height: 25px; padding: 0 7px;
overflow: hidden; border: 1px solid #2e2e44; border-radius: 5px; background: #1a1a28; color: #dde; cursor: pointer;
}
.tl-animation-trigger > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-animation-trigger > i { flex: 0 0 auto; color: #8c96ad; font-style: normal; }
.tl-animation-trigger:disabled { opacity: .5; cursor: not-allowed; }
.tl-animation-menu {
position: absolute; z-index: 30; right: 0; bottom: calc(100% + 6px); width: 226px; padding: 7px;
border: 1px solid #3c4760; border-radius: 8px; background: #171b29; box-shadow: 0 10px 24px rgba(0,0,0,.45);
}
.tl-animation-menu-title { padding: 2px 4px 6px; color: #77839c; font-size: 10px; }
.tl-animation-option {
display: flex; align-items: center; justify-content: space-between; width: 100%; height: 27px; padding: 0 7px;
border: 0; border-radius: 5px; background: transparent; color: #b8c1d5; text-align: left; cursor: pointer;
}
.tl-animation-option > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-animation-option:hover { background: #252e42; }
.tl-animation-option.on { background: #34335d; color: #fff; }
.tl-animation-option > i { color: #8d8aff; font-style: normal; }
.tl-animation-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; margin-top: 7px; padding-top: 7px; border-top: 1px solid #2b3346; }
.tl-animation-name { min-width: 0; height: 27px; padding: 0 7px; box-sizing: border-box; border: 1px solid #343e55; border-radius: 5px; background: #101522; color: #e1e6f2; outline: none; }
.tl-animation-name:focus { border-color: #6865d8; }
.tl-animation-editor button, .tl-animation-actions button { height: 27px; padding: 0 8px; border: 1px solid #3c4861; border-radius: 5px; background: #29344a; color: #cbd3e3; cursor: pointer; }
.tl-animation-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 6px; }
.tl-animation-actions button:last-child { border-color: #5b3942; background: #3b282f; color: #e2a8b2; }
.tl-animation-actions button:disabled { opacity: .4; cursor: not-allowed; }
.tl-animation-error { margin-top: 5px; color: #ef8997; font-size: 10px; }
.tl-animation-hint { margin-top: 5px; color: #69758d; font-size: 9px; text-align: center; }
.tl-sel { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; padding: 2px 6px; font-size: 12px; }
.tl-sel:disabled { opacity: .5; cursor: not-allowed; }
.tl-spine-preview .tl-sel { max-width: 190px; }
.tl-zoom { margin-left: auto; font-size: 11px; color: #667; }
.tl-viewport { position: relative; flex: 1; overflow: hidden; background: #0d0d14; border: 1px solid #2a2a3c; border-radius: 6px; cursor: ew-resize; }
.tl-ticks { position: absolute; top: 0; left: 0; right: 0; height: 18px; }
.tl-tickline { position: absolute; top: 0; bottom: 0; width: 1px; background: #263040; }
.tl-ticklabel { position: absolute; top: 2px; transform: translateX(-50%); font-size: 9px; color: #667; }
.tl-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: #ffcc33; z-index: 5; pointer-events: none; }
.tl-lanes { position: absolute; top: 22px; left: 0; right: 0; bottom: 0; }
.tl-lane { position: relative; height: 26px; margin-bottom: 4px; }
.lane-ico { position: absolute; left: 4px; top: 6px; font-size: 11px; color: #dde; z-index: 2; pointer-events: none; }
.lane-ico.sub-lane-ico { color: #7f8999; }
.lane-bar { position: absolute; top: 4px; height: 18px; display: flex; align-items: stretch; cursor: grab; box-sizing: border-box; }
.lane-bar:active { cursor: grabbing; }
.lane-name { position: absolute; left: 14px; right: 5px; top: 2px; z-index: 2; pointer-events: none; font-size: 11px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar-life { position: absolute; inset: 0 auto 0 0; z-index: 0; min-width: 1px; border: 1px solid #c05a66; border-radius: 5px; background: #a54551; box-sizing: border-box; }
.sub-emitter-bar .bar-life { border-color: #727d8f; background: #596274; }
.bar-life.loop-life { border-color: #667085; background: #242b39; }
.bar-emission { position: absolute; inset: 0 auto 0 0; z-index: 1; min-width: 1px; border: 1px solid #45a97d; border-radius: 5px 0 0 5px; background: #2f8b62; box-sizing: border-box; }
.bar-emission.bar-loop { border-radius: 0; }
.bar-loop-transition { position: absolute; top: 0; bottom: 0; z-index: 1; border: 1px solid #7d8799; background: #50596a; box-sizing: border-box; }
.bar-loop-start { left: 0; border-radius: 5px 0 0 5px; }
.bar-loop-end { border-radius: 0 5px 5px 0; }
.bar-handle { position: absolute; z-index: 4; right: -4px; top: -1px; bottom: -1px; width: 8px; cursor: ew-resize; background: rgba(255,255,255,0.18); }
.bar-handle:hover { background: rgba(255,255,255,0.35); }
.spine-ico { color: #c08cff; }
.spine-bar {
position: absolute; top: 4px; height: 18px; min-width: 1px; box-sizing: border-box;
border: 1px solid #a575ed; border-radius: 5px; background: #7448b8; cursor: grab;
box-shadow: inset 0 0 0 1px rgba(255,255,255,.05);
}
.spine-bar:active { cursor: grabbing; }
.tl-empty { color: #667; font-size: 11px; padding: 8px; }
</style>