init
This commit is contained in:
commit
c6863f2bfc
34 files changed
+6909
No files matched your search
@@ -0,0 +1,839 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user