Files
RUWIO/src/components/Timeline.vue
T
thandClaude Opus 4.7 276d621d20 0.3.0: freeform drawing, stroke properties, polygon edit gestures
- Freeform drawing tool: new sidebar button enters a click-to-draw mode
  that appends points, shows a live ghost preview line, and exits via
  Enter or double-click (near the start point closes the path). Works
  end-to-end through all four render pipelines.
- Polygon authoring gestures: double-click on a path inserts a vertex on
  the nearest edge; double-click on an open polygon's endpoint resumes
  the drawing session from that end, reversing point order when needed.
- Stroke + closed-path properties: all shape types (rect, circle, text,
  polygon) expose stroke color, stroke width, and (for polygons) a
  closed toggle. Fill and stroke each support a "transparent" switch so
  shapes can render as outline-only or fill-only.
- Mask preview no longer inherits the user's stroke — the dashed ghost
  keeps its own outline regardless of stroke-width changes.
- Playhead no longer dims on inactive timeline tracks; only keyframes,
  segments and clip blocks fade.
- Properties panel point-count control for polygons, plus per-keyframe
  points animation that correctly interpolates once a baseline exists.
- Pivot drag hides the rotation handle until release.
- Transformer attach retries on a short timer to avoid races where the
  Konva node hasn't mounted yet when selection changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 09:53:01 +02:00

2985 lines
81 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
PhArrowsSplit,
PhCaretDown,
PhCaretRight,
PhCheck,
PhCopy,
PhDotsSixVertical,
PhFolder,
PhFolderPlus,
PhLock,
PhLockOpen,
PhMagnet,
PhMaskHappy,
PhPauseCircle,
PhTrash,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
import type {
AnimatableProps,
Clip,
ClipInstance,
Easing,
Keyframe,
Layer,
TrackGroup,
} from '@/types/scene'
interface TimelineLayerTrack {
type: 'layer'
layerId: string
layer: Layer
kfs: Keyframe[]
kind: 'main' | 'position' | 'rotation'
label: string
isParent: boolean
orderIndex: number
inGroup: boolean
parentGroupId?: string
groupChildIndex?: number
}
interface TimelineClipTrack {
type: 'clip'
clipTrackId: string
name: string
orderIndex: number
inGroup: boolean
parentGroupId?: string
groupChildIndex?: number
}
interface TimelineGroupTrack {
type: 'group'
groupId: string
name: string
collapsed: boolean
color: string | null
orderIndex: number
childCount: number
}
type TimelineTrack = TimelineLayerTrack | TimelineClipTrack | TimelineGroupTrack
const store = useSceneStore()
const duration = computed(() => store.activeDuration)
const inClipEdit = computed(() => store.activeClipId !== null)
const clipInstancesByTrack = computed<Record<string, ClipInstance[]>>(() => {
const out: Record<string, ClipInstance[]> = {}
for (const ci of store.scene.clipInstances ?? []) {
;(out[ci.clipTrackId] ||= []).push(ci)
}
return out
})
const clipById = computed(() => {
const m = new Map<string, Clip>()
for (const c of store.scene.clips ?? []) m.set(c.id, c)
return m
})
const clipTrackById = computed(() => {
const m = new Map<string, NonNullable<typeof store.scene.clipTracks>[number]>()
for (const t of store.scene.clipTracks ?? []) m.set(t.id, t)
return m
})
const groupById = computed(() => {
const m = new Map<string, TrackGroup>()
for (const g of store.scene.groups ?? []) m.set(g.id, g)
return m
})
const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth'
const TRACK_NAME_WIDTH_DEFAULT = 140
const TRACK_NAME_WIDTH_MIN = 80
const TRACK_NAME_WIDTH_MAX = 400
function readStoredTrackNameWidth(): number {
try {
const raw = localStorage.getItem(TRACK_NAME_WIDTH_KEY)
if (raw === null) return TRACK_NAME_WIDTH_DEFAULT
const n = Number.parseInt(raw, 10)
if (Number.isFinite(n) && n >= TRACK_NAME_WIDTH_MIN) return Math.min(n, TRACK_NAME_WIDTH_MAX)
} catch {
/* ignore */
}
return TRACK_NAME_WIDTH_DEFAULT
}
const trackNameWidth = ref(readStoredTrackNameWidth())
const timelineRootStyle = computed(() => ({
'--track-name-width': `${trackNameWidth.value}px`,
}))
let nameResizeStartX = 0
let nameResizeStartW = 0
let nameResizePointerId = -1
const nameResizing = ref(false)
function onNameResizeStart(e: PointerEvent) {
if (e.button !== 0) return
e.preventDefault()
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
nameResizePointerId = e.pointerId
nameResizeStartX = e.clientX
nameResizeStartW = trackNameWidth.value
nameResizing.value = true
document.body.style.userSelect = 'none'
}
function onNameResizeMove(e: PointerEvent) {
if (!nameResizing.value || e.pointerId !== nameResizePointerId) return
const dx = e.clientX - nameResizeStartX
trackNameWidth.value = Math.max(
TRACK_NAME_WIDTH_MIN,
Math.min(TRACK_NAME_WIDTH_MAX, nameResizeStartW + dx),
)
}
function onNameResizeEnd(e: PointerEvent) {
if (!nameResizing.value || e.pointerId !== nameResizePointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
nameResizePointerId = -1
nameResizing.value = false
document.body.style.userSelect = ''
try {
localStorage.setItem(TRACK_NAME_WIDTH_KEY, String(trackNameWidth.value))
} catch {
/* ignore */
}
}
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 - trackNameWidth.value)
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(() => trackNameWidth.value + laneWidth.value)
type DisplayMode = 'seconds' | 'frames'
const displayMode = ref<DisplayMode>('seconds')
const snapEnabled = ref(true)
const SNAP_PX = 8
function allKeyframeTimes(ignoreKeyframeId?: string): number[] {
const out: number[] = []
for (const layer of store.scene.layers) {
for (const k of layer.keyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
if (layer.positionKeyframes)
for (const k of layer.positionKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
}
return out
}
function snapTime(t: number, ignoreKeyframeId?: string): number {
return snapToCandidates(t, allKeyframeTimes(ignoreKeyframeId), {
enabled: snapEnabled.value,
pxPerSec: effectivePxPerSec.value,
pixelThreshold: SNAP_PX,
duration: duration.value,
})
}
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 groupKeyframeTimes(groupId: string): number[] {
const group = groupById.value.get(groupId)
if (!group) return []
const times: number[] = []
const layerById = new Map<string, Layer>()
for (const l of store.scene.layers) layerById.set(l.id, l)
for (const childId of group.childIds) {
const layer = layerById.get(childId)
if (layer) {
for (const k of layer.keyframes) times.push(k.time)
for (const k of layer.positionKeyframes ?? []) times.push(k.time)
for (const k of layer.rotationKeyframes ?? []) times.push(k.time)
continue
}
const ct = clipTrackById.value.get(childId)
if (ct) {
for (const ci of clipInstancesByTrack.value[ct.id] ?? []) {
times.push(ci.startTime)
}
}
}
return times
}
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(snapTime(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(snapTime(ratio * duration.value))
}
function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
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.activeLayers.find((l) => l.id === track.layerId)
if (!layer || layer.locked) 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) {
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, keyframeId: string) {
e.stopPropagation()
if (kfDrag.value?.moved) return
const layer = store.activeLayers.find((l) => l.id === layerId)
if (layer?.locked) return
store.removeKeyframe(layerId, keyframeId)
}
function emitLayerTrack(
layer: Layer,
orderIndex: number,
inGroup: boolean,
out: TimelineTrack[],
parentGroupId?: string,
groupChildIndex?: number,
) {
const base = { type: 'layer' as const, layerId: layer.id, layer, orderIndex, inGroup, parentGroupId, groupChildIndex }
if (!layer.separated) {
out.push({ ...base, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true })
} else {
out.push({ ...base, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true })
out.push({ ...base, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false })
out.push({ ...base, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false })
}
}
const tracks = computed<TimelineTrack[]>(() => {
const out: TimelineTrack[] = []
if (store.activeClipId) {
store.activeLayers.forEach((layer, orderIndex) => {
emitLayerTrack(layer, orderIndex, false, out)
})
return out
}
const order = store.scene.trackOrder ?? []
const layerById = new Map<string, Layer>()
for (const l of store.scene.layers) layerById.set(l.id, l)
order.forEach((id, orderIndex) => {
const layer = layerById.get(id)
if (layer) {
emitLayerTrack(layer, orderIndex, false, out)
return
}
const ct = clipTrackById.value.get(id)
if (ct) {
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? t('timeline.clipTrackName'), orderIndex, inGroup: false })
return
}
const grp = groupById.value.get(id)
if (grp) {
out.push({
type: 'group',
groupId: grp.id,
name: grp.name,
collapsed: !!grp.collapsed,
color: grp.color ?? null,
orderIndex,
childCount: grp.childIds.length,
})
if (!grp.collapsed) {
grp.childIds.forEach((cid, childIndex) => {
const cl = layerById.get(cid)
if (cl) {
emitLayerTrack(cl, -1, true, out, grp.id, childIndex)
return
}
const cct = clipTrackById.value.get(cid)
if (cct) {
out.push({
type: 'clip',
clipTrackId: cct.id,
name: cct.name ?? t('timeline.clipTrackName'),
orderIndex: -1,
inGroup: true,
parentGroupId: grp.id,
groupChildIndex: childIndex,
})
}
})
}
}
})
return out
})
function trackDndId(track: TimelineTrack): string {
if (track.type === 'layer') return track.layerId
if (track.type === 'clip') return track.clipTrackId
return track.groupId
}
function isTrackHead(track: TimelineTrack): boolean {
if (track.type === 'layer') return track.isParent
return true
}
function trackChildIndex(track: TimelineTrack): number | null {
if (track.type === 'layer' || track.type === 'clip') {
return track.groupChildIndex ?? null
}
return null
}
function trackParentGroupId(track: TimelineTrack): string | null {
if (track.type === 'layer' || track.type === 'clip') {
return track.parentGroupId ?? null
}
return null
}
function isDropBefore(track: TimelineTrack): boolean {
if (dropIndex.value === null) return false
if (!isTrackHead(track)) return false
if (dragTrackId.value === trackDndId(track)) return false
if (dropWithinGroupId.value) {
if (trackParentGroupId(track) !== dropWithinGroupId.value) return false
return dropIndex.value === trackChildIndex(track)
}
if (track.type !== 'group' && track.inGroup) return false
return dropIndex.value === track.orderIndex
}
function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined): boolean {
if (dropIndex.value === null) return false
if (dragTrackId.value === trackDndId(track)) return false
if (dropWithinGroupId.value) {
if (trackParentGroupId(track) !== dropWithinGroupId.value) return false
const ci = trackChildIndex(track)
if (ci === null) return false
if (dropIndex.value !== ci + 1) return false
if (nextTrack && trackChildIndex(nextTrack) === ci) return false
return true
}
if (track.type !== 'group' && track.inGroup) return false
if (dropIndex.value !== track.orderIndex + 1) return false
if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false
return true
}
type SelectionKey = KfSelectionKey
const { selectedKfs, selectedKfSet } = useKfSelection()
const selectionKey = kfSelectionKey
function isKfSelected(layerId: string, kind: 'main' | 'position' | 'rotation', keyframeId: string): boolean {
return selectedKfSet.value.has(`${layerId}:${kind}:${keyframeId}`)
}
function clearKfSelection() {
selectedKfs.value = []
}
interface SelectionSnapshotEntry {
layerId: string
keyframeId: string
kind: 'main' | 'position' | 'rotation'
initialTime: number
}
function snapshotSelection(): SelectionSnapshotEntry[] {
const out: SelectionSnapshotEntry[] = []
for (const s of selectedKfs.value) {
const layer = store.activeLayers.find((l) => l.id === s.layerId)
if (!layer) continue
const arr =
s.kind === 'position'
? layer.positionKeyframes
: s.kind === 'rotation'
? layer.rotationKeyframes
: layer.keyframes
const kf = arr?.find((k) => k.id === s.keyframeId)
if (kf) out.push({ ...s, initialTime: kf.time })
}
return out
}
interface KfDrag {
layerId: string
keyframeId: string
kind: 'main' | 'position' | 'rotation'
lane: HTMLElement
pointerId: number
startX: number
moved: boolean
initialTime: number
snapshot: SelectionSnapshotEntry[]
}
const kfDrag = ref<KfDrag | null>(null)
function onKeyframePointerDown(
e: PointerEvent,
layerId: string,
keyframeId: string,
kind: 'main' | 'position' | 'rotation',
) {
if (e.button !== 0) return
const layer = store.activeLayers.find((l) => l.id === layerId)
if (layer?.locked) return
const kfEl = e.currentTarget as HTMLElement
const lane = kfEl.parentElement
if (!lane) return
// If this keyframe isn't in the current selection, replace selection with just this one.
const key = `${layerId}:${kind}:${keyframeId}`
if (!selectedKfSet.value.has(key)) {
selectedKfs.value = [{ layerId, kind, keyframeId }]
}
const arr =
kind === 'position'
? layer?.positionKeyframes
: kind === 'rotation'
? layer?.rotationKeyframes
: layer?.keyframes
const srcKf = arr?.find((k) => k.id === keyframeId)
const initialTime = srcKf?.time ?? 0
e.stopPropagation()
kfEl.setPointerCapture(e.pointerId)
kfDrag.value = {
layerId,
keyframeId,
kind,
lane,
pointerId: e.pointerId,
startX: e.clientX,
moved: false,
initialTime,
snapshot: snapshotSelection(),
}
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 rawNewTime = ratio * duration.value
// For snapping, ignore all currently selected kfs so we don't snap to ourselves
const ignoreIds = new Set<string>()
for (const s of d.snapshot) ignoreIds.add(s.keyframeId)
const snappedPrimary = snapToCandidates(
rawNewTime,
allKeyframeTimesExcluding(ignoreIds),
{
enabled: snapEnabled.value,
pxPerSec: effectivePxPerSec.value,
pixelThreshold: SNAP_PX,
duration: duration.value,
},
)
// Group delta clamped so nothing leaves [0, duration]
let delta = snappedPrimary - d.initialTime
let minDelta = -Infinity
let maxDelta = Infinity
for (const s of d.snapshot) {
if (-s.initialTime > minDelta) minDelta = -s.initialTime
if (duration.value - s.initialTime < maxDelta) maxDelta = duration.value - s.initialTime
}
delta = Math.max(minDelta, Math.min(maxDelta, delta))
if (d.snapshot.length <= 1) {
store.setKeyframeTime(d.layerId, d.keyframeId, d.initialTime + delta)
} else {
for (const s of d.snapshot) {
store.setKeyframeTime(s.layerId, s.keyframeId, s.initialTime + delta)
}
}
store.setPlayhead(d.initialTime + delta)
}
function allKeyframeTimesExcluding(ignoreIds: Set<string>): number[] {
const out: number[] = []
for (const layer of store.activeLayers) {
for (const k of layer.keyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
if (layer.positionKeyframes)
for (const k of layer.positionKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
}
return out
}
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
held: boolean
}
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,
held: !!a.hold,
})
}
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: t('timeline.easingLinear') },
{ value: 'easeIn', label: t('timeline.easingEaseIn') },
{ value: 'easeOut', label: t('timeline.easingEaseOut') },
{ value: 'easeInOut', label: t('timeline.easingEaseInOut') },
]
interface KfMenuState {
layerId: string
keyframeId: string
time: number
x: number
y: number
currentEasing: Easing
isFirst: boolean
isLast: boolean
isHeld: boolean
}
const menu = ref<KfMenuState | null>(null)
const menuRef = ref<HTMLDivElement | null>(null)
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 = kfs.find((k) => k.id === keyframeId)
if (!layer || !kf) return
const sorted = [...kfs].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,
isLast: sorted[sorted.length - 1]?.id === kf.id,
isHeld: !!kf.hold,
}
store.selectLayer(layerId)
}
function toggleHold() {
if (!menu.value) return
store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
menu.value = null
}
function applyEasing(easing: Easing) {
if (!menu.value) return
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
menu.value = null
}
function deleteFromMenu() {
if (!menu.value) return
store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
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 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 {
kind: 'layer' | 'clip' | 'group'
trackId: string
x: number
y: number
separated: boolean
currentColor: string | null
locked: boolean
}
const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
{ value: null, label: t('timeline.colors.none') },
{ value: '#ff6b6b', label: t('timeline.colors.red') },
{ value: '#ffa94d', label: t('timeline.colors.orange') },
{ value: '#ffd43b', label: t('timeline.colors.yellow') },
{ value: '#51cf66', label: t('timeline.colors.green') },
{ value: '#4dc4ff', label: t('timeline.colors.cyan') },
{ value: '#6ea8ff', label: t('timeline.colors.blue') },
{ value: '#b197fc', label: t('timeline.colors.purple') },
{ value: '#ff8cc6', label: t('timeline.colors.pink') },
{ value: '#adb5bd', label: t('timeline.colors.gray') },
]
const dragTrackId = ref<string | null>(null)
const dropIndex = ref<number | null>(null)
const dropWithinGroupId = ref<string | null>(null)
function onTrackDragStart(e: DragEvent, trackId: string) {
if (!e.dataTransfer) return
dragTrackId.value = trackId
e.dataTransfer.effectAllowed = 'move'
try {
e.dataTransfer.setData('text/plain', trackId)
} catch {
/* ignore */
}
}
function onTrackDragOver(e: DragEvent, orderIndex: number) {
if (!dragTrackId.value) return
if (!e.dataTransfer) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const midY = rect.top + rect.height / 2
dropIndex.value = e.clientY < midY ? orderIndex : orderIndex + 1
dropWithinGroupId.value = null
}
function onGroupChildDragOver(e: DragEvent, groupId: string, childIndex: number) {
if (!dragTrackId.value) return
if (dragTrackId.value === groupId) return
// Groups themselves cannot be nested inside another group
if (groupById.value.has(dragTrackId.value)) return
if (!e.dataTransfer) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const midY = rect.top + rect.height / 2
dropIndex.value = e.clientY < midY ? childIndex : childIndex + 1
dropWithinGroupId.value = groupId
dropGroupId.value = null
}
function onGroupChildDrop(e: DragEvent) {
e.preventDefault()
const srcId = dragTrackId.value
const target = dropIndex.value
const groupId = dropWithinGroupId.value
dragTrackId.value = null
dropIndex.value = null
dropWithinGroupId.value = null
dropGroupId.value = null
if (!srcId || target === null || !groupId) return
if (srcId === groupId) return
if (groupById.value.has(srcId)) return
const group = groupById.value.get(groupId)
if (!group) return
const from = group.childIds.indexOf(srcId)
let to = target
// If source is already inside this group and before the target index,
// removal will shift subsequent positions down by one.
if (from >= 0 && from < to) to -= 1
store.moveTrackIntoGroup(srcId, groupId, to)
}
function onTrackDragLeave() {
/* keep dropIndex to avoid flicker */
}
function onTrackDrop(e: DragEvent) {
e.preventDefault()
const srcId = dragTrackId.value
const target = dropIndex.value
dragTrackId.value = null
dropIndex.value = null
if (!srcId || target === null) return
const order = store.scene.trackOrder ?? []
const from = order.indexOf(srcId)
if (from < 0) return
let to = target
if (from < to) to -= 1
store.moveTrack(from, to)
}
function onTrackDragEnd() {
dragTrackId.value = null
dropIndex.value = null
dropGroupId.value = null
dropWithinGroupId.value = null
}
const dropGroupId = ref<string | null>(null)
function onGroupBodyDragOver(e: DragEvent, groupId: string) {
if (!dragTrackId.value) return
// A group can't be moved into itself
if (dragTrackId.value === groupId) return
// A group itself can't be moved into another group (keep groups top-level)
if (groupById.value.has(dragTrackId.value)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
dropGroupId.value = groupId
dropIndex.value = null
}
function onGroupBodyDragLeave() {
// kept across frames; cleared on drop or dragend
}
function onGroupBodyDrop(e: DragEvent, groupId: string) {
e.preventDefault()
const srcId = dragTrackId.value
dragTrackId.value = null
dropIndex.value = null
dropGroupId.value = null
if (!srcId) return
if (srcId === groupId) return
if (groupById.value.has(srcId)) return
store.moveTrackIntoGroup(srcId, groupId)
}
function toggleGroupCollapsed(groupId: string) {
store.toggleGroupCollapsed(groupId)
}
function addGroup() {
store.addTrackGroup()
}
async function renameGroupPrompt(groupId: string, current: string) {
const next = await promptDialog({
title: t('timeline.renameGroupDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameGroupDialog.confirm'),
})
if (next === null) return
store.renameGroup(groupId, next)
}
async function ungroup(groupId: string) {
store.removeTrackGroup(groupId)
}
interface BoxSelectState {
pointerId: number
originClientX: number
originClientY: number
currentClientX: number
currentClientY: number
tracksEl: HTMLElement
moved: boolean
additive: boolean
}
const boxSelect = ref<BoxSelectState | null>(null)
const boxRectStyle = computed(() => {
const b = boxSelect.value
if (!b || !b.moved) return null
const tRect = b.tracksEl.getBoundingClientRect()
const left = Math.min(b.originClientX, b.currentClientX) - tRect.left
const top = Math.min(b.originClientY, b.currentClientY) - tRect.top
const width = Math.abs(b.currentClientX - b.originClientX)
const height = Math.abs(b.currentClientY - b.originClientY)
return {
left: left + 'px',
top: top + 'px',
width: width + 'px',
height: height + 'px',
}
})
function onTracksPointerDown(e: PointerEvent) {
if (e.button !== 0) return
const target = e.target as HTMLElement | null
if (!target) return
if (
target.closest(
'.kf, .clip-block, .clip-handle, .track-name, .drag-handle, .add-clip-btn, .playhead',
)
) {
return
}
const tracksEl = e.currentTarget as HTMLElement
tracksEl.setPointerCapture(e.pointerId)
boxSelect.value = {
pointerId: e.pointerId,
originClientX: e.clientX,
originClientY: e.clientY,
currentClientX: e.clientX,
currentClientY: e.clientY,
tracksEl,
moved: false,
additive: e.shiftKey,
}
}
function onTracksPointerMove(e: PointerEvent) {
const b = boxSelect.value
if (!b || b.pointerId !== e.pointerId) return
b.currentClientX = e.clientX
b.currentClientY = e.clientY
if (!b.moved) {
const dx = b.currentClientX - b.originClientX
const dy = b.currentClientY - b.originClientY
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) b.moved = true
}
}
function onTracksPointerUp(e: PointerEvent) {
const b = boxSelect.value
if (!b || b.pointerId !== e.pointerId) return
if (b.tracksEl.hasPointerCapture(e.pointerId)) b.tracksEl.releasePointerCapture(e.pointerId)
if (b.moved) {
commitBoxSelection(b)
} else if (!b.additive) {
// bare click on empty space without Shift → clear selection
clearKfSelection()
}
boxSelect.value = null
}
function commitBoxSelection(b: BoxSelectState) {
const left = Math.min(b.originClientX, b.currentClientX)
const right = Math.max(b.originClientX, b.currentClientX)
const top = Math.min(b.originClientY, b.currentClientY)
const bottom = Math.max(b.originClientY, b.currentClientY)
const nodes = b.tracksEl.querySelectorAll<HTMLElement>('.kf[data-kf-id]')
const hits: SelectionKey[] = []
for (const node of Array.from(nodes)) {
const r = node.getBoundingClientRect()
const cx = r.left + r.width / 2
const cy = r.top + r.height / 2
if (cx < left || cx > right || cy < top || cy > bottom) continue
const layerId = node.dataset.layerId
const kind = node.dataset.kind as 'main' | 'position' | 'rotation' | undefined
const keyframeId = node.dataset.kfId
if (!layerId || !kind || !keyframeId) continue
hits.push({ layerId, kind, keyframeId })
}
if (b.additive) {
const seen = new Set(selectedKfs.value.map(selectionKey))
const merged = [...selectedKfs.value]
for (const h of hits) {
const k = selectionKey(h)
if (!seen.has(k)) {
seen.add(k)
merged.push(h)
}
}
selectedKfs.value = merged
} else {
selectedKfs.value = hits
}
}
function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): number {
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return 0
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
return ratio * duration.value
}
function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) {
if (e.dataTransfer?.types.includes('application/x-animatr-clip')) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
}
function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
e.preventDefault()
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
if (track?.locked) return
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromEvent(e, el)
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
if (clipId) {
store.addClipInstance(clipId, time, clipTrackId)
}
}
interface ClipMoveState {
instanceId: string
pointerId: number
startX: number
initialStartTime: number
moved: boolean
}
const clipMove = ref<ClipMoveState | null>(null)
function onClipBlockPointerDown(e: PointerEvent, ci: ClipInstance) {
if (e.button !== 0) return
const track = store.scene.clipTracks?.find((t) => t.id === ci.clipTrackId)
if (track?.locked) return
// Let handles handle their own pointerdowns
const target = e.target as HTMLElement | null
if (target?.classList.contains('clip-handle')) return
e.preventDefault()
e.stopPropagation()
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
clipMove.value = {
instanceId: ci.id,
pointerId: e.pointerId,
startX: e.clientX,
initialStartTime: ci.startTime,
moved: false,
}
}
function onClipBlockPointerMove(e: PointerEvent) {
const m = clipMove.value
if (!m || m.pointerId !== e.pointerId) return
const deltaPx = e.clientX - m.startX
if (!m.moved && Math.abs(deltaPx) < 2) return
m.moved = true
const pps = effectivePxPerSec.value
if (pps <= 0) return
const deltaSec = deltaPx / pps
const newStart = Math.max(0, m.initialStartTime + deltaSec)
store.moveClipInstance(m.instanceId, newStart)
}
function onClipBlockPointerUp(e: PointerEvent) {
const m = clipMove.value
if (!m || m.pointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
const wasMoved = m.moved
clipMove.value = null
// suppress click/dblclick if we actually dragged
if (wasMoved) e.preventDefault()
}
const clipDragOverDropzone = ref(false)
function onNewTrackDragEnter(e: DragEvent) {
if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
e.preventDefault()
clipDragOverDropzone.value = true
}
function onNewTrackDragOver(e: DragEvent) {
if (!e.dataTransfer?.types.includes('application/x-animatr-clip')) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
clipDragOverDropzone.value = true
}
function onNewTrackDragLeave() {
clipDragOverDropzone.value = false
}
function onNewTrackDrop(e: DragEvent) {
clipDragOverDropzone.value = false
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
if (!clipId) return
e.preventDefault()
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromLane(e, el)
const track = store.addClipTrack()
store.addClipInstance(clipId, time, track.id)
}
function pixelTimeFromLane(e: DragEvent, el: HTMLElement): number {
// Account for sticky track-name column offset: compute based on the full tl-content lane.
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return 0
const NAME_COL = trackNameWidth.value
const laneLeft = rect.left + NAME_COL
const laneWidth = rect.width - NAME_COL
if (laneWidth <= 0) return 0
const ratio = Math.max(0, Math.min(1, (e.clientX - laneLeft) / laneWidth))
return ratio * duration.value
}
function clipDurationOf(ci: ClipInstance): number {
return clipById.value.get(ci.clipId)?.duration ?? 1
}
function visibleDurationOf(ci: ClipInstance): number {
return Math.max(
0.05,
clipDurationOf(ci) - (ci.trimStart ?? 0) - (ci.trimEnd ?? 0),
)
}
function clipBlockStyle(ci: ClipInstance) {
return {
left: pxFromTime(ci.startTime) + 'px',
width: Math.max(6, pxFromTime(visibleDurationOf(ci))) + 'px',
}
}
function trimTooltip(ci: ClipInstance): string {
const name = clipById.value.get(ci.clipId)?.name ?? '?'
const dur = visibleDurationOf(ci).toFixed(2)
const trims: string[] = []
if ((ci.trimStart ?? 0) > 0) trims.push(t('timeline.trimStart', { value: (ci.trimStart ?? 0).toFixed(2) }))
if ((ci.trimEnd ?? 0) > 0) trims.push(t('timeline.trimEnd', { value: (ci.trimEnd ?? 0).toFixed(2) }))
const trimsStr = trims.length ? ` · ${trims.join(' · ')}` : ''
return t('timeline.trimTooltip', { name, duration: dur, trims: trimsStr })
}
interface ClipTrimState {
instanceId: string
side: 'start' | 'end'
pointerId: number
startX: number
initialStartTime: number
initialTrimStart: number
initialTrimEnd: number
}
const clipTrim = ref<ClipTrimState | null>(null)
function onClipHandlePointerDown(e: PointerEvent, ci: ClipInstance, side: 'start' | 'end') {
if (e.button !== 0) return
const track = store.scene.clipTracks?.find((t) => t.id === ci.clipTrackId)
if (track?.locked) return
e.preventDefault()
e.stopPropagation()
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
clipTrim.value = {
instanceId: ci.id,
side,
pointerId: e.pointerId,
startX: e.clientX,
initialStartTime: ci.startTime,
initialTrimStart: ci.trimStart ?? 0,
initialTrimEnd: ci.trimEnd ?? 0,
}
}
function onClipHandlePointerMove(e: PointerEvent) {
const t = clipTrim.value
if (!t || t.pointerId !== e.pointerId) return
const pps = effectivePxPerSec.value
if (pps <= 0) return
const deltaSec = (e.clientX - t.startX) / pps
if (t.side === 'start') {
// shift block right = trim more from start + shift startTime
store.trimClipInstance(t.instanceId, {
trimStart: t.initialTrimStart + deltaSec,
startTime: t.initialStartTime + deltaSec,
})
} else {
// shift right edge right = less trimEnd (reveal more)
store.trimClipInstance(t.instanceId, {
trimEnd: t.initialTrimEnd - deltaSec,
})
}
}
function onClipHandlePointerUp(e: PointerEvent) {
const t = clipTrim.value
if (!t || t.pointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
clipTrim.value = null
}
function onClipBlockDblClick(ci: ClipInstance) {
store.enterClip(ci.clipId)
}
async function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
e.preventDefault()
const clip = clipById.value.get(ci.clipId)
const ok = await confirmDialog({
title: t('timeline.removeInstanceDialog.title'),
message: clip
? t('timeline.removeInstanceDialog.message', {
name: clip.name,
time: ci.startTime.toFixed(2),
})
: t('timeline.removeInstanceDialog.generic'),
confirmLabel: t('timeline.removeInstanceDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeClipInstance(ci.id)
}
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.activeLayers.find((l) => l.id === layerId)
if (!layer) return
store.selectLayer(layerId)
trackMenu.value = {
kind: 'layer',
trackId: layerId,
x: e.clientX,
y: e.clientY,
separated: !!layer.separated,
currentColor: layer.color ?? null,
locked: !!layer.locked,
}
}
function onGroupContextMenu(e: MouseEvent, groupId: string) {
e.preventDefault()
e.stopPropagation()
const group = groupById.value.get(groupId)
if (!group) return
trackMenu.value = {
kind: 'group',
trackId: groupId,
x: e.clientX,
y: e.clientY,
separated: false,
currentColor: group.color ?? null,
locked: false,
}
}
function onClipTrackContextMenu(e: MouseEvent, clipTrackId: string) {
e.preventDefault()
e.stopPropagation()
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
if (!track) return
trackMenu.value = {
kind: 'clip',
trackId: clipTrackId,
x: e.clientX,
y: e.clientY,
separated: false,
currentColor: track.color ?? null,
locked: !!track.locked,
}
}
function toggleLocked() {
if (!trackMenu.value) return
store.setTrackLocked(trackMenu.value.trackId, !trackMenu.value.locked)
trackMenu.value = null
}
function applyTrackColor(color: string | null) {
if (!trackMenu.value) return
store.setTrackColor(trackMenu.value.trackId, color)
trackMenu.value = null
}
function trackColorStyle(color: string | null | undefined): Record<string, string> | undefined {
if (!color) return undefined
return { '--track-color': color }
}
function toggleSeparated() {
if (!trackMenu.value) return
store.setLayerSeparated(trackMenu.value.trackId, !trackMenu.value.separated)
trackMenu.value = null
}
function deleteTrack() {
if (!trackMenu.value) return
if (trackMenu.value.kind === 'layer') {
store.removeLayer(trackMenu.value.trackId)
} else if (trackMenu.value.kind === 'clip') {
store.removeClipTrack(trackMenu.value.trackId)
} else {
store.removeTrackGroup(trackMenu.value.trackId)
}
trackMenu.value = null
}
async function renameFromMenu() {
if (!trackMenu.value) return
if (trackMenu.value.kind !== 'group') return
const group = groupById.value.get(trackMenu.value.trackId)
if (!group) return
const id = trackMenu.value.trackId
const current = group.name
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameGroupDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameGroupDialog.confirm'),
})
if (next === null) return
store.renameGroup(id, next)
}
function onDocumentPointerDown(e: PointerEvent) {
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
trackMenu.value = null
}
}
function onDocumentKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && selectedKfs.value.length) {
selectedKfs.value = []
}
}
watch([menu, trackMenu], ([m, tm]) => {
if (m || tm) {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
document.addEventListener('keydown', onKeyDown)
} else {
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
}
})
async function clampMenuIntoViewport(
getEl: () => HTMLElement | null,
x: number,
y: number,
) {
await nextTick()
const el = getEl()
if (!el) return
const rect = el.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
const margin = 8
let left = x
let top = y
if (left + rect.width + margin > vw) left = Math.max(margin, vw - rect.width - margin)
if (top + rect.height + margin > vh) top = Math.max(margin, vh - rect.height - margin)
el.style.left = `${left}px`
el.style.top = `${top}px`
}
watch(menu, (m) => {
if (!m) return
clampMenuIntoViewport(() => menuRef.value, m.x, m.y)
})
watch(trackMenu, (tm) => {
if (!tm) return
clampMenuIntoViewport(() => trackMenuRef.value, tm.x, tm.y)
})
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 - trackNameWidth.value + 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 - trackNameWidth.value))
})
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)
document.addEventListener('keydown', onDocumentKeyDown)
})
onBeforeUnmount(() => {
ro?.disconnect()
ro = null
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keydown', onDocumentKeyDown)
})
</script>
<template>
<div class="timeline" :style="timelineRootStyle">
<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="t('timeline.toggleTimeUnit')">
<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>
<button
class="snap-btn"
:class="{ active: snapEnabled }"
:title="snapEnabled ? t('timeline.snapOn') : t('timeline.snapOff')"
@click="snapEnabled = !snapEnabled"
>
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
</button>
<button
v-if="!inClipEdit"
class="snap-btn"
:title="t('timeline.addGroupTooltip')"
@click="addGroup"
>
<PhFolderPlus :size="12" />
</button>
</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"
@pointerdown="onTracksPointerDown"
@pointermove="onTracksPointerMove"
@pointerup="onTracksPointerUp"
@pointercancel="onTracksPointerUp"
>
<div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div>
<template v-for="(track, idx) in tracks" :key="
track.type === 'layer'
? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}`
: track.type === 'clip'
? `c:${track.clipTrackId}:${track.inGroup ? 'g' : 't'}`
: `gr:${track.groupId}`
">
<!-- Layer track -->
<div
v-if="track.type === 'layer'"
class="track"
:class="{
'sub-track': !track.isParent,
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: track.isParent && dragTrackId === track.layerId,
colored: !!track.layer.color,
locked: !!track.layer.locked,
'in-group': track.inGroup,
}"
:style="trackColorStyle(track.layer.color)"
@dragover="
track.isParent
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
: onTrackDragOver($event, track.orderIndex))
: null
"
@drop="
track.isParent
? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
: null
"
@dragleave="onTrackDragLeave"
>
<span
class="track-name"
:class="{
selected: track.layerId === store.selectedLayerId && track.isParent,
parent: track.isParent,
sub: !track.isParent,
}"
:draggable="track.isParent"
@click="store.selectLayer(track.layerId)"
@contextmenu="track.isParent ? onTrackContextMenu($event, track.layerId) : null"
@dragstart="track.isParent ? onTrackDragStart($event, track.layerId) : null"
@dragend="onTrackDragEnd"
>
<PhDotsSixVertical v-if="track.isParent" :size="12" class="drag-handle" />
<PhLock
v-if="track.isParent && track.layer.locked"
:size="11"
class="lock-badge"
/>
<PhArrowsSplit
v-if="track.isParent && track.layer.separated"
:size="11"
class="name-badge"
/>
<PhMaskHappy
v-if="track.isParent && track.layer.isMask"
:size="11"
class="name-badge"
:class="{ 'mask-negative': track.layer.maskMode === 'negative' }"
/>
<span v-if="!track.isParent" class="sub-indent">↳</span>
{{ track.label }}
</span>
<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(track.kfs)"
:key="seg.id"
class="kf-seg"
:class="[seg.held ? 'held' : `easing-${seg.easing}`]"
:style="segmentStyle(seg)"
/>
<span
v-for="kf in track.kfs"
:key="kf.id"
class="kf"
:class="{
hold: kf.hold,
selected: isKfSelected(track.layerId, track.kind, kf.id),
}"
:style="{ left: pxFromTime(kf.time) + 'px' }"
:data-layer-id="track.layerId"
:data-kind="track.kind"
:data-kf-id="kf.id"
:title="
kf.hold
? t('timeline.stillFrameTooltip', { time: formatKfTime(kf.time) })
: t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })
"
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id, track.kind)"
@pointermove="onKeyframePointerMove"
@pointerup="onKeyframePointerUp"
@pointercancel="onKeyframePointerUp"
@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>
<!-- Clip track -->
<div
v-else-if="track.type === 'clip'"
class="track clip-track"
:class="{
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: dragTrackId === track.clipTrackId,
colored: !!clipTrackById.get(track.clipTrackId)?.color,
locked: !!clipTrackById.get(track.clipTrackId)?.locked,
'in-group': track.inGroup,
}"
:style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)"
@dragover="
track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
: onTrackDragOver($event, track.orderIndex)
"
@drop="track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event)"
@dragleave="onTrackDragLeave"
>
<span
class="track-name clip-track-name"
draggable="true"
@contextmenu="onClipTrackContextMenu($event, track.clipTrackId)"
@dragstart="onTrackDragStart($event, track.clipTrackId)"
@dragend="onTrackDragEnd"
>
<PhDotsSixVertical :size="12" class="drag-handle" />
<PhLock
v-if="clipTrackById.get(track.clipTrackId)?.locked"
:size="11"
class="lock-badge"
/>
<PhFilmStrip :size="11" class="name-badge" />
{{ track.name }}
</span>
<div
class="track-lane clip-lane"
:style="{ width: laneWidth + 'px' }"
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="ci in clipInstancesByTrack[track.clipTrackId] ?? []"
:key="ci.id"
class="clip-block"
:class="{
'trimmed-start': (ci.trimStart ?? 0) > 0,
'trimmed-end': (ci.trimEnd ?? 0) > 0,
trimming: clipTrim?.instanceId === ci.id,
moving: clipMove?.instanceId === ci.id,
}"
:style="clipBlockStyle(ci)"
:title="trimTooltip(ci)"
@pointerdown="onClipBlockPointerDown($event, ci)"
@pointermove="onClipBlockPointerMove"
@pointerup="onClipBlockPointerUp"
@pointercancel="onClipBlockPointerUp"
@dragstart.prevent.stop
@dblclick="onClipBlockDblClick(ci)"
@contextmenu="onClipBlockContextMenu($event, ci)"
>
<span
class="clip-handle start"
draggable="false"
@pointerdown="onClipHandlePointerDown($event, ci, 'start')"
@pointermove="onClipHandlePointerMove"
@pointerup="onClipHandlePointerUp"
@pointercancel="onClipHandlePointerUp"
@dragstart.prevent.stop
@dblclick.stop
@click.stop
/>
<PhFilmStrip :size="11" class="clip-block-icon" />
<span class="clip-block-name">{{ clipById.get(ci.clipId)?.name ?? '?' }}</span>
<span
class="clip-handle end"
draggable="false"
@pointerdown="onClipHandlePointerDown($event, ci, 'end')"
@pointermove="onClipHandlePointerMove"
@pointerup="onClipHandlePointerUp"
@pointercancel="onClipHandlePointerUp"
@dragstart.prevent.stop
@dblclick.stop
@click.stop
/>
</span>
</div>
</div>
<!-- Group header -->
<div
v-else
class="track group-track"
:class="{
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: dragTrackId === track.groupId,
colored: !!track.color,
'drop-into': dropGroupId === track.groupId,
collapsed: track.collapsed,
}"
:style="trackColorStyle(track.color)"
@dragover="onTrackDragOver($event, track.orderIndex)"
@drop="onTrackDrop($event)"
@dragleave="onTrackDragLeave"
>
<span
class="track-name group-track-name"
draggable="true"
@click="toggleGroupCollapsed(track.groupId)"
@dblclick="renameGroupPrompt(track.groupId, track.name)"
@contextmenu="onGroupContextMenu($event, track.groupId)"
@dragstart="onTrackDragStart($event, track.groupId)"
@dragend="onTrackDragEnd"
>
<PhDotsSixVertical :size="12" class="drag-handle" />
<component
:is="track.collapsed ? PhCaretRight : PhCaretDown"
:size="11"
class="group-caret"
/>
<PhFolder :size="12" class="name-badge" />
{{ track.name }}
<span class="group-count">{{ t('timeline.groupChildCount', { n: track.childCount }) }}</span>
</span>
<div
class="track-lane group-lane"
:class="{ 'drop-into': dropGroupId === track.groupId }"
:style="{ width: laneWidth + 'px' }"
@dragover="onGroupBodyDragOver($event, track.groupId)"
@drop="onGroupBodyDrop($event, track.groupId)"
@dragleave="onGroupBodyDragLeave"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span v-if="track.collapsed && track.childCount === 0" class="group-empty-hint">
{{ t('timeline.groupEmpty') }}
</span>
<template v-if="track.collapsed">
<span
v-for="(time, kfIdx) in groupKeyframeTimes(track.groupId)"
:key="`gkf-${kfIdx}`"
class="group-kf-dot"
:style="{ left: pxFromTime(time) + 'px' }"
/>
</template>
</div>
</div>
</template>
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
<div
v-if="!inClipEdit"
class="track clip-track-dropzone"
:class="{ hot: clipDragOverDropzone }"
@dragenter="onNewTrackDragEnter"
@dragover="onNewTrackDragOver"
@dragleave="onNewTrackDragLeave"
@drop="onNewTrackDrop"
>
<span class="dropzone-hint">{{ t('timeline.dropNewTrackHint') }}</span>
</div>
</div>
</div>
</div>
<div
class="name-col-resize"
:class="{ resizing: nameResizing }"
:style="{ left: trackNameWidth + 'px' }"
:title="t('timeline.resizeTrackColumn')"
@pointerdown="onNameResizeStart"
@pointermove="onNameResizeMove"
@pointerup="onNameResizeEnd"
@pointercancel="onNameResizeEnd"
></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 && !menu.isHeld }"
:disabled="menu.isFirst || menu.isHeld"
@click="applyEasing(opt.value)"
>
<span class="check">
<PhCheck
v-if="menu.currentEasing === opt.value && !menu.isHeld"
:size="12"
weight="bold"
/>
</span>
<span class="kf-menu-icon-spacer"></span>
{{ opt.label }}
</button>
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
<div v-else-if="menu.isHeld" class="kf-menu-note">{{ t('timeline.holdDisabledHint') }}</div>
</div>
<div class="kf-menu-divider"></div>
<button
class="kf-menu-item"
:class="{ active: menu.isHeld }"
:disabled="menu.isLast"
:title="menu.isLast ? t('timeline.lastKfHint') : ''"
@click="toggleHold"
>
<span class="check">
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
</span>
<PhPauseCircle :size="12" class="kf-menu-icon" />
{{ t('timeline.stillFrame') }}
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="duplicateFromMenu">
<span class="check"></span>
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<button class="kf-menu-item danger" @click="deleteFromMenu">
<span class="check"></span>
<PhTrash :size="12" class="kf-menu-icon" />
{{ t('timeline.delete') }}
</button>
</div>
<div
v-if="trackMenu"
ref="trackMenuRef"
class="kf-menu"
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
>
<div class="kf-menu-section">
<div class="color-tiles">
<button
v-for="c in TRACK_COLORS"
:key="c.value ?? 'none'"
class="color-tile"
:class="{ selected: trackMenu.currentColor === c.value, none: c.value === null }"
:style="c.value ? { background: c.value } : undefined"
:title="c.label"
@click="applyTrackColor(c.value)"
>
<PhCheck
v-if="trackMenu.currentColor === c.value && c.value !== null"
:size="10"
weight="bold"
/>
</button>
</div>
</div>
<template v-if="trackMenu.kind === 'group'">
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="renameFromMenu">
<span class="check"></span>
<PhArrowsSplit :size="12" class="kf-menu-icon" style="visibility: hidden" />
{{ t('timeline.renameGroup') }}
</button>
</template>
<template v-else>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="toggleLocked">
<span class="check">
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
</span>
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" class="kf-menu-icon" />
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
</button>
<template v-if="trackMenu.kind === 'layer'">
<button
class="kf-menu-item"
:disabled="trackMenu.locked"
@click="toggleSeparated"
>
<span class="check">
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
</span>
<PhArrowsSplit :size="12" class="kf-menu-icon" />
{{ t('timeline.separate') }}
</button>
</template>
</template>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteTrack">
<span class="check"></span>
<PhTrash :size="12" class="kf-menu-icon" />
{{
trackMenu.kind === 'layer'
? t('timeline.deleteLayer')
: trackMenu.kind === 'clip'
? t('timeline.deleteClipTrack')
: t('timeline.ungroup')
}}
</button>
</div>
</div>
</template>
<style scoped>
.timeline {
--track-name-width: 140px;
position: relative;
height: 100%;
overflow: hidden;
}
.name-col-resize {
position: absolute;
top: 0;
bottom: 0;
width: 8px;
transform: translateX(-4px);
cursor: ew-resize;
z-index: 15;
touch-action: none;
}
.name-col-resize::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 4px;
width: 1px;
background: transparent;
transition: background 120ms var(--ease);
}
.name-col-resize:hover::after,
.name-col-resize.resizing::after {
background: var(--accent);
}
.name-col-resize.resizing {
background: rgba(110, 168, 255, 0.08);
}
.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;
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);
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: 12px;
height: 14px;
background: var(--accent);
border-radius: 2px;
margin-top: 2px;
box-shadow: 0 0 0 1px 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;
position: relative;
}
.box-select-rect {
position: absolute;
z-index: 50;
border: 1px solid var(--accent);
background: rgba(110, 168, 255, 0.12);
pointer-events: none;
border-radius: 2px;
}
.kf.selected {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.kf.hold.selected {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.track {
display: flex;
align-items: center;
height: 30px;
border-bottom: 1px solid var(--border);
transition: background 140ms var(--ease);
position: relative;
}
.track.dragging {
opacity: 0.5;
}
.track.drop-before::before,
.track.drop-after::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
z-index: 5;
pointer-events: none;
box-shadow: 0 0 0 1px rgba(110, 168, 255, 0.35);
}
.track.drop-before::before {
top: -1px;
}
.track.drop-after::after {
bottom: -1px;
}
.track.inactive .track-lane > :not(.playhead) {
opacity: 0.4;
transition: opacity 140ms var(--ease);
}
.track.inactive:hover .track-lane > :not(.playhead) {
opacity: 0.75;
}
.track.locked {
height: 15px;
}
.track.clip-track.locked {
height: 17px;
}
.track.sub-track.locked {
height: 13px;
}
.track.locked .track-name {
font-size: 10px;
}
.track.locked .track-lane {
background-image: repeating-linear-gradient(
45deg,
rgba(255, 255, 255, 0.04) 0,
rgba(255, 255, 255, 0.04) 4px,
transparent 4px,
transparent 8px
);
cursor: not-allowed;
}
.track.locked .kf,
.track.locked .kf-seg,
.track.locked .clip-block {
opacity: 0.55;
cursor: not-allowed;
}
.track.locked .drag-handle {
display: none;
}
.lock-badge {
color: var(--text-dim);
margin-right: 4px;
flex-shrink: 0;
opacity: 0.85;
}
/* Neutralize hover states on locked tracks */
.track.locked .track-name:hover {
background: var(--panel);
color: var(--text-dim);
}
.track.locked .track-name.selected:hover {
background:
linear-gradient(var(--accent-soft), var(--accent-soft)),
var(--panel);
color: var(--accent);
}
.track.locked.sub-track .track-name:hover {
background: var(--panel-alt);
}
.track.locked.inactive:hover .track-lane {
opacity: 0.4;
}
.track.locked .clip-block:hover {
background: color-mix(in srgb, var(--track-color, var(--accent)) 14%, transparent);
transform: none;
}
.track.locked .clip-block:hover .clip-handle {
opacity: 0;
}
.track.locked .clip-block.trimmed-start:hover .clip-handle.start,
.track.locked .clip-block.trimmed-end:hover .clip-handle.end {
opacity: 0.55;
}
.track.locked .clip-handle:hover,
.track.locked .clip-handle:active {
background: transparent !important;
opacity: 0 !important;
}
.track.locked .kf:hover {
background: var(--track-color, var(--accent));
transform: rotate(45deg);
}
.track.locked .kf.hold:hover {
background: var(--panel);
border-color: var(--track-color, var(--accent));
transform: none;
}
.track-name {
position: sticky;
left: 0;
z-index: 5;
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:
linear-gradient(var(--accent-soft), var(--accent-soft)),
var(--panel);
}
.track.colored > .track-name {
box-shadow: inset 3px 0 0 0 var(--track-color);
}
.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;
}
.name-badge.mask-negative {
color: #ff6b6b;
}
.drag-handle {
color: var(--text-dim);
margin-right: 4px;
flex-shrink: 0;
cursor: grab;
opacity: 0;
transition: opacity 140ms var(--ease);
}
.track-name:hover .drag-handle,
.track-name.selected .drag-handle {
opacity: 0.7;
}
.drag-handle:active {
cursor: grabbing;
}
.sub-indent {
color: var(--text-dim);
margin-right: 4px;
font-size: 10px;
opacity: 0.6;
}
.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);
z-index: 4;
}
.kf-seg {
position: absolute;
top: 50%;
height: 3px;
margin-top: -1.5px;
border-radius: 999px;
pointer-events: none;
background: var(--track-color, var(--accent));
opacity: 0.55;
}
.kf-seg.easing-linear {
background: var(--track-color, var(--accent));
}
.kf-seg.easing-easeIn {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--track-color, var(--accent)) 15%, transparent) 0%,
color-mix(in srgb, var(--track-color, var(--accent)) 25%, transparent) 30%,
var(--track-color, var(--accent)) 100%
);
opacity: 0.9;
}
.kf-seg.easing-easeOut {
background: linear-gradient(
90deg,
var(--track-color, var(--accent)) 0%,
color-mix(in srgb, var(--track-color, var(--accent)) 25%, transparent) 70%,
color-mix(in srgb, var(--track-color, var(--accent)) 15%, transparent) 100%
);
opacity: 0.9;
}
.kf-seg.easing-easeInOut {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--track-color, var(--accent)) 15%, transparent) 0%,
var(--track-color, var(--accent)) 50%,
color-mix(in srgb, var(--track-color, var(--accent)) 15%, transparent) 100%
);
opacity: 0.95;
}
.kf-seg.held {
background: repeating-linear-gradient(
90deg,
var(--text-dim) 0,
var(--text-dim) 2px,
transparent 2px,
transparent 5px
);
opacity: 0.7;
}
.kf {
position: absolute;
top: 50%;
width: 11px;
height: 11px;
margin-left: -5.5px;
margin-top: -5.5px;
background: var(--track-color, 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;
}
.kf.hold {
width: 15px;
height: 15px;
margin-left: -7.5px;
margin-top: -7.5px;
transform: none;
border-radius: 50%;
background: var(--panel);
border: 2px solid var(--track-color, var(--accent));
box-shadow: none;
}
.kf.hold:hover {
background: var(--panel);
border-color: #fff;
transform: scale(1.2);
}
.empty {
color: var(--text-dim);
font-style: italic;
padding: 12px;
margin: 0;
font-size: 12px;
}
.track.clip-track {
height: 34px;
background: linear-gradient(
to bottom,
transparent 0%,
rgba(110, 168, 255, 0.04) 50%,
transparent 100%
);
}
.clip-track .track-name {
background: var(--panel-alt);
font-size: 11px;
color: var(--text-dim);
}
.clip-track-name {
gap: 6px;
}
.clip-lane {
position: relative;
}
.clip-block {
position: absolute;
top: 4px;
bottom: 4px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
background: color-mix(in srgb, var(--track-color, var(--accent)) 14%, transparent);
border: 1px solid var(--track-color, var(--accent));
border-radius: var(--radius-sm);
color: var(--track-color, var(--accent));
font-size: 11px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
cursor: grab;
touch-action: none;
z-index: 2;
transition: background 140ms var(--ease), transform 140ms var(--ease);
}
.clip-block:hover {
background: color-mix(in srgb, var(--track-color, var(--accent)) 22%, transparent);
}
.clip-block:active {
cursor: grabbing;
}
.clip-block-icon {
flex-shrink: 0;
opacity: 0.9;
}
.clip-block-name {
overflow: hidden;
text-overflow: ellipsis;
}
.clip-handle {
position: absolute;
top: 0;
bottom: 0;
width: 8px;
cursor: ew-resize;
z-index: 3;
touch-action: none;
background: linear-gradient(
to right,
rgba(255, 255, 255, 0.35),
rgba(255, 255, 255, 0)
);
opacity: 0;
transition: opacity 120ms var(--ease), background 120ms var(--ease);
}
.clip-handle.start {
left: 0;
border-top-left-radius: var(--radius-sm);
border-bottom-left-radius: var(--radius-sm);
}
.clip-handle.end {
right: 0;
background: linear-gradient(
to left,
rgba(255, 255, 255, 0.35),
rgba(255, 255, 255, 0)
);
border-top-right-radius: var(--radius-sm);
border-bottom-right-radius: var(--radius-sm);
}
.clip-handle::after {
content: '';
position: absolute;
top: 20%;
bottom: 20%;
width: 2px;
border-radius: 2px;
background: var(--track-color, var(--accent));
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
}
.clip-handle.start::after {
left: 2px;
}
.clip-handle.end::after {
right: 2px;
}
.clip-block:hover .clip-handle {
opacity: 1;
}
.clip-block.trimmed-start .clip-handle.start,
.clip-block.trimmed-end .clip-handle.end {
opacity: 0.75;
}
.clip-block.trimmed-start .clip-handle.start::after,
.clip-block.trimmed-end .clip-handle.end::after {
background: #ffc857;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
}
.clip-block.trimming .clip-handle {
opacity: 1;
}
.clip-block.trimming {
box-shadow: 0 0 0 1px #ffc857;
}
.clip-block.moving {
box-shadow: 0 0 0 1px var(--track-color, var(--accent)), 0 4px 12px rgba(0, 0, 0, 0.3);
cursor: grabbing;
z-index: 4;
}
.clip-handle:hover,
.clip-handle:active {
background: rgba(255, 255, 255, 0.25) !important;
opacity: 1 !important;
}
.track.group-track {
height: 28px;
background: linear-gradient(
to bottom,
rgba(110, 168, 255, 0.05) 0%,
rgba(110, 168, 255, 0.02) 100%
);
font-weight: 500;
}
.track.group-track .track-name {
background: var(--panel-alt);
font-size: 12px;
color: var(--text);
gap: 4px;
cursor: pointer;
}
.track.group-track .track-name:hover {
background: var(--panel-hover);
}
.group-caret {
color: var(--text-dim);
flex-shrink: 0;
}
.group-count {
margin-left: auto;
font-size: 10px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.group-lane {
background: transparent;
pointer-events: auto;
position: relative;
}
.track.group-track.drop-into > .track-name,
.track.group-track .group-lane.drop-into {
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent);
}
.group-empty-hint {
position: sticky;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: var(--text-dim);
opacity: 0.6;
font-style: italic;
}
.group-kf-dot {
position: absolute;
top: 50%;
width: 5px;
height: 5px;
margin-left: -2.5px;
margin-top: -2.5px;
border-radius: 50%;
background: var(--track-color, var(--accent));
opacity: 0.75;
pointer-events: none;
}
.track.in-group .track-name {
padding-left: 38px;
}
.track.in-group.sub-track .track-name {
padding-left: 50px;
}
.clip-track-dropzone {
height: 26px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px dashed var(--border);
border-left: none;
border-right: none;
transition: background 140ms var(--ease), border-color 140ms var(--ease);
color: var(--text-dim);
font-size: 11px;
}
.clip-track-dropzone.hot {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.dropzone-hint {
position: sticky;
left: 50%;
transform: translateX(-50%);
pointer-events: none;
white-space: nowrap;
}
.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;
height: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
flex-shrink: 0;
}
.kf-menu-icon,
.kf-menu-icon-spacer {
width: 12px;
height: 12px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.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;
}
.color-tiles {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
padding: 4px 10px 6px;
}
.color-tile {
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--border);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #000;
cursor: pointer;
transition: transform 100ms var(--ease), box-shadow 100ms var(--ease);
}
.color-tile:hover {
transform: scale(1.1);
border-color: var(--text-dim);
}
.color-tile.selected {
border-color: var(--text);
box-shadow: 0 0 0 2px var(--panel), 0 0 0 3px var(--accent);
}
.color-tile.none {
background:
linear-gradient(45deg, transparent calc(50% - 1px), var(--danger) calc(50% - 1px), var(--danger) calc(50% + 1px), transparent calc(50% + 1px)),
var(--panel-alt);
}
.color-tile.none.selected {
box-shadow: 0 0 0 2px var(--panel), 0 0 0 3px var(--accent);
}
</style>