840 lines
20 KiB
Vue
840 lines
20 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { PhCheck, PhCopy, PhTrash } from '@phosphor-icons/vue'
|
|
import { useSceneStore } from '@/stores/scene'
|
|
import type { Easing, Keyframe } from '@/types/scene'
|
|
|
|
const store = useSceneStore()
|
|
|
|
const duration = computed(() => store.scene.duration)
|
|
|
|
const NAME_WIDTH = 140
|
|
const MIN_PX_PER_SEC = 2
|
|
const MAX_PX_PER_SEC = 2000
|
|
|
|
const scrollEl = ref<HTMLDivElement | null>(null)
|
|
const viewportWidth = ref(0)
|
|
const pxPerSec = ref(0)
|
|
const autoFit = ref(true)
|
|
|
|
function clamp(v: number, lo: number, hi: number) {
|
|
return Math.max(lo, Math.min(hi, v))
|
|
}
|
|
|
|
const fitPxPerSec = computed(() => {
|
|
const avail = Math.max(60, viewportWidth.value - NAME_WIDTH)
|
|
const d = Math.max(0.1, duration.value)
|
|
return avail / d
|
|
})
|
|
|
|
const effectivePxPerSec = computed(() => {
|
|
if (autoFit.value || pxPerSec.value <= 0) return fitPxPerSec.value
|
|
return pxPerSec.value
|
|
})
|
|
|
|
const laneWidth = computed(() => duration.value * effectivePxPerSec.value)
|
|
const totalWidth = computed(() => NAME_WIDTH + laneWidth.value)
|
|
|
|
type DisplayMode = 'seconds' | 'frames'
|
|
const displayMode = ref<DisplayMode>('seconds')
|
|
|
|
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]
|
|
|
|
const fps = computed(() => (store.scene.fps > 0 ? store.scene.fps : 60))
|
|
|
|
const tickStep = computed(() => {
|
|
const pps = effectivePxPerSec.value
|
|
if (displayMode.value === 'frames') {
|
|
const pxPerFrame = pps / fps.value
|
|
for (const f of TICK_CANDIDATES_FRAMES) {
|
|
if (f * pxPerFrame >= 55) return f / fps.value
|
|
}
|
|
return TICK_CANDIDATES_FRAMES[TICK_CANDIDATES_FRAMES.length - 1] / fps.value
|
|
}
|
|
for (const s of TICK_CANDIDATES_SEC) {
|
|
if (s * pps >= 55) return s
|
|
}
|
|
return TICK_CANDIDATES_SEC[TICK_CANDIDATES_SEC.length - 1]
|
|
})
|
|
|
|
const ticks = computed(() => {
|
|
const d = duration.value
|
|
const step = tickStep.value
|
|
const out: number[] = []
|
|
for (let t = 0; t <= d + 1e-6; t += step) {
|
|
out.push(Math.round(t * 1000) / 1000)
|
|
}
|
|
if (out[out.length - 1] < d - 1e-6) out.push(d)
|
|
return out
|
|
})
|
|
|
|
function pxFromTime(t: number) {
|
|
return t * effectivePxPerSec.value
|
|
}
|
|
|
|
function formatTick(t: number) {
|
|
if (displayMode.value === 'frames') {
|
|
return `${Math.round(t * fps.value)}`
|
|
}
|
|
if (Number.isInteger(t)) return `${t}s`
|
|
return `${t.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')}s`
|
|
}
|
|
|
|
function formatKfTime(t: number) {
|
|
if (displayMode.value === 'frames') return `${Math.round(t * fps.value)} fr`
|
|
return `${t.toFixed(2)}s`
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
function onScrubPointerDown(e: PointerEvent) {
|
|
const el = e.currentTarget as HTMLElement
|
|
el.setPointerCapture(e.pointerId)
|
|
seekFromEvent(e, el)
|
|
}
|
|
|
|
function onScrubPointerMove(e: PointerEvent) {
|
|
const el = e.currentTarget as HTMLElement
|
|
if (!el.hasPointerCapture(e.pointerId)) return
|
|
seekFromEvent(e, el)
|
|
}
|
|
|
|
function onScrubPointerUp(e: PointerEvent) {
|
|
const el = e.currentTarget as HTMLElement
|
|
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
|
}
|
|
|
|
function onLaneClick(e: MouseEvent) {
|
|
if (kfDrag.value) return
|
|
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))
|
|
store.setPlayhead(ratio * duration.value)
|
|
}
|
|
|
|
function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
|
|
e.stopPropagation()
|
|
if (kfDrag.value?.moved) {
|
|
kfDrag.value = null
|
|
return
|
|
}
|
|
kfDrag.value = null
|
|
store.selectLayer(layerId)
|
|
store.setPlayhead(time)
|
|
}
|
|
|
|
function onKeyframeDblClick(e: MouseEvent, layerId: string, time: number) {
|
|
e.stopPropagation()
|
|
if (kfDrag.value?.moved) return
|
|
store.removeKeyframeAt(layerId, time)
|
|
}
|
|
|
|
interface KfDrag {
|
|
layerId: string
|
|
keyframeId: string
|
|
lane: HTMLElement
|
|
pointerId: number
|
|
startX: number
|
|
moved: boolean
|
|
}
|
|
|
|
const kfDrag = ref<KfDrag | null>(null)
|
|
|
|
function onKeyframePointerDown(e: PointerEvent, layerId: string, keyframeId: string) {
|
|
if (e.button !== 0) return
|
|
const kfEl = e.currentTarget as HTMLElement
|
|
const lane = kfEl.parentElement
|
|
if (!lane) return
|
|
e.stopPropagation()
|
|
kfEl.setPointerCapture(e.pointerId)
|
|
kfDrag.value = {
|
|
layerId,
|
|
keyframeId,
|
|
lane,
|
|
pointerId: e.pointerId,
|
|
startX: e.clientX,
|
|
moved: false,
|
|
}
|
|
store.selectLayer(layerId)
|
|
}
|
|
|
|
function onKeyframePointerMove(e: PointerEvent) {
|
|
const d = kfDrag.value
|
|
if (!d || d.pointerId !== e.pointerId) return
|
|
if (!d.moved && Math.abs(e.clientX - d.startX) < 3) return
|
|
d.moved = true
|
|
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
|
|
store.setKeyframeTime(d.layerId, d.keyframeId, newTime)
|
|
store.setPlayhead(newTime)
|
|
}
|
|
|
|
function onKeyframePointerUp(e: PointerEvent) {
|
|
const d = kfDrag.value
|
|
if (!d || d.pointerId !== e.pointerId) return
|
|
const el = e.currentTarget as HTMLElement
|
|
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
|
if (!d.moved) {
|
|
kfDrag.value = null
|
|
}
|
|
}
|
|
|
|
interface KfSegment {
|
|
id: string
|
|
fromTime: number
|
|
toTime: number
|
|
easing: Easing
|
|
}
|
|
|
|
function segmentsOf(kfs: Keyframe[]): KfSegment[] {
|
|
if (kfs.length < 2) return []
|
|
const sorted = [...kfs].sort((a, b) => a.time - b.time)
|
|
const out: KfSegment[] = []
|
|
for (let i = 0; i < sorted.length - 1; i++) {
|
|
const a = sorted[i]
|
|
const b = sorted[i + 1]
|
|
out.push({
|
|
id: `${a.id}>${b.id}`,
|
|
fromTime: a.time,
|
|
toTime: b.time,
|
|
easing: b.easing,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
function segmentStyle(seg: KfSegment) {
|
|
return {
|
|
left: `${pxFromTime(seg.fromTime)}px`,
|
|
width: `${pxFromTime(seg.toTime - seg.fromTime)}px`,
|
|
}
|
|
}
|
|
|
|
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' },
|
|
]
|
|
|
|
interface KfMenuState {
|
|
layerId: string
|
|
keyframeId: string
|
|
time: number
|
|
x: number
|
|
y: number
|
|
currentEasing: Easing
|
|
isFirst: boolean
|
|
}
|
|
|
|
const menu = ref<KfMenuState | null>(null)
|
|
const menuRef = ref<HTMLDivElement | null>(null)
|
|
|
|
function onKeyframeContextMenu(e: MouseEvent, layerId: string, keyframeId: string) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const layer = store.scene.layers.find((l) => l.id === layerId)
|
|
const kf = layer?.keyframes.find((k) => k.id === keyframeId)
|
|
if (!layer || !kf) return
|
|
const sorted = [...layer.keyframes].sort((a, b) => a.time - b.time)
|
|
menu.value = {
|
|
layerId,
|
|
keyframeId: kf.id,
|
|
time: kf.time,
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
currentEasing: kf.easing,
|
|
isFirst: sorted[0]?.id === kf.id,
|
|
}
|
|
store.selectLayer(layerId)
|
|
}
|
|
|
|
function applyEasing(easing: Easing) {
|
|
if (!menu.value) return
|
|
store.setKeyframeEasing(menu.value.layerId, menu.value.time, easing)
|
|
menu.value = null
|
|
}
|
|
|
|
function deleteFromMenu() {
|
|
if (!menu.value) return
|
|
store.removeKeyframeAt(menu.value.layerId, menu.value.time)
|
|
menu.value = null
|
|
}
|
|
|
|
function duplicateFromMenu() {
|
|
if (!menu.value) return
|
|
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)
|
|
if (newKf) store.setPlayhead(newKf.time)
|
|
}
|
|
menu.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
|
|
}
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') menu.value = null
|
|
}
|
|
|
|
watch(menu, (val) => {
|
|
if (val) {
|
|
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
|
document.addEventListener('keydown', onKeyDown)
|
|
} else {
|
|
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
|
document.removeEventListener('keydown', onKeyDown)
|
|
}
|
|
})
|
|
|
|
function onWheel(e: WheelEvent) {
|
|
if (!scrollEl.value) return
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault()
|
|
const rect = scrollEl.value.getBoundingClientRect()
|
|
const cursorInViewport = e.clientX - rect.left
|
|
const laneCursor = cursorInViewport - NAME_WIDTH + scrollEl.value.scrollLeft
|
|
const currentPps = effectivePxPerSec.value
|
|
const timeAtCursor = Math.max(0, laneCursor) / currentPps
|
|
const factor = Math.pow(1.0015, -e.deltaY)
|
|
const newPps = clamp(currentPps * factor, MIN_PX_PER_SEC, MAX_PX_PER_SEC)
|
|
autoFit.value = false
|
|
pxPerSec.value = newPps
|
|
requestAnimationFrame(() => {
|
|
if (!scrollEl.value) return
|
|
const targetLane = timeAtCursor * newPps
|
|
scrollEl.value.scrollLeft = Math.max(0, targetLane - (cursorInViewport - NAME_WIDTH))
|
|
})
|
|
return
|
|
}
|
|
if (e.shiftKey) {
|
|
e.preventDefault()
|
|
scrollEl.value.scrollLeft += e.deltaY !== 0 ? e.deltaY : e.deltaX
|
|
}
|
|
}
|
|
|
|
let ro: ResizeObserver | null = null
|
|
|
|
onMounted(() => {
|
|
if (!scrollEl.value) return
|
|
ro = new ResizeObserver((entries) => {
|
|
const rect = entries[0].contentRect
|
|
viewportWidth.value = rect.width
|
|
})
|
|
ro.observe(scrollEl.value)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
ro?.disconnect()
|
|
ro = null
|
|
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
|
document.removeEventListener('keydown', onKeyDown)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="timeline">
|
|
<div ref="scrollEl" class="tl-scroll" @wheel="onWheel">
|
|
<div class="tl-content" :style="{ width: totalWidth + 'px' }">
|
|
<div class="scrub-bar">
|
|
<div class="name-spacer">
|
|
<div class="mode-toggle" title="Zeit-Einheit umschalten">
|
|
<button
|
|
class="mode-btn"
|
|
:class="{ active: displayMode === 'seconds' }"
|
|
@click="displayMode = 'seconds'"
|
|
>s</button>
|
|
<button
|
|
class="mode-btn"
|
|
:class="{ active: displayMode === 'frames' }"
|
|
@click="displayMode = 'frames'"
|
|
>fr</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="scrub-ruler"
|
|
:style="{ width: laneWidth + 'px' }"
|
|
@pointerdown="onScrubPointerDown"
|
|
@pointermove="onScrubPointerMove"
|
|
@pointerup="onScrubPointerUp"
|
|
@pointercancel="onScrubPointerUp"
|
|
>
|
|
<span
|
|
v-for="t in ticks"
|
|
:key="t"
|
|
class="tick"
|
|
:class="{ 'tick-start': t === 0, 'tick-end': Math.abs(t - duration) < 1e-6 }"
|
|
:style="{ left: pxFromTime(t) + 'px' }"
|
|
>
|
|
<span class="tick-line"></span>
|
|
<span class="tick-label">{{ formatTick(t) }}</span>
|
|
</span>
|
|
<span class="scrub-handle" :style="{ left: pxFromTime(store.playhead) + 'px' }">
|
|
<span class="scrub-handle-head"></span>
|
|
<span class="scrub-handle-line"></span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="tracks">
|
|
<div v-for="layer in store.scene.layers" :key="layer.id" class="track">
|
|
<span
|
|
class="track-name"
|
|
:class="{ selected: layer.id === store.selectedLayerId }"
|
|
@click="store.selectLayer(layer.id)"
|
|
>
|
|
{{ layer.name }}
|
|
</span>
|
|
<div class="track-lane" :style="{ width: laneWidth + 'px' }" @click="onLaneClick">
|
|
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
|
<span
|
|
v-for="seg in segmentsOf(layer.keyframes)"
|
|
:key="seg.id"
|
|
class="kf-seg"
|
|
:class="`easing-${seg.easing}`"
|
|
:style="segmentStyle(seg)"
|
|
/>
|
|
<span
|
|
v-for="kf in layer.keyframes"
|
|
: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)"
|
|
@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)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p v-if="!store.scene.layers.length" class="empty">Noch keine Layer.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="menu"
|
|
ref="menuRef"
|
|
class="kf-menu"
|
|
:style="{ left: menu.x + 'px', top: menu.y + 'px' }"
|
|
>
|
|
<div class="kf-menu-section">
|
|
<div class="kf-menu-header">Easing</div>
|
|
<button
|
|
v-for="opt in EASING_OPTIONS"
|
|
:key="opt.value"
|
|
class="kf-menu-item"
|
|
:class="{ active: menu.currentEasing === opt.value }"
|
|
:disabled="menu.isFirst"
|
|
@click="applyEasing(opt.value)"
|
|
>
|
|
<span class="check">
|
|
<PhCheck v-if="menu.currentEasing === opt.value" :size="12" weight="bold" />
|
|
</span>
|
|
{{ opt.label }}
|
|
</button>
|
|
<div v-if="menu.isFirst" class="kf-menu-note">Erster Keyframe — Easing ohne Effekt</div>
|
|
</div>
|
|
<div class="kf-menu-divider"></div>
|
|
<button class="kf-menu-item" @click="duplicateFromMenu">
|
|
<PhCopy :size="12" />
|
|
Duplizieren
|
|
</button>
|
|
<button class="kf-menu-item danger" @click="deleteFromMenu">
|
|
<PhTrash :size="12" />
|
|
Löschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.timeline {
|
|
--track-name-width: 140px;
|
|
position: relative;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tl-scroll {
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow-x: auto;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.tl-content {
|
|
position: relative;
|
|
min-width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.scrub-bar {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
display: flex;
|
|
height: 28px;
|
|
background: var(--panel);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.name-spacer {
|
|
position: sticky;
|
|
left: 0;
|
|
z-index: 2;
|
|
width: var(--track-name-width);
|
|
background: var(--panel);
|
|
border-right: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 10px;
|
|
}
|
|
|
|
.mode-toggle {
|
|
display: inline-flex;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
overflow: hidden;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.mode-btn {
|
|
padding: 3px 8px;
|
|
min-width: 22px;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 0;
|
|
color: var(--text-dim);
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
text-transform: lowercase;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.mode-btn:hover:not(:disabled) {
|
|
background: var(--panel-alt);
|
|
color: var(--text);
|
|
}
|
|
|
|
.mode-btn.active {
|
|
background: var(--accent-soft);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.mode-btn + .mode-btn {
|
|
border-left: 1px solid var(--border);
|
|
}
|
|
|
|
.scrub-ruler {
|
|
position: relative;
|
|
height: 100%;
|
|
cursor: ew-resize;
|
|
user-select: none;
|
|
touch-action: none;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tick {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
transform: translateX(-50%);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
pointer-events: none;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.tick.tick-start {
|
|
transform: translateX(0);
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.tick.tick-end {
|
|
transform: translateX(-100%);
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.tick-line {
|
|
width: 1px;
|
|
height: 6px;
|
|
background: var(--border);
|
|
}
|
|
|
|
.tick-label {
|
|
font-size: 10px;
|
|
line-height: 1;
|
|
padding-top: 3px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.scrub-handle {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: -1px;
|
|
transform: translateX(-50%);
|
|
pointer-events: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
|
|
.scrub-handle-head {
|
|
width: 11px;
|
|
height: 11px;
|
|
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);
|
|
}
|
|
|
|
.scrub-handle-line {
|
|
flex: 1;
|
|
width: 1px;
|
|
background: var(--accent);
|
|
}
|
|
|
|
.tracks {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.track {
|
|
display: flex;
|
|
align-items: center;
|
|
height: 30px;
|
|
border-bottom: 1px solid var(--border);
|
|
transition: background 140ms var(--ease);
|
|
}
|
|
|
|
.track-name {
|
|
position: sticky;
|
|
left: 0;
|
|
z-index: 3;
|
|
width: var(--track-name-width);
|
|
padding: 0 12px;
|
|
color: var(--text-dim);
|
|
font-size: 12px;
|
|
border-right: 1px solid var(--border);
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
flex-shrink: 0;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
background: var(--panel);
|
|
transition: color 140ms var(--ease), background 140ms var(--ease);
|
|
}
|
|
|
|
.track-name:hover {
|
|
background: var(--panel-alt);
|
|
color: var(--text);
|
|
}
|
|
|
|
.track-name.selected {
|
|
color: var(--accent);
|
|
background: var(--accent-soft);
|
|
}
|
|
|
|
.track-lane {
|
|
position: relative;
|
|
height: 100%;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.playhead {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 1px;
|
|
background: var(--accent);
|
|
pointer-events: none;
|
|
transform: translateX(-0.5px);
|
|
}
|
|
|
|
.kf-seg {
|
|
position: absolute;
|
|
top: 50%;
|
|
height: 3px;
|
|
margin-top: -1.5px;
|
|
border-radius: 999px;
|
|
pointer-events: none;
|
|
background: var(--accent);
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.kf-seg.easing-linear {
|
|
background: var(--accent);
|
|
}
|
|
|
|
.kf-seg.easing-easeIn {
|
|
background: linear-gradient(
|
|
90deg,
|
|
rgba(110, 168, 255, 0.15) 0%,
|
|
rgba(110, 168, 255, 0.25) 30%,
|
|
rgba(110, 168, 255, 1) 100%
|
|
);
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.kf-seg.easing-easeOut {
|
|
background: linear-gradient(
|
|
90deg,
|
|
rgba(110, 168, 255, 1) 0%,
|
|
rgba(110, 168, 255, 0.25) 70%,
|
|
rgba(110, 168, 255, 0.15) 100%
|
|
);
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.kf-seg.easing-easeInOut {
|
|
background: linear-gradient(
|
|
90deg,
|
|
rgba(110, 168, 255, 0.15) 0%,
|
|
rgba(110, 168, 255, 1) 50%,
|
|
rgba(110, 168, 255, 0.15) 100%
|
|
);
|
|
opacity: 0.95;
|
|
}
|
|
|
|
.kf {
|
|
position: absolute;
|
|
top: 50%;
|
|
width: 11px;
|
|
height: 11px;
|
|
margin-left: -5.5px;
|
|
margin-top: -5.5px;
|
|
background: var(--accent);
|
|
transform: rotate(45deg);
|
|
border-radius: 2px;
|
|
cursor: grab;
|
|
touch-action: none;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
|
transition: background 120ms var(--ease), transform 120ms var(--ease);
|
|
z-index: 2;
|
|
}
|
|
|
|
.kf:hover {
|
|
background: #fff;
|
|
transform: rotate(45deg) scale(1.15);
|
|
}
|
|
|
|
.kf:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.empty {
|
|
color: var(--text-dim);
|
|
font-style: italic;
|
|
padding: 12px;
|
|
margin: 0;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.kf-menu {
|
|
position: fixed;
|
|
z-index: 1000;
|
|
min-width: 180px;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border-strong);
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--shadow-md);
|
|
padding: 5px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.kf-menu-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.kf-menu-header {
|
|
padding: 6px 10px 2px;
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.kf-menu-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 7px 10px;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text);
|
|
font: inherit;
|
|
font-size: 12px;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
border-radius: var(--radius-sm);
|
|
width: 100%;
|
|
}
|
|
|
|
.kf-menu-item:hover:not(:disabled) {
|
|
background: var(--panel-alt);
|
|
}
|
|
|
|
.kf-menu-item:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.kf-menu-item.active {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.kf-menu-item.danger {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.kf-menu-item.danger:hover:not(:disabled) {
|
|
background: var(--danger-soft);
|
|
}
|
|
|
|
.check {
|
|
width: 14px;
|
|
display: inline-flex;
|
|
justify-content: center;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.kf-menu-note {
|
|
padding: 2px 10px 4px;
|
|
font-size: 10px;
|
|
font-style: italic;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.kf-menu-divider {
|
|
height: 1px;
|
|
background: var(--border);
|
|
margin: 4px 0;
|
|
}
|
|
</style>
|