Files
RUWIO/src/components/Timeline.vue
T
2026-04-22 12:52:47 +02:00

5143 lines
150 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
PhArrowsSplit,
PhCaretDown,
PhCaretRight,
PhCheck,
PhCode,
PhCopy,
PhDotsSixVertical,
PhFilmStrip,
PhFolder,
PhFolderPlus,
PhLock,
PhLockOpen,
PhEye,
PhEyeSlash,
PhMagnet,
PhMaskHappy,
PhCircle,
PhImage,
PhMusicNote,
PhPauseCircle,
PhPencilSimpleLine,
PhPlus,
PhPolygon,
PhSpeakerSlash,
PhSquare,
PhTextT,
PhTrash,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useClipartStore } from '@/stores/clipart'
import { importAudioFile } from '@/lib/fileLoader'
import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDragSource'
import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog, promptDialog } from '@/components/ui'
import ScriptFrameDialog from './ScriptFrameDialog.vue'
import { t } from '@/i18n'
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
import type {
AnimatableProps,
Asset,
AudioAsset,
AudioClip,
Clip,
ClipInstance,
Easing,
HitShape,
Keyframe,
Layer,
TrackGroup,
} from '@/types/scene'
import { BUTTON_STATE_TIMES } 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
locked: boolean
orderIndex: number
childCount: number
}
interface TimelineScriptTrack {
type: 'script'
scriptTrackId: string
name: string
orderIndex: number
locked: boolean
color: string | null
}
interface TimelineAudioTrack {
type: 'audio'
audioTrackId: string
name: string
orderIndex: number
locked: boolean
muted: boolean
color: string | null
}
type TimelineTrack =
| TimelineLayerTrack
| TimelineClipTrack
| TimelineGroupTrack
| TimelineScriptTrack
| TimelineAudioTrack
const store = useSceneStore()
const clipart = useClipartStore()
const duration = computed(() =>
store.activeButtonAssetId ? 5 : store.activeDuration,
)
const inClipEdit = computed(() => store.activeClipId !== null)
// Only surface the "drop a clip to add a new track" dropzone when the user
// has at least one clip in the library — otherwise the hint is confusing
// (you can't drop what you don't have).
const showClipDropzone = computed(
() =>
!inClipEdit.value &&
!store.activeButtonAssetId &&
(store.scene.clips?.length ?? 0) > 0,
)
// Keep the dropzone hint horizontally centred in the *visible* ruler area
// (right of the sticky name-spacer) regardless of horizontal scroll. The
// dropzone itself is as wide as .tl-content, so its natural flex-center
// is not at the visual center — we position the hint absolutely at
// `scrollLeft + trackNameWidth + (visibleRulerWidth / 2)`.
const dropzoneHintStyle = computed(() => {
const tn = trackNameWidth.value
const vp = viewportWidth.value
if (vp <= 0) return {}
const visibleRulerW = Math.max(0, vp - tn)
const leftPx = scrollLeft.value + tn + visibleRulerW / 2
return { left: `${leftPx}px` }
})
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 scriptTrackById = computed(() => {
const m = new Map<string, NonNullable<typeof store.scene.scriptTracks>[number]>()
for (const t of store.scene.scriptTracks ?? []) m.set(t.id, t)
return m
})
const scriptFramesByTrack = computed<Record<string, typeof store.scene.scriptFrames>>(() => {
const out: Record<string, NonNullable<typeof store.scene.scriptFrames>> = {}
for (const f of store.scene.scriptFrames ?? []) {
;(out[f.trackId] ||= []).push(f)
}
return out
})
const audioTrackById = computed(() => {
const m = new Map<string, NonNullable<typeof store.scene.audioTracks>[number]>()
for (const t of store.scene.audioTracks ?? []) m.set(t.id, t)
return m
})
const audioClipsByTrack = computed<Record<string, AudioClip[]>>(() => {
const out: Record<string, AudioClip[]> = {}
for (const c of store.scene.audioClips ?? []) {
;(out[c.audioTrackId] ||= []).push(c)
}
return out
})
const audioAssetById = computed(() => {
const m = new Map<string, AudioAsset>()
for (const a of store.scene.assets) {
if (a.kind === 'audio') m.set(a.id, a)
}
return m
})
// Bump the storage key so clients with an old (narrower) stored value pick
// up the new default that matches the assets-sidebar column width (260px),
// which keeps the timeline track-name column visually aligned with it.
const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth:v2'
const TRACK_NAME_WIDTH_DEFAULT = 260
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
// Fixed 100px per state-time unit in button-edit mode. Hoisted here so the
// effectivePxPerSec computed below can reference it.
const BUTTON_STATE_WIDTH = 100
const scrollEl = ref<HTMLDivElement | null>(null)
const viewportWidth = ref(0)
// Tracks horizontal scroll offset of the timeline so we can hide the
// scrub-handle behind the sticky name-spacer when the playhead has
// scrolled off the left edge of the visible ruler area.
const scrollLeft = 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(() => {
// Button-edit uses a fixed 100px per state unit so the kf dots on tracks
// line up with the state labels on the scrubber above.
if (store.activeButtonAssetId) return BUTTON_STATE_WIDTH
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
}
// True when the playhead has scrolled off the left side of the visible ruler
// area (so the handle's centre sits behind the sticky name-spacer column).
// Used to drop the handle's z-index below the name-spacer so it doesn't
// render over the toggle-row buttons. When in view, the handle sits above
// the name-spacer so its left half isn't clipped at t=0.
const scrubHandleObscured = computed(
() => scrollLeft.value > pxFromTime(store.playhead),
)
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 bracketTimeFromEvent(e: PointerEvent, el: HTMLElement): number {
const ruler = el.parentElement as HTMLElement | null
if (!ruler) return 0
const rect = ruler.getBoundingClientRect()
if (rect.width <= 0) return 0
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
return snapTime(ratio * duration.value)
}
function onInBracketPointerDown(e: PointerEvent) {
e.stopPropagation()
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
function onInBracketPointerMove(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
e.stopPropagation()
const t = bracketTimeFromEvent(e, el)
store.setPlaybackRange(Math.min(t, store.playbackOut), null)
}
function onInBracketPointerUp(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
}
function onOutBracketPointerDown(e: PointerEvent) {
e.stopPropagation()
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
function onOutBracketPointerMove(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
e.stopPropagation()
const t = bracketTimeFromEvent(e, el)
store.setPlaybackRange(null, Math.max(t, store.playbackIn))
}
function onOutBracketPointerUp(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
}
// -------- Markers -----------------------------------------------------
const selectedMarkerId = ref<string | null>(null)
const markerDrag = ref<{ id: string; startX: number; moved: boolean } | null>(null)
function onOutsideMarkerPointerDown(e: PointerEvent) {
const el = e.target as HTMLElement | null
// Leave selection alone when the click lands on the marker itself
// (or any menu/dialog — treat anything inside a popup as "not outside").
if (el?.closest('.timeline-marker')) return
if (el?.closest('.dialog, .kf-menu, .track-menu')) return
selectedMarkerId.value = null
}
watch(selectedMarkerId, (id) => {
if (id) {
document.addEventListener('pointerdown', onOutsideMarkerPointerDown, true)
} else {
document.removeEventListener('pointerdown', onOutsideMarkerPointerDown, true)
}
})
function markerTimeFromEvent(e: PointerEvent, markerEl: HTMLElement): number {
const ruler = markerEl.parentElement as HTMLElement | null
if (!ruler) return 0
const rect = ruler.getBoundingClientRect()
if (rect.width <= 0) return 0
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
return snapTime(ratio * duration.value)
}
function onMarkerPointerDown(e: PointerEvent, markerId: string) {
e.stopPropagation()
selectedMarkerId.value = markerId
markerDrag.value = { id: markerId, startX: e.clientX, moved: false }
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
function onMarkerPointerMove(e: PointerEvent, markerId: string) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
const drag = markerDrag.value
if (!drag || drag.id !== markerId) return
e.stopPropagation()
if (!drag.moved && Math.abs(e.clientX - drag.startX) > 3) drag.moved = true
if (drag.moved) {
store.setMarkerTime(markerId, markerTimeFromEvent(e, el))
}
}
function onMarkerPointerUp(e: PointerEvent, markerId: string) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
markerDrag.value = null
void markerId
}
async function renameMarkerPrompt(markerId: string) {
const marker = store.scene.markers?.find((m) => m.id === markerId)
if (!marker) return
const next = await promptDialog({
title: t('timeline.renameMarkerTitle'),
message: t('timeline.renameMarkerMessage'),
defaultValue: marker.name,
confirmLabel: t('timeline.renameMarkerConfirm'),
})
if (next === null) return
const trimmed = next.trim()
if (!trimmed) return
const ok = store.renameMarker(markerId, trimmed)
if (!ok) {
await confirmDialog({
title: t('timeline.renameMarkerTitle'),
message: t('timeline.renameMarkerCollision', { name: trimmed }),
confirmLabel: t('timeline.renameMarkerRetry'),
})
// Let the user pick again.
renameMarkerPrompt(markerId)
}
}
async function addMarkerAtPlayhead() {
const marker = store.addMarker(store.playhead)
selectedMarkerId.value = marker.id
await nextTick()
// Open rename prompt immediately so the user can name the marker.
renameMarkerPrompt(marker.id)
}
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))
}
const canCreateKf = computed(() => {
const id = store.selectedLayerId
if (!id) return false
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return false
if (layer.locked) return false
return !lockedGroupChildIds.value.has(id)
})
function addKeyframeForSelected() {
const id = store.selectedLayerId
if (!id) return
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer || isLayerIdLocked(id)) return
const time = snapTime(store.playhead)
const sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = {
scaleX: sampled.scaleX,
scaleY: sampled.scaleY,
opacity: sampled.opacity,
}
if (sampled.fill) props.fill = sampled.fill
if (!layer.separated) {
props.x = sampled.x
props.y = sampled.y
props.rotation = sampled.rotation
}
store.upsertKeyframe(id, time, props)
}
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 || isLayerIdLocked(track.layerId)) 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
if (isLayerIdLocked(layerId)) 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 assetById = computed(() => {
const m = new Map<string, Asset>()
for (const a of store.scene.assets) m.set(a.id, a)
return m
})
function assetIconFor(layer: Layer) {
const asset = assetById.value.get(layer.assetId)
if (!asset) return null
if (asset.kind === 'rect') return PhSquare
if (asset.kind === 'circle') return PhCircle
if (asset.kind === 'image') return PhImage
if (asset.kind === 'text') return PhTextT
if (asset.kind === 'polygon') return asset.closed === false ? PhPencilSimpleLine : PhPolygon
return null
}
const lockedGroupChildIds = computed(() => {
const s = new Set<string>()
for (const g of store.scene.groups ?? []) {
if (g.locked) for (const cid of g.childIds) s.add(cid)
}
return s
})
/** Effective lock for a bare layer id — direct flag OR reached by a
* locked parent group. Keyframe interactions consult this so edits
* are blocked the moment the containing group is locked, without
* having to look up the TimelineTrack wrapper. */
function isLayerIdLocked(layerId: string): boolean {
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) return false
if (layer.locked) return true
return lockedGroupChildIds.value.has(layerId)
}
function isTrackEffectivelyLocked(track: TimelineTrack): boolean {
if (track.type === 'layer') {
return !!track.layer.locked || lockedGroupChildIds.value.has(track.layerId)
}
if (track.type === 'clip') {
return (
!!clipTrackById.value.get(track.clipTrackId)?.locked ||
lockedGroupChildIds.value.has(track.clipTrackId)
)
}
return track.locked
}
const tracks = computed<TimelineTrack[]>(() => {
const out: TimelineTrack[] = []
// Button-asset edit: show only the active state's layers. 'hit' mode
// has no layer stack — timeline is empty (user edits hit shapes on
// the stage).
if (store.activeButtonAssetId) {
// Hit mode is a separate stage — it owns the timeline, shows hit shapes
// as tracks (rendered below, outside the main track v-for), and layers
// from the 4 content states do not appear here.
if (store.activeButtonEditMode === 'hit') return out
store.activeLayers.forEach((layer, orderIndex) => {
emitLayerTrack(layer, orderIndex, false, out)
})
return out
}
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 st = scriptTrackById.value.get(id)
if (st) {
out.push({
type: 'script',
scriptTrackId: st.id,
name: st.name ?? t('timeline.scriptTrackName'),
orderIndex,
locked: !!st.locked,
color: st.color ?? null,
})
return
}
const at = audioTrackById.value.get(id)
if (at) {
out.push({
type: 'audio',
audioTrackId: at.id,
name: at.name ?? t('timeline.audioTrackName'),
orderIndex,
locked: !!at.locked,
muted: !!at.muted,
color: at.color ?? null,
})
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,
locked: !!grp.locked,
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,
})
}
})
}
}
})
if (showLocked.value) return out
const lockedGroupChildIds = new Set<string>()
const lockedGroupIds = new Set<string>()
for (const g of store.scene.groups ?? []) {
if (g.locked) {
lockedGroupIds.add(g.id)
for (const cid of g.childIds) lockedGroupChildIds.add(cid)
}
}
return out.filter((tr) => {
if (tr.type === 'layer') {
if (tr.layer.locked) return false
if (lockedGroupChildIds.has(tr.layerId)) return false
return true
}
if (tr.type === 'clip') {
if (clipTrackById.value.get(tr.clipTrackId)?.locked) return false
if (lockedGroupChildIds.has(tr.clipTrackId)) return false
return true
}
if (tr.type === 'script') {
return !tr.locked
}
if (tr.type === 'audio') {
return !tr.locked
}
// group: hide when locked
return !lockedGroupIds.has(tr.groupId)
})
})
function trackDndId(track: TimelineTrack): string {
if (track.type === 'layer') return track.layerId
if (track.type === 'clip') return track.clipTrackId
if (track.type === 'script') return track.scriptTrackId
if (track.type === 'audio') return track.audioTrackId
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 === 'layer' || track.type === 'clip') && 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 === 'layer' || track.type === 'clip') && 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 (isLayerIdLocked(layerId)) 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
}
}
// ---- Script frame dragging ------------------------------------------------
interface SfDrag {
frameId: string
lane: HTMLElement
pointerId: number
startX: number
moved: boolean
initialTime: number
}
const sfDrag = ref<SfDrag | null>(null)
function onScriptFramePointerDown(e: PointerEvent, frameId: string) {
if (e.button !== 0) return
const el = e.currentTarget as HTMLElement
const lane = el.parentElement
if (!lane) return
const frame = store.scene.scriptFrames?.find((f) => f.id === frameId)
if (!frame) return
e.stopPropagation()
el.setPointerCapture(e.pointerId)
sfDrag.value = {
frameId,
lane,
pointerId: e.pointerId,
startX: e.clientX,
moved: false,
initialTime: frame.time,
}
}
function onScriptFramePointerMove(e: PointerEvent) {
const d = sfDrag.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 = snapTime(ratio * duration.value)
store.setScriptFrameTime(d.frameId, newTime)
}
function onScriptFramePointerUp(e: PointerEvent) {
const d = sfDrag.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) {
openScriptFrame(d.frameId)
}
sfDrag.value = null
}
// ---------------------------------------------------------------------------
interface KfSegment {
id: string
fromTime: number
toTime: number
easing: Easing
held: boolean
/** True when this segment is the "still-frame tail" extending past
* the last keyframe to the end of the timeline. Rendered dimmer so
* it reads as an after-image rather than an interpolation. */
tail?: boolean
}
function segmentsOf(kfs: Keyframe[], duration: number): KfSegment[] {
if (kfs.length === 0) return []
const sorted = [...kfs].sort((a, b) => a.time - b.time)
const out: KfSegment[] = []
// Single keyframe means the layer is always visible (no animation
// range), so the line should span the entire timeline as a solid
// dim "on-stage" indicator — same treatment as a still-frame tail.
if (sorted.length === 1) {
const only = sorted[0]
if (duration > 0) {
out.push({
id: `${only.id}>full`,
fromTime: 0,
toTime: duration,
easing: only.easing,
held: false,
tail: true,
})
}
return out
}
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,
})
}
// Last-keyframe "still frame" tail: when the final keyframe is held,
// extend the on-stage indicator to the end of the timeline. Solid
// and dim — the value isn't interpolating, it's just idling.
const last = sorted[sorted.length - 1]
if (last.hold && duration > last.time) {
out.push({
id: `${last.id}>tail`,
fromTime: last.time,
toTime: duration,
easing: last.easing,
held: false,
tail: true,
})
}
return out
}
function segmentStyle(seg: KfSegment) {
return {
left: `${pxFromTime(seg.fromTime)}px`,
width: `${pxFromTime(seg.toTime - seg.fromTime)}px`,
}
}
// Button-editor only: list the 4 state-slot times that this track has
// NO keyframe on. The template renders a "+" placeholder over each
// empty slot; clicking it duplicates the nearest keyframe (prev by
// preference, next if there's nothing earlier) onto that slot.
const BUTTON_SLOT_TIMES = [
BUTTON_STATE_TIMES.normal,
BUTTON_STATE_TIMES.hover,
BUTTON_STATE_TIMES.pressed,
BUTTON_STATE_TIMES.disabled,
] as const
function emptyButtonSlots(kfs: Keyframe[]): number[] {
if (!store.activeButtonAssetId) return []
if (kfs.length === 0) return []
const EPS = 1e-3
const has = (t: number) => kfs.some((k) => Math.abs(k.time - t) < EPS)
return BUTTON_SLOT_TIMES.filter((t) => !has(t))
}
function addKfFromPrev(layerId: string, kfs: Keyframe[], time: number) {
if (!kfs.length) return
let prev: Keyframe | null = null
let next: Keyframe | null = null
for (const k of kfs) {
if (k.time < time) {
if (!prev || k.time > prev.time) prev = k
} else if (k.time > time) {
if (!next || k.time < next.time) next = k
}
}
const source = prev ?? next
if (!source) return
store.duplicateKeyframeAt(layerId, source.id, time)
}
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()
if (isLayerIdLocked(layerId)) return
const layer = store.activeLayers.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.activeLayers.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' | 'script' | 'audio'
trackId: string
x: number
y: number
separated: boolean
currentColor: string | null
locked: boolean
muted?: 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, .script-lane, .script-frame',
)
) {
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',
}
}
// ---- Audio clips ----
const AUDIO_CLIP_DND_MIME = 'application/x-animatr-audio-asset'
function audibleDurationOf(clip: AudioClip): number {
const asset = audioAssetById.value.get(clip.audioAssetId)
if (!asset) return 0
return Math.max(
0.05,
asset.duration - (clip.trimStart ?? 0) - (clip.trimEnd ?? 0),
)
}
function audioClipBlockStyle(clip: AudioClip) {
return {
left: pxFromTime(clip.startTime) + 'px',
width: Math.max(6, pxFromTime(audibleDurationOf(clip))) + 'px',
}
}
function audioClipTooltip(clip: AudioClip): string {
const asset = audioAssetById.value.get(clip.audioAssetId)
if (!asset) return ''
return `${asset.name} · ${audibleDurationOf(clip).toFixed(2)}s`
}
/** Would a hypothetical audible block spanning [start, start+length] on
* `trackId` collide with an existing clip? Clips on the same track
* must not overlap — the user has to pick a second track for parallel
* playback. `exceptId` is the moving clip's own id (so it doesn't
* self-collide during a drag). */
function audioWindowCollides(
trackId: string,
start: number,
length: number,
exceptId?: string,
): boolean {
const end = start + length
const siblings = audioClipsByTrack.value[trackId] ?? []
for (const c of siblings) {
if (c.id === exceptId) continue
const asset = audioAssetById.value.get(c.audioAssetId)
if (!asset) continue
const cEnd = c.startTime + Math.max(0, asset.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0))
if (cEnd > start && c.startTime < end) return true
}
return false
}
/** Lighter check used during `dragover`: does the drop point land on top
* of any existing clip? Use during drag-to-create when the dragged
* item's duration isn't yet known (clipart before decode, or internal
* DnD whose getData is locked during dragover). */
function audioPointInAnyClip(trackId: string, t: number): boolean {
const siblings = audioClipsByTrack.value[trackId] ?? []
for (const c of siblings) {
const asset = audioAssetById.value.get(c.audioAssetId)
if (!asset) continue
const end = c.startTime + Math.max(0, asset.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0))
if (t >= c.startTime && t < end) return true
}
return false
}
function onAudioLaneDragOver(e: DragEvent, audioTrackId: string) {
const types = e.dataTransfer?.types
if (!types) return
// Accept both internal-asset drops and clipart-file drops (images are
// filtered out at drop time — we only import audio clipart here).
if (!types.includes(AUDIO_CLIP_DND_MIME) && !types.includes('application/x-animatr-clipart')) return
e.preventDefault()
if (!e.dataTransfer) return
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromEvent(e, el)
const collides = audioPointInAnyClip(audioTrackId, time)
e.dataTransfer.dropEffect = collides ? 'none' : 'copy'
// Show a ghost preview on the hovered lane. The source info (name +
// duration) comes from whichever library row the user started
// dragging from — see useAudioDragSource. 0-length drops (clipart
// pre-decode) render at the minimum 6px width so the ghost stays
// visible as a drop target.
const src = audioDragSource.value
if (!collides && src) {
audioClipGhost.value = {
clipId: '__external__',
targetTrackId: audioTrackId,
startTime: time,
length: Math.max(0, src.duration),
}
} else {
audioClipGhost.value = null
}
}
function onAudioLaneDragLeave() {
audioClipGhost.value = null
}
async function onAudioLaneDrop(e: DragEvent, audioTrackId: string) {
e.preventDefault()
audioClipGhost.value = null
clearAudioDragSource()
const track = audioTrackById.value.get(audioTrackId)
if (!track || track.locked) return
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromEvent(e, el)
const assetId = e.dataTransfer?.getData(AUDIO_CLIP_DND_MIME)
if (assetId) {
const asset = audioAssetById.value.get(assetId)
const length = asset ? Math.max(0, asset.duration) : 0
// Length-based collision: a drop near an existing clip could still
// have its tail overlap the neighbour even if the drop *point* is
// free. Skip silently — the dragover cursor already warned.
if (audioWindowCollides(audioTrackId, time, length)) return
store.addAudioClip(assetId, time, audioTrackId)
return
}
// Clipart folder drop — import the file as an AudioAsset first, then
// place it on the lane. Images get silently ignored (user can drop
// them on the stage instead).
const clipartIdx = e.dataTransfer?.getData('application/x-animatr-clipart')
if (clipartIdx) {
const idx = Number.parseInt(clipartIdx, 10)
const item = Number.isFinite(idx) ? clipart.items[idx] : null
if (!item || item.kind !== 'audio') return
const res = await importAudioFile(store, item.file)
if (!res?.assetId) return
const newAsset = audioAssetById.value.get(res.assetId)
const length = newAsset ? Math.max(0, newAsset.duration) : 0
if (audioWindowCollides(audioTrackId, time, length)) {
// Decoded length turned out to collide. Drop is refused; the
// library asset still sticks around for the user to place manually.
return
}
store.addAudioClip(res.assetId, time, audioTrackId)
}
}
async function onAudioClipContextMenu(e: MouseEvent, clip: AudioClip) {
e.preventDefault()
e.stopPropagation()
// v1: a single "remove" action. The right-click context menu grows
// later once trim handles and per-clip properties land.
const ok = await confirmDialog({
title: t('timeline.removeAudioClipDialog.title'),
message: t('timeline.removeAudioClipDialog.message'),
confirmLabel: t('timeline.removeAudioClipDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeAudioClip(clip.id)
}
interface AudioClipMoveState {
clipId: string
pointerId: number
startX: number
initialStartTime: number
initialTrackId: string
moved: boolean
}
const audioClipMove = ref<AudioClipMoveState | null>(null)
interface AudioClipGhost {
clipId: string
targetTrackId: string
startTime: number
length: number
}
/** While dragging a clip over a DIFFERENT audio track, this holds a
* preview "where it will land" on that target lane. Rendered as a
* dashed block in the template so the user sees the drop target
* before committing via pointerup. Cleared whenever the cursor
* returns to the clip's original lane or leaves the audio area. */
const audioClipGhost = ref<AudioClipGhost | null>(null)
function onAudioClipPointerDown(e: PointerEvent, clip: AudioClip) {
if (e.button !== 0) return
const track = audioTrackById.value.get(clip.audioTrackId)
if (track?.locked) return
e.preventDefault()
e.stopPropagation()
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
audioClipMove.value = {
clipId: clip.id,
pointerId: e.pointerId,
startX: e.clientX,
initialStartTime: clip.startTime,
initialTrackId: clip.audioTrackId,
moved: false,
}
}
/** Walk the DOM under the pointer to discover which audio track (if
* any) the cursor is currently over. Returns the track id, or null
* when the cursor has drifted off the audio lanes entirely. Used on
* pointerup to decide whether a drag should swap tracks. */
function audioTrackIdFromPointer(clientX: number, clientY: number): string | null {
const el = document.elementFromPoint(clientX, clientY)
if (!el) return null
const host = (el as Element).closest('[data-audio-track-id]') as HTMLElement | null
if (!host) return null
const id = host.dataset.audioTrackId
if (!id) return null
// Only treat it as a valid target if the track exists in the store
// (defensive against stale DOM in the middle of re-renders).
return audioTrackById.value.get(id) ? id : null
}
function onAudioClipPointerMove(e: PointerEvent) {
const m = audioClipMove.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)
const clip = store.scene.audioClips?.find((c) => c.id === m.clipId)
if (!clip) return
// Horizontal drag always moves the clip on its CURRENT track with
// cascade push. Track-switch is deferred to pointerup (to keep the
// pointer capture anchored on a stable DOM element).
store.setAudioClipPosition(m.clipId, newStart, clip.audioTrackId)
// If the cursor is hovering over a different audio track, preview
// the drop on that lane as a dashed ghost. Users see where release
// will land BEFORE they commit.
const hovered = audioTrackIdFromPointer(e.clientX, e.clientY)
const canSwap =
hovered &&
hovered !== m.initialTrackId &&
store.scene.audioTracks &&
store.scene.audioTracks.length > 1
if (canSwap) {
const asset = audioAssetById.value.get(clip.audioAssetId)
const length = asset
? Math.max(0, asset.duration - (clip.trimStart ?? 0) - (clip.trimEnd ?? 0))
: 0
audioClipGhost.value = {
clipId: clip.id,
targetTrackId: hovered!,
startTime: newStart,
length,
}
} else {
audioClipGhost.value = null
}
}
function onAudioClipPointerUp(e: PointerEvent) {
const m = audioClipMove.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
const ghost = audioClipGhost.value
if (wasMoved && ghost && ghost.clipId === m.clipId) {
// Commit to the previewed target lane with cascade push.
store.setAudioClipPosition(m.clipId, ghost.startTime, ghost.targetTrackId)
}
audioClipMove.value = null
audioClipGhost.value = null
if (wasMoved) e.preventDefault()
}
// -------- Button asset edit bar --------------------------------------
const BUTTON_EDIT_STATES = ['normal', 'hover', 'pressed', 'disabled'] as const
type ButtonContentState = (typeof BUTTON_EDIT_STATES)[number]
function stateLabel(st: ButtonContentState): string {
return st[0].toUpperCase() + st.slice(1)
}
const activeButtonAssetName = computed(() => {
const id = store.activeButtonAssetId
if (!id) return ''
const asset = store.scene.assets.find((a) => a.id === id)
return asset && asset.kind === 'button' ? asset.name : ''
})
// Visual "state timeline" — each state sits at a fixed 100px increment so
// the track-lane kf dots (at pxFromTime(t) = t * 100) line up with the
// state label directly above. Hit has no kf semantics; it sits one slot
// past 'disabled' as a navigation shortcut only.
function stateTickLeft(idx: number): number {
return (idx + 1) * BUTTON_STATE_WIDTH
}
const BUTTON_STATE_TOTAL_WIDTH = BUTTON_STATE_WIDTH * (BUTTON_EDIT_STATES.length + 1)
const currentStateLeft = computed(() => {
const st = store.activeButtonState
if (!st) return 0
const idx = BUTTON_EDIT_STATES.indexOf(st as ButtonContentState)
return idx >= 0 ? stateTickLeft(idx) : 0
})
function updateStateFromPointer(e: PointerEvent) {
const el = e.currentTarget as HTMLElement | null
if (!el) return
const r = el.getBoundingClientRect()
const x = e.clientX - r.left
const idx = Math.max(
0,
Math.min(
BUTTON_EDIT_STATES.length - 1,
Math.round((x - BUTTON_STATE_WIDTH) / BUTTON_STATE_WIDTH),
),
)
store.setActiveButtonState(BUTTON_EDIT_STATES[idx])
}
const activeButtonHitShapes = computed(() => {
const id = store.activeButtonAssetId
if (!id) return []
const asset = store.scene.assets.find((a) => a.id === id)
return asset && asset.kind === 'button' ? asset.hitArea : []
})
function hitShapeLabel(shape: HitShape, idx: number): string {
const n = idx + 1
if (shape.kind === 'rect') return `Rect ${n} · ${Math.round(shape.width)}×${Math.round(shape.height)}`
if (shape.kind === 'circle') return `Circle ${n} · r=${Math.round(shape.radius)}`
return `Polygon ${n} · ${shape.points.length} pts`
}
function onDeleteHitShape(idx: number) {
const id = store.activeButtonAssetId
if (!id) return
store.removeButtonHitShape(id, idx)
}
function onStateScrubPointerDown(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
updateStateFromPointer(e)
}
function onStateScrubPointerMove(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
updateStateFromPointer(e)
}
function onStateScrubPointerUp(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
}
// Script frame dialog state
const scriptDialogOpen = ref(false)
const scriptDialogFrameId = ref<string | null>(null)
function openScriptFrame(frameId: string) {
scriptDialogFrameId.value = frameId
scriptDialogOpen.value = true
}
function onScriptLaneClick(e: MouseEvent, trackId: string) {
// Only handle clicks on the bare lane (not on an existing frame).
if ((e.target as HTMLElement).closest('.script-frame')) 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))
const time = snapTime(ratio * duration.value)
const frame = store.addScriptFrame(trackId, time)
if (frame) openScriptFrame(frame.id)
}
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 onLayerTrackNameClick(e: MouseEvent, track: TimelineLayerTrack) {
if (isTrackEffectivelyLocked(track)) return
if (e.shiftKey && track.isParent) {
store.toggleLayerSelection(track.layerId)
return
}
store.selectLayer(track.layerId)
}
function onTrackContextMenu(e: MouseEvent, layerId: string) {
e.preventDefault()
e.stopPropagation()
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) return
// If the right-clicked layer isn't already in the multi-selection,
// treat the right-click as a single-select so the menu actions
// operate on what the user actually clicked. Multi-selected ids
// persist so "Group selected" has a real list to work with.
if (!store.selectedLayerIds.includes(layerId)) {
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 onScriptTrackContextMenu(e: MouseEvent, scriptTrackId: string) {
e.preventDefault()
e.stopPropagation()
const track = store.scene.scriptTracks?.find((t) => t.id === scriptTrackId)
if (!track) return
trackMenu.value = {
kind: 'script',
trackId: scriptTrackId,
x: e.clientX,
y: e.clientY,
separated: false,
currentColor: track.color ?? null,
locked: !!track.locked,
}
}
function onAudioTrackContextMenu(e: MouseEvent, audioTrackId: string) {
e.preventDefault()
e.stopPropagation()
const track = audioTrackById.value.get(audioTrackId)
if (!track) return
trackMenu.value = {
kind: 'audio',
trackId: audioTrackId,
x: e.clientX,
y: e.clientY,
separated: false,
currentColor: track.color ?? null,
locked: !!track.locked,
muted: !!track.muted,
}
}
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 groupSelectedFromMenu() {
store.groupSelectedLayers()
trackMenu.value = null
}
function convertSelectedToClipFromMenu() {
store.convertSelectedLayersToClip()
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 if (trackMenu.value.kind === 'audio') {
store.removeAudioTrack(trackMenu.value.trackId)
} else if (trackMenu.value.kind === 'script') {
store.removeScriptTrack(trackMenu.value.trackId)
} else {
store.removeTrackGroup(trackMenu.value.trackId)
}
trackMenu.value = null
}
function duplicateTrackFromMenu() {
if (!trackMenu.value) return
const newId = store.duplicateTrack(trackMenu.value.trackId)
trackMenu.value = null
if (newId && store.scene.layers.some((l) => l.id === newId)) {
// Only layers have a meaningful per-track selection model; clip /
// script tracks aren't selected in the same sense.
store.selectLayer(newId)
}
}
async function renameFromMenu() {
if (!trackMenu.value) return
if (trackMenu.value.kind === 'group') {
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)
return
}
if (trackMenu.value.kind === 'script') {
const track = scriptTrackById.value.get(trackMenu.value.trackId)
if (!track) return
const id = trackMenu.value.trackId
const current = track.name ?? t('timeline.scriptTrackName')
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameScriptTrackDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameScriptTrackDialog.confirm'),
})
if (next === null) return
store.setScriptTrackName(id, next)
}
if (trackMenu.value?.kind === 'audio') {
const track = audioTrackById.value.get(trackMenu.value.trackId)
if (!track) return
const id = trackMenu.value.trackId
const current = track.name ?? t('timeline.audioTrackName')
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameAudioTrackDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameAudioTrackDialog.confirm'),
})
if (next === null) return
store.setAudioTrackName(id, next)
}
}
function toggleAudioMutedFromMenu() {
if (!trackMenu.value || trackMenu.value.kind !== 'audio') return
store.setAudioTrackMuted(trackMenu.value.trackId, !trackMenu.value.muted)
trackMenu.value = null
}
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 isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
const tag = target.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
if (target.isContentEditable) return true
return false
}
function onDocumentKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (selectedKfs.value.length) {
selectedKfs.value = []
return
}
if (selectedMarkerId.value) {
selectedMarkerId.value = null
return
}
}
if (isEditableTarget(e.target)) return
if (e.key === 'Backspace' || e.key === 'Delete') {
if (selectedKfs.value.length) {
e.preventDefault()
const targets = [...selectedKfs.value]
selectedKfs.value = []
for (const k of targets) store.removeKeyframe(k.layerId, k.keyframeId)
return
}
if (selectedMarkerId.value) {
e.preventDefault()
const id = selectedMarkerId.value
selectedMarkerId.value = null
store.removeMarker(id)
return
}
// Selected-track deletion. Only layer tracks carry a selection
// today (clip / script / group tracks don't set selectedLayerId).
// Skip the delete when the layer is locked directly or through its
// parent group — Backspace should respect the same locks that block
// mouse interaction.
if (store.selectedLayerId) {
const layerId = store.selectedLayerId
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) return
if (layer.locked || lockedGroupChildIds.value.has(layerId)) return
e.preventDefault()
store.removeLayer(layerId)
return
}
}
if (
e.key.toLowerCase() === 'm' &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.shiftKey
) {
e.preventDefault()
addMarkerAtPlayhead()
}
}
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
function onScrollCapture() {
if (scrollEl.value) scrollLeft.value = scrollEl.value.scrollLeft
}
onMounted(() => {
if (!scrollEl.value) return
ro = new ResizeObserver((entries) => {
const rect = entries[0].contentRect
viewportWidth.value = rect.width
})
ro.observe(scrollEl.value)
scrollEl.value.addEventListener('scroll', onScrollCapture, { passive: true })
// Seed with current value.
scrollLeft.value = scrollEl.value.scrollLeft
document.addEventListener('keydown', onDocumentKeyDown)
})
onBeforeUnmount(() => {
ro?.disconnect()
ro = null
if (scrollEl.value) scrollEl.value.removeEventListener('scroll', onScrollCapture)
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
document.removeEventListener('pointerdown', onOutsideMarkerPointerDown, 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 v-if="store.activeButtonAssetId" class="scrub-bar button-scrub-bar">
<div class="name-spacer">
<div class="button-mode-toggle">
<button
class="mode-btn"
:class="{ active: store.activeButtonEditMode === 'states' }"
v-tooltip="t('timeline.buttonModeStates')"
@click="store.setButtonEditMode('states')"
>{{ t('timeline.buttonModeStatesShort') }}</button>
<button
class="mode-btn hit"
:class="{ active: store.activeButtonEditMode === 'hit' }"
v-tooltip="t('timeline.buttonModeHit')"
@click="store.setButtonEditMode('hit')"
>{{ t('timeline.buttonModeHitShort') }}</button>
</div>
</div>
<div
v-if="store.activeButtonEditMode === 'states'"
class="scrub-ruler button-state-ruler"
:style="{ width: BUTTON_STATE_TOTAL_WIDTH + 'px' }"
@pointerdown="onStateScrubPointerDown"
@pointermove="onStateScrubPointerMove"
@pointerup="onStateScrubPointerUp"
@pointercancel="onStateScrubPointerUp"
>
<span
v-for="(st, idx) in BUTTON_EDIT_STATES"
:key="st"
class="tick button-state-tick"
:class="{
active: store.activeButtonState === st,
}"
:style="{ left: stateTickLeft(idx) + 'px' }"
>
<span class="tick-line"></span>
<span class="tick-label">{{ stateLabel(st) }}</span>
</span>
<span
class="scrub-handle"
:style="{ left: currentStateLeft + 'px' }"
>
<span class="scrub-handle-head"></span>
<span class="scrub-handle-line"></span>
</span>
</div>
<div v-else class="hit-edit-header">
<span class="hit-edit-title">{{ t('timeline.hitAreaHeader') }}</span>
<span class="hit-edit-hint">{{ t('timeline.hitAreaHint') }}</span>
</div>
</div>
<div v-else class="scrub-bar">
<div class="name-spacer">
<div class="mode-toggle" v-tooltip="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 }"
v-tooltip="snapEnabled ? t('timeline.snapOn') : t('timeline.snapOff')"
@click="snapEnabled = !snapEnabled"
>
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
</button>
<button
class="snap-btn"
:class="{ active: !showLocked }"
v-tooltip="showLocked ? t('timeline.hideLocked') : t('timeline.showLocked')"
@click="showLocked = !showLocked"
>
<component
:is="showLocked ? PhEye : PhEyeSlash"
:size="12"
/>
</button>
<button
v-if="!inClipEdit"
class="snap-btn"
v-tooltip="t('timeline.addGroupTooltip')"
@click="addGroup"
>
<PhFolderPlus :size="12" />
</button>
<button
v-if="!inClipEdit"
class="snap-btn"
v-tooltip="t('timeline.addScriptTrackTooltip')"
@click="store.addScriptTrack()"
>
<PhCode :size="12" />
</button>
<button
v-if="!inClipEdit"
class="snap-btn"
v-tooltip="t('timeline.addAudioTrackTooltip')"
@click="store.addAudioTrack()"
>
<PhMusicNote :size="12" />
</button>
<button
class="snap-btn add-kf-btn"
v-tooltip="t('timeline.addKeyframeTooltip')"
:disabled="!canCreateKf"
@click="addKeyframeForSelected"
>
<PhPlus :size="12" :weight="'bold'" />
</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
v-if="store.playbackIn > 0"
class="range-dim dim-before"
:style="{ width: pxFromTime(store.playbackIn) + 'px' }"
></span>
<span
v-if="store.playbackOut < duration"
class="range-dim dim-after"
:style="{
left: pxFromTime(store.playbackOut) + 'px',
width: pxFromTime(duration - store.playbackOut) + 'px',
}"
></span>
<span
class="range-bracket bracket-in"
v-tooltip="t('timeline.inPoint')"
:style="{ left: pxFromTime(store.playbackIn) + 'px' }"
@pointerdown="onInBracketPointerDown"
@pointermove="onInBracketPointerMove"
@pointerup="onInBracketPointerUp"
@pointercancel="onInBracketPointerUp"
></span>
<span
class="range-bracket bracket-out"
v-tooltip="t('timeline.outPoint')"
:style="{ left: pxFromTime(store.playbackOut) + 'px' }"
@pointerdown="onOutBracketPointerDown"
@pointermove="onOutBracketPointerMove"
@pointerup="onOutBracketPointerUp"
@pointercancel="onOutBracketPointerUp"
></span>
<span
v-for="marker in store.scene.markers ?? []"
:key="marker.id"
class="timeline-marker"
:class="{ selected: selectedMarkerId === marker.id }"
:style="{
left: pxFromTime(marker.time) + 'px',
...(marker.color ? { '--marker-color': marker.color } : {}),
}"
v-tooltip="marker.name"
@pointerdown="onMarkerPointerDown($event, marker.id)"
@pointermove="onMarkerPointerMove($event, marker.id)"
@pointerup="onMarkerPointerUp($event, marker.id)"
@pointercancel="onMarkerPointerUp($event, marker.id)"
@dblclick.stop="renameMarkerPrompt(marker.id)"
>
<span class="timeline-marker-flag"></span>
<span class="timeline-marker-label">{{ marker.name }}</span>
</span>
<span
class="scrub-handle"
:class="{ obscured: scrubHandleObscured }"
: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'}`
: track.type === 'script'
? `s:${track.scriptTrackId}`
: track.type === 'audio'
? `a:${track.audioTrackId}`
: `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: isTrackEffectivelyLocked(track),
'in-group': track.inGroup,
}"
:style="trackColorStyle(track.layer.color)"
@dragover="
track.isParent && !isTrackEffectivelyLocked(track)
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
: onTrackDragOver($event, track.orderIndex))
: null
"
@drop="
track.isParent && !isTrackEffectivelyLocked(track)
? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
: null
"
@dragleave="onTrackDragLeave"
>
<span
class="track-name"
:class="{
selected:
track.isParent && store.selectedLayerIds.includes(track.layerId),
parent: track.isParent,
sub: !track.isParent,
}"
:draggable="track.isParent && !isTrackEffectivelyLocked(track)"
@click="onLayerTrackNameClick($event, track)"
@contextmenu="
track.isParent && !isTrackEffectivelyLocked(track)
? onTrackContextMenu($event, track.layerId)
: null
"
@dragstart="
track.isParent && !isTrackEffectivelyLocked(track)
? onTrackDragStart($event, track.layerId)
: null
"
@dragend="onTrackDragEnd"
>
<PhDotsSixVertical v-if="track.isParent" :size="12" class="drag-handle" />
<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' }"
/>
<component
v-else-if="track.isParent && assetIconFor(track.layer)"
:is="assetIconFor(track.layer)"
:size="11"
class="name-badge"
/>
<span v-if="!track.isParent" class="sub-indent">↳</span>
<span class="track-label">{{ track.label }}</span>
<button
v-if="track.isParent"
class="eye-btn"
:class="{ hidden: track.layer.editorHidden }"
v-tooltip="track.layer.editorHidden ? t('timeline.showInEditor') : t('timeline.hideInEditor')"
@click.stop="store.setLayerEditorHidden(track.layerId, !track.layer.editorHidden)"
@mousedown.stop
@dragstart.prevent.stop
>
<component :is="track.layer.editorHidden ? PhEyeSlash : PhEye" :size="12" />
</button>
<button
v-if="track.isParent && !lockedGroupChildIds.has(track.layerId)"
class="lock-btn"
:class="{ locked: track.layer.locked }"
v-tooltip="track.layer.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setTrackLocked(track.layerId, !track.layer.locked)"
@mousedown.stop
@dragstart.prevent.stop
>
<span class="lock-icon rest">
<component :is="track.layer.locked ? PhLock : PhLockOpen" :size="12" />
</span>
<span class="lock-icon alt">
<component
:is="track.layer.locked ? PhLockOpen : PhLock"
:size="12"
:weight="track.layer.locked ? 'fill' : 'regular'"
/>
</span>
</button>
</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, duration)"
:key="seg.id"
class="kf-seg"
:class="[seg.held ? 'held' : `easing-${seg.easing}`, { tail: seg.tail }]"
: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"
v-tooltip="
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)"
/>
<button
v-for="emptyT in emptyButtonSlots(track.kfs)"
:key="`empty-${track.layerId}-${track.kind}-${emptyT}`"
type="button"
class="kf-add"
:style="{ left: pxFromTime(emptyT) + 'px' }"
v-tooltip="t('timeline.addKfFromPrev')"
@mousedown.stop
@pointerdown.stop
@click.stop="addKfFromPrev(track.layerId, track.kfs, emptyT)"
>
<PhPlus :size="10" weight="bold" />
</button>
</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: isTrackEffectivelyLocked(track),
'in-group': track.inGroup,
}"
:style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)"
@dragover="
!isTrackEffectivelyLocked(track)
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
: onTrackDragOver($event, track.orderIndex))
: null
"
@drop="
!isTrackEffectivelyLocked(track)
? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
: null
"
@dragleave="onTrackDragLeave"
>
<span
class="track-name clip-track-name"
:draggable="!isTrackEffectivelyLocked(track)"
@contextmenu="
!isTrackEffectivelyLocked(track)
? onClipTrackContextMenu($event, track.clipTrackId)
: null
"
@dragstart="
!isTrackEffectivelyLocked(track)
? onTrackDragStart($event, track.clipTrackId)
: null
"
@dragend="onTrackDragEnd"
>
<PhDotsSixVertical :size="12" class="drag-handle" />
<PhFilmStrip :size="11" class="name-badge" />
<span class="track-label">{{ track.name }}</span>
<button
v-if="!lockedGroupChildIds.has(track.clipTrackId)"
class="lock-btn"
:class="{ locked: !!clipTrackById.get(track.clipTrackId)?.locked }"
v-tooltip="clipTrackById.get(track.clipTrackId)?.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="
store.setTrackLocked(
track.clipTrackId,
!clipTrackById.get(track.clipTrackId)?.locked,
)
"
@mousedown.stop
@dragstart.prevent.stop
>
<span class="lock-icon rest">
<component
:is="clipTrackById.get(track.clipTrackId)?.locked ? PhLock : PhLockOpen"
:size="12"
/>
</span>
<span class="lock-icon alt">
<component
:is="clipTrackById.get(track.clipTrackId)?.locked ? PhLockOpen : PhLock"
:size="12"
:weight="clipTrackById.get(track.clipTrackId)?.locked ? 'fill' : 'regular'"
/>
</span>
</button>
</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)"
v-tooltip="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>
<!-- Script track -->
<div
v-else-if="track.type === 'script'"
class="track script-track"
:class="{
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: dragTrackId === track.scriptTrackId,
colored: !!track.color,
locked: track.locked,
}"
:style="trackColorStyle(track.color)"
@dragover="!track.locked ? onTrackDragOver($event, track.orderIndex) : null"
@drop="!track.locked ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave"
>
<span
class="track-name script-track-name"
:draggable="!track.locked"
@contextmenu="!track.locked ? onScriptTrackContextMenu($event, track.scriptTrackId) : null"
@dragstart="!track.locked ? onTrackDragStart($event, track.scriptTrackId) : null"
@dragend="onTrackDragEnd"
>
<PhDotsSixVertical :size="12" class="drag-handle" />
<PhCode :size="11" class="name-badge" />
<span class="track-label">{{ track.name }}</span>
<button
class="lock-btn"
:class="{ locked: track.locked }"
v-tooltip="track.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setTrackLocked(track.scriptTrackId, !track.locked)"
@mousedown.stop
@dragstart.prevent.stop
>
<span class="lock-icon rest">
<component :is="track.locked ? PhLock : PhLockOpen" :size="12" />
</span>
<span class="lock-icon alt">
<component
:is="track.locked ? PhLockOpen : PhLock"
:size="12"
:weight="track.locked ? 'fill' : 'regular'"
/>
</span>
</button>
</span>
<div
class="track-lane script-lane"
:style="{ width: laneWidth + 'px' }"
@click="track.locked ? null : onScriptLaneClick($event, track.scriptTrackId)"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="frame in scriptFramesByTrack[track.scriptTrackId] ?? []"
:key="frame.id"
class="script-frame"
:style="{ left: pxFromTime(frame.time) + 'px' }"
v-tooltip="frame.label ?? t('timeline.scriptFrameTooltip', { n: frame.actions.length })"
@pointerdown="onScriptFramePointerDown($event, frame.id)"
@pointermove="onScriptFramePointerMove"
@pointerup="onScriptFramePointerUp"
@pointercancel="onScriptFramePointerUp"
>
<span class="script-frame-dot"></span>
<span v-if="frame.actions.length > 1" class="script-frame-count">{{ frame.actions.length }}</span>
</span>
</div>
</div>
<!-- Audio track -->
<div
v-else-if="track.type === 'audio'"
class="track audio-track"
:class="{
colored: !!track.color,
locked: track.locked,
muted: track.muted,
}"
:style="trackColorStyle(track.color)"
:data-audio-track-id="track.audioTrackId"
>
<span
class="track-name audio-track-name"
@contextmenu="!track.locked ? onAudioTrackContextMenu($event, track.audioTrackId) : null"
>
<PhDotsSixVertical :size="12" class="drag-handle" />
<PhMusicNote :size="11" class="name-badge" />
<span class="track-label">{{ track.name }}</span>
<button
v-if="track.muted"
class="mute-indicator"
v-tooltip="t('timeline.audioMuted')"
@click.stop="store.setAudioTrackMuted(track.audioTrackId, false)"
@mousedown.stop
>
<PhSpeakerSlash :size="12" />
</button>
</span>
<div
class="track-lane audio-lane"
:style="{ width: laneWidth + 'px' }"
@dragover.stop="!track.locked ? onAudioLaneDragOver($event, track.audioTrackId) : null"
@dragleave.stop="onAudioLaneDragLeave"
@drop.stop="!track.locked ? onAudioLaneDrop($event, track.audioTrackId) : null"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="clip in audioClipsByTrack[track.audioTrackId] ?? []"
:key="clip.id"
class="audio-clip-block"
:class="{ moving: audioClipMove?.clipId === clip.id }"
:style="audioClipBlockStyle(clip)"
v-tooltip="audioClipTooltip(clip)"
@pointerdown="onAudioClipPointerDown($event, clip)"
@pointermove="onAudioClipPointerMove"
@pointerup="onAudioClipPointerUp"
@pointercancel="onAudioClipPointerUp"
@contextmenu="onAudioClipContextMenu($event, clip)"
>
<span class="audio-clip-label">
{{ audioAssetById.get(clip.audioAssetId)?.name ?? '—' }}
</span>
</span>
<span
v-if="audioClipGhost && audioClipGhost.targetTrackId === track.audioTrackId"
class="audio-clip-ghost"
:style="{
left: pxFromTime(audioClipGhost.startTime) + 'px',
width: Math.max(6, pxFromTime(audioClipGhost.length)) + 'px',
}"
></span>
</div>
</div>
<!-- Group header -->
<div
v-else-if="track.type === 'group'"
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,
locked: track.locked,
}"
:style="trackColorStyle(track.color)"
@dragover="!track.locked ? onTrackDragOver($event, track.orderIndex) : null"
@drop="!track.locked ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave"
>
<span
class="track-name group-track-name"
:class="{ 'drop-into': dropGroupId === track.groupId }"
:draggable="!track.locked"
@click="toggleGroupCollapsed(track.groupId)"
@dblclick="!track.locked ? renameGroupPrompt(track.groupId, track.name) : null"
@contextmenu="!track.locked ? onGroupContextMenu($event, track.groupId) : null"
@dragstart="!track.locked ? onTrackDragStart($event, track.groupId) : null"
@dragend="onTrackDragEnd"
@dragover.stop="!track.locked ? onGroupBodyDragOver($event, track.groupId) : null"
@drop.stop="!track.locked ? onGroupBodyDrop($event, track.groupId) : null"
@dragleave="onGroupBodyDragLeave"
>
<PhDotsSixVertical :size="12" class="drag-handle" />
<component
:is="track.collapsed ? PhCaretRight : PhCaretDown"
:size="11"
class="group-caret"
/>
<PhFolder :size="12" class="name-badge" />
<span class="track-label">{{ track.name }}</span>
<span class="group-count">{{ t('timeline.groupChildCount', { n: track.childCount }) }}</span>
<button
class="lock-btn"
:class="{ locked: track.locked }"
v-tooltip="track.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setTrackLocked(track.groupId, !track.locked)"
@mousedown.stop
@dragstart.prevent.stop
>
<span class="lock-icon rest">
<component :is="track.locked ? PhLock : PhLockOpen" :size="12" />
</span>
<span class="lock-icon alt">
<component
:is="track.locked ? PhLockOpen : PhLock"
:size="12"
:weight="track.locked ? 'fill' : 'regular'"
/>
</span>
</button>
</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>
<template v-if="store.activeButtonEditMode === 'hit'">
<div
v-for="(shape, idx) in activeButtonHitShapes"
:key="`hit-track-${idx}`"
class="track hit-track"
>
<div class="name-col">
<span class="hit-track-kind">
<PhSquare v-if="shape.kind === 'rect'" :size="11" />
<PhCircle v-else-if="shape.kind === 'circle'" :size="11" />
<PhPolygon v-else :size="11" />
</span>
<span class="hit-track-label">{{ hitShapeLabel(shape, idx) }}</span>
<button
class="hit-track-delete"
v-tooltip="t('timeline.hitTrackDelete')"
@pointerdown.stop
@click.stop="onDeleteHitShape(idx)"
>×</button>
</div>
<div class="lane hit-lane"></div>
</div>
<p v-if="!activeButtonHitShapes.length" class="empty">
{{ t('timeline.hitAreaEmpty') }}
</p>
</template>
<p
v-else-if="!tracks.length"
class="empty"
>
{{ store.activeButtonAssetId ? t('timeline.emptyButtonState') : t('timeline.noTracks') }}
</p>
<div
v-if="showClipDropzone"
class="track clip-track-dropzone"
:class="{ hot: clipDragOverDropzone }"
@dragenter="onNewTrackDragEnter"
@dragover="onNewTrackDragOver"
@dragleave="onNewTrackDragLeave"
@drop="onNewTrackDrop"
>
<span class="dropzone-hint" :style="dropzoneHintStyle">
{{ t('timeline.dropNewTrackHint') }}
</span>
</div>
</div>
</div>
</div>
<div
class="name-col-resize"
:class="{ resizing: nameResizing }"
:style="{ left: trackNameWidth + 'px' }"
v-tooltip="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 }"
:disabled="menu.isFirst"
@click="applyEasing(opt.value)"
>
<span class="kf-menu-icon">
<PhCheck
v-if="menu.currentEasing === opt.value"
:size="12"
weight="bold"
/>
</span>
{{ opt.label }}
</button>
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
</div>
<div class="kf-menu-divider"></div>
<button
class="kf-menu-item"
:class="{ active: menu.isHeld }"
@click="toggleHold"
>
<PhPauseCircle :size="12" class="kf-menu-icon" />
{{ t('timeline.stillFrame') }}
<PhCheck
v-if="menu.isHeld"
:size="12"
weight="bold"
class="kf-menu-trailing-check"
/>
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<button class="kf-menu-item danger" @click="deleteFromMenu">
<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"
v-tooltip="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">
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
{{ t('timeline.renameGroup') }}
</button>
</template>
<template v-else-if="trackMenu.kind === 'script'">
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="renameFromMenu">
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
{{ t('timeline.renameScriptTrack') }}
</button>
</template>
<template v-else-if="trackMenu.kind === 'audio'">
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="renameFromMenu">
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
{{ t('timeline.renameAudioTrack') }}
</button>
<button class="kf-menu-item" @click="toggleAudioMutedFromMenu">
<PhSpeakerSlash :size="12" class="kf-menu-icon" />
{{ t('timeline.muteAudioTrack') }}
<PhCheck
v-if="trackMenu.muted"
:size="12"
weight="bold"
class="kf-menu-trailing-check"
/>
</button>
</template>
<template v-else>
<template v-if="trackMenu.kind === 'layer'">
<div class="kf-menu-divider"></div>
<button
class="kf-menu-item"
:disabled="trackMenu.locked"
@click="toggleSeparated"
>
<PhArrowsSplit :size="12" class="kf-menu-icon" />
{{ t('timeline.separate') }}
<PhCheck
v-if="trackMenu.separated"
:size="12"
weight="bold"
class="kf-menu-trailing-check"
/>
</button>
<button
v-if="store.selectedLayerIds.length >= 2"
class="kf-menu-item"
@click="groupSelectedFromMenu"
>
<PhFolderPlus :size="12" class="kf-menu-icon" />
{{ t('timeline.groupSelected', { n: store.selectedLayerIds.length }) }}
</button>
<button
v-if="store.selectedLayerIds.length >= 1"
class="kf-menu-item"
@click="convertSelectedToClipFromMenu"
>
<PhFilmStrip :size="12" class="kf-menu-icon" />
{{ t('timeline.convertToClip', { n: store.selectedLayerIds.length }) }}
</button>
</template>
</template>
<template v-if="trackMenu.kind !== 'group'">
<div class="kf-menu-divider"></div>
<button
class="kf-menu-item"
:disabled="trackMenu.locked"
@click="duplicateTrackFromMenu"
>
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
</template>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteTrack">
<PhTrash :size="12" class="kf-menu-icon" />
{{
trackMenu.kind === 'layer'
? t('timeline.deleteLayer')
: trackMenu.kind === 'clip'
? t('timeline.deleteClipTrack')
: trackMenu.kind === 'script'
? t('timeline.deleteScriptTrack')
: trackMenu.kind === 'audio'
? t('timeline.deleteAudioTrack')
: t('timeline.ungroup')
}}
</button>
</div>
<ScriptFrameDialog
v-model="scriptDialogOpen"
:frame-id="scriptDialogFrameId"
/>
</div>
</template>
<style scoped>
.timeline {
--track-name-width: 140px;
position: relative;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.button-scrub-bar {
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
}
.button-scrub-bar .button-name-label {
font-size: 11px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.button-state-ruler {
cursor: pointer;
}
.button-state-tick .tick-line {
height: 8px;
background: var(--border);
}
.button-state-tick.active .tick-line {
background: var(--accent);
}
.button-state-tick .tick-label {
font-size: 11px;
font-weight: 500;
text-transform: capitalize;
padding-top: 4px;
letter-spacing: 0.01em;
}
.button-state-tick.active .tick-label {
color: var(--text);
}
.button-mode-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg);
margin-left: auto;
}
.button-mode-toggle .mode-btn {
padding: 3px 8px;
min-width: 48px;
font-size: 10px;
font-weight: 500;
color: var(--text-dim);
background: transparent;
border: none;
cursor: pointer;
}
.button-mode-toggle .mode-btn:hover {
background: var(--panel-alt);
color: var(--text);
}
.button-mode-toggle .mode-btn.active {
background: var(--accent-soft);
color: var(--accent);
}
.button-mode-toggle .mode-btn.hit.active {
background: color-mix(in srgb, #f59e0b 24%, transparent);
color: #f59e0b;
}
.button-mode-toggle .mode-btn + .mode-btn {
border-left: 1px solid var(--border);
}
.hit-edit-header {
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
color: var(--text);
font-size: 11px;
flex: 1;
}
.hit-edit-title {
font-weight: 600;
color: #f59e0b;
}
.hit-edit-hint {
color: var(--text-dim);
}
.hit-track .name-col {
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
}
.hit-track-kind {
color: #f59e0b;
display: inline-flex;
align-items: center;
}
.hit-track-label {
font-size: 11px;
color: var(--text);
font-variant-numeric: tabular-nums;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hit-track-delete {
width: 18px;
height: 18px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text-dim);
cursor: pointer;
font-size: 13px;
line-height: 1;
}
.hit-track-delete:hover {
background: var(--panel-alt);
color: #ef4444;
}
.hit-lane {
background: repeating-linear-gradient(
45deg,
transparent 0 8px,
color-mix(in srgb, #f59e0b 8%, transparent) 8px 16px
);
}
.name-col-resize {
position: absolute;
/* Start below the scrub bar so the bar's hit area (scrubber/playhead)
is never shadowed by the resize strip. */
top: 28px;
bottom: 0;
width: 6px;
/* Hit area lies entirely inside the name-column side of the boundary
so the playhead sitting on the lane edge stays clickable. */
transform: translateX(-6px);
cursor: ew-resize;
z-index: 3;
touch-action: none;
}
.name-col-resize::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
right: 0;
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;
/* Above sticky track-name columns (z-index 30) so that scrolling rows
pass *under* the scrub-bar + name-spacer instead of sliding over it. */
z-index: 40;
display: flex;
/* 28px ruler content + 3px padding-top reserved for the scrub handle
head to poke above the ruler. Total box height: 31px. */
height: 28px;
padding-top: 3px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.name-spacer {
position: sticky;
left: 0;
z-index: 30;
width: var(--track-name-width);
/* Extend 3px up (into the scrub-bar's padding-top) and 1px down (over
the scrub-bar's border so the handle line's `bottom: -1px` overshoot
can't peek through underneath). Together this makes the toggle row
fully cover the obscured handle. */
height: calc(100% + 4px);
margin-top: -3px;
margin-bottom: -1px;
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);
}
.snap-btn:disabled {
opacity: 0.4;
cursor: default;
background: var(--bg);
color: var(--text-dim);
}
.snap-btn.add-kf-btn {
margin-left: auto;
}
.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;
}
.range-dim {
position: absolute;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.35);
pointer-events: none;
z-index: 1;
}
.range-dim.dim-before {
left: 0;
}
.range-bracket {
position: absolute;
top: 0;
bottom: 0;
width: 10px;
cursor: ew-resize;
z-index: 19;
touch-action: none;
}
.range-bracket::before,
.range-bracket::after {
content: '';
position: absolute;
background: var(--range-bracket);
transition: background 120ms var(--ease);
}
.range-bracket::before {
top: 0;
bottom: 0;
width: 2px;
}
.range-bracket::after {
top: 0;
width: 8px;
height: 6px;
}
.range-bracket.bracket-in::before {
left: 0;
}
.range-bracket.bracket-in::after {
left: 0;
border-radius: 0 2px 2px 0;
}
.range-bracket.bracket-in {
transform: translateX(-0.5px);
}
.range-bracket.bracket-out::before {
right: 0;
}
.range-bracket.bracket-out::after {
right: 0;
border-radius: 2px 0 0 2px;
}
.range-bracket.bracket-out {
transform: translateX(calc(0.5px - 10px));
}
.range-bracket:hover::before,
.range-bracket:hover::after {
background: var(--range-hover);
}
.timeline-marker {
position: absolute;
top: 0;
bottom: 0;
/* Above playhead (20) and brackets (19) so marker clicks/drags win
when the playhead has snapped onto the marker's position. */
z-index: 21;
pointer-events: auto;
cursor: pointer;
touch-action: none;
--marker-color: #ef4444;
}
.timeline-marker::before {
content: '';
position: absolute;
top: 8px;
bottom: 0;
left: 0;
width: 1px;
background: var(--marker-color);
opacity: 0.45;
pointer-events: none;
}
.timeline-marker-flag {
position: absolute;
top: 0;
left: 0;
width: 8px;
height: 10px;
background: var(--marker-color);
clip-path: polygon(0 0, 100% 0, 100% 70%, 0 100%);
}
.timeline-marker-label {
position: absolute;
top: -1px;
left: 10px;
font-size: 10px;
font-weight: 500;
color: var(--marker-color);
white-space: nowrap;
pointer-events: none;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.timeline-marker:hover .timeline-marker-flag {
box-shadow: 0 0 0 1.5px var(--range-hover);
}
/* Selected marker: override to orange (incl. per-marker color overrides)
so the selection is obvious against the default red. */
.timeline-marker.selected {
--marker-color: #f59e0b !important;
}
.timeline-marker.selected .timeline-marker-flag {
box-shadow: 0 0 0 1.5px var(--range-hover);
}
.timeline-marker.selected .timeline-marker-label {
font-weight: 700;
}
.scrub-handle {
position: absolute;
top: 0;
bottom: -1px;
transform: translateX(-50%);
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
/* Default: above the sticky name-spacer (z-index 30 in the scrub-bar
stacking context) so the handle isn't half-clipped when the playhead
is at t=0 and overlaps the name-spacer column. */
z-index: 41;
}
/* When the playhead has scrolled past the left edge of the ruler (i.e. the
handle centre sits behind the sticky name-spacer), drop below z:30 so
the name-spacer + toggle row covers the handle cleanly. Toggled from
JS via `scrubHandleObscured`. */
.scrub-handle.obscured {
z-index: 20;
}
.scrub-handle-head {
width: 14px;
height: 18px;
background: var(--accent);
/* Poke 3px into the scrub-bar's padding-top area so the head sticks
out above the ruler, with slightly rounded top corners (radius 2). */
margin-top: -3px;
clip-path: path('M 2 0 Q 0 0 0 2 L 0 10 L 7 18 L 14 10 L 14 2 Q 14 0 12 0 Z');
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
pointer-events: auto;
cursor: ew-resize;
transition: background 120ms var(--ease);
}
.scrub-handle-head:hover {
background: var(--range-hover);
}
.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;
}
/* Inactive tracks fade as a whole row — same visual vocabulary as
the active track, just dimmer. Applying opacity to the row itself
keeps keyframes, segments, and the still-frame tail visually in
sync (previously we faded each child individually, which multiplied
against each child's own opacity and made diamonds read as ghostly
while their connecting segments looked nearly normal). */
.track.inactive {
opacity: 0.5;
transition: opacity 140ms var(--ease);
}
.track.inactive:hover {
opacity: 0.85;
}
.track.locked {
height: 15px;
background: color-mix(in srgb, #ff6b6b 8%, transparent);
}
.track.clip-track.locked {
height: 17px;
}
.track.sub-track.locked {
height: 13px;
}
.track.locked .track-name {
font-size: 10px;
background: color-mix(in srgb, #ff6b6b 8%, var(--panel));
}
.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: default;
/* Locked lane is fully inert: no hover, no click, no drag, nothing. */
pointer-events: none;
}
.track.locked .kf,
.track.locked .kf-seg,
.track.locked .clip-block {
opacity: 0.55;
}
.track.locked .drag-handle {
/* Keep the layout space so labels align with unlocked siblings — just hide
the grip visually. */
visibility: hidden;
}
/* Locked track-name is inert except for the lock toggle itself. */
.track.locked .track-name {
cursor: default;
pointer-events: none;
/* Keep the dimmed look but don't react to hover */
color: var(--text-dim);
}
.track.locked .track-name .lock-btn {
pointer-events: auto;
}
/* Locked groups stay collapsible — re-enable pointer events on the name */
.track.group-track.locked .track-name {
pointer-events: auto;
cursor: pointer;
}
.lock-btn {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 140ms var(--ease), color 140ms var(--ease);
}
/* Editor-only show/hide toggle. Sits to the left of the lock button.
Stays visible when the layer is hidden so the state is obvious; on
hover it fades in on every track so you can reach it without the
layer first being hidden. */
.eye-btn {
margin-left: auto;
margin-right: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 140ms var(--ease), color 140ms var(--ease);
}
.track-name:hover .eye-btn {
opacity: 0.7;
}
.eye-btn:hover {
color: var(--text);
opacity: 1;
}
.eye-btn.hidden {
opacity: 1;
color: var(--accent);
}
.track-name:hover .lock-btn {
opacity: 0.7;
}
.lock-btn:hover {
color: var(--text);
opacity: 1;
}
.lock-btn.locked {
opacity: 1;
color: var(--text);
}
.lock-btn.locked:hover {
color: var(--accent);
}
.lock-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
}
.lock-icon.alt {
display: none;
}
.lock-btn:hover .lock-icon.rest {
display: none;
}
.lock-btn:hover .lock-icon.alt {
display: inline-flex;
}
.track-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1 1 auto;
}
.track-name {
position: sticky;
left: 0;
z-index: 30;
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);
/* Above segments/keyframes but below sticky label columns (z-index 30). */
z-index: 20;
}
.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;
}
/* "Tail" = on-stage indicator where the layer is visible but no
interpolation is happening (still-frame past the last keyframe, or
a single-keyframe layer that's always on stage). Solid line at low
opacity — reads as "layer idling on stage" rather than "thin line".
Overrides any preceding easing-* gradient background so the fill is
always a flat accent colour at the track's tint. */
.kf-seg.tail,
.kf-seg.tail.easing-linear,
.kf-seg.tail.easing-easeIn,
.kf-seg.tail.easing-easeOut,
.kf-seg.tail.easing-easeInOut {
background: var(--track-color, var(--accent));
opacity: 0.35;
}
.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;
}
/* Button-editor empty-slot placeholder. Outlined accent-coloured
circle that stays fully opaque (no fade) so it's obvious the slot
is interactive; on hover the fill solidifies into the accent so
the user sees "I'm about to drop a keyframe here". */
.kf-add {
position: absolute;
top: 50%;
width: 18px;
height: 18px;
margin-left: -9px;
margin-top: -9px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--panel);
border: 1.5px solid var(--track-color, var(--accent));
border-radius: 50%;
color: var(--track-color, var(--accent));
cursor: pointer;
transition: background 140ms var(--ease), color 140ms var(--ease),
transform 140ms var(--ease);
z-index: 2;
}
.kf-add:hover {
background: var(--track-color, var(--accent));
color: #fff;
transform: scale(1.1);
}
.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-lane {
position: relative;
}
/* ---- Script track ---- */
.track.script-track {
height: 26px;
background: linear-gradient(
to bottom,
rgba(245, 158, 11, 0.06) 0%,
rgba(245, 158, 11, 0.02) 100%
);
}
.script-track .track-name {
background: var(--panel-alt);
font-size: 11px;
color: var(--text-dim);
}
.script-lane {
position: relative;
}
.script-frame {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 3px;
cursor: pointer;
touch-action: none;
user-select: none;
}
.script-frame-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #f59e0b;
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.35);
}
.script-frame-count {
font-size: 9px;
font-weight: 600;
color: #f59e0b;
background: rgba(245, 158, 11, 0.15);
border-radius: 999px;
padding: 0 5px;
line-height: 13px;
min-width: 13px;
text-align: center;
}
/* ---- Audio track ---- */
.track.audio-track {
height: 30px;
background: linear-gradient(
to bottom,
rgba(14, 165, 233, 0.08) 0%,
rgba(14, 165, 233, 0.02) 100%
);
}
.audio-track .track-name {
background: var(--panel-alt);
font-size: 11px;
color: var(--text-dim);
}
.audio-track.muted .track-name .track-label {
text-decoration: line-through;
opacity: 0.6;
}
.mute-indicator {
margin-left: auto;
background: transparent;
border: none;
padding: 2px 4px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-dim);
display: inline-flex;
align-items: center;
}
.mute-indicator:hover {
color: var(--text);
background: var(--panel-hover);
}
.audio-lane {
position: relative;
}
.audio-clip-block {
position: absolute;
top: 4px;
bottom: 4px;
background: linear-gradient(
to bottom,
rgba(14, 165, 233, 0.35) 0%,
rgba(14, 165, 233, 0.15) 100%
);
border: 1px solid rgba(14, 165, 233, 0.6);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
overflow: hidden;
padding: 0 6px;
box-sizing: border-box;
cursor: grab;
touch-action: none;
user-select: none;
}
.audio-clip-block:active,
.audio-clip-block.moving {
cursor: grabbing;
}
.audio-clip-block.moving {
border-color: var(--accent);
}
.track.audio-track.locked .audio-clip-block {
cursor: default;
}
.audio-clip-ghost {
position: absolute;
top: 4px;
bottom: 4px;
border: 1.5px dashed var(--accent);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 15%, transparent);
pointer-events: none;
box-sizing: border-box;
}
.audio-track.muted .audio-clip-block {
opacity: 0.45;
}
.audio-clip-label {
font-size: 10px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.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;
padding-left: 7px;
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 {
position: relative;
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: absolute;
top: 50%;
/* `left` is driven from a computed so the hint stays in the centre of
the *visible* ruler area (i.e. centred in the user's viewport, right
of the sticky name-spacer) regardless of horizontal scroll. */
transform: translate(-50%, -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);
}
.kf-menu-icon {
width: 12px;
height: 12px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Track-menu toggle items (e.g. "Separate Position and Rotation") show their
"active" checkmark on the far right so the left edge stays aligned with
items that only have an icon + label. */
.kf-menu-trailing-check {
margin-left: auto;
color: var(--accent);
flex-shrink: 0;
}
.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>