Files
RUWIO/src/components/Timeline.vue
T
thandClaude Opus 4.7 1f2812c64d 1.1.3: multi-select on the stage — marquee, group transform, panel
- Click-and-drag on the empty stage starts a layer marquee with
  touch (not enclose) semantics — every unlocked layer whose bbox
  overlaps the drag rect joins the selection. Tiny clicks still
  deselect; shift adds to the existing selection on both clicks
  and marquees. Polygon-vertex lasso stays available when a single
  polygon is the active selection.
- Konva transformer now wraps every selected layer in one shared
  bounding box (polygons fold in for mixed selections; vertex
  handles + the vertex lasso suppress to avoid fighting the box).
  rotateEnabled flips off on multi to skip the ambiguous shared-
  pivot rotation. Drag of any selected node mirrors the delta onto
  every sibling Konva node so the whole group travels together;
  transformend persists each layer's final pose, not just the
  anchor's.
- liveLayerPose became a per-id map (liveLayerPoses) so the
  reactive shape configs don't snap non-focused siblings back to
  their sampled pre-transform pose mid-gesture.
- Plain click on a member of an existing multi-selection no longer
  collapses to single-select before the drag begins.
- Arrow-key nudge + Timeline + keyframe button + Properties panel
  all fan out across the multi-selection: every editable layer
  gets the value, kind-specific panel sections (text, polygon,
  fill, stroke, blur, shadow, mask, button) collapse to leave just
  Transform + Opacity, and the trash button labels with the
  selection count.
- Click on empty stage with a polygon selected now deselects the
  layer too instead of leaving the polygon stuck in the panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 08:41:26 +02:00

5871 lines
177 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, type ComponentPublicInstance } from 'vue'
import {
PhArrowsSplit,
PhCaretDown,
PhCaretRight,
PhCheck,
PhCode,
PhCopy,
PhDotsSixVertical,
PhFilmStrip,
PhFolder,
PhFolderMinus,
PhFolderPlus,
PhLock,
PhLockOpen,
PhEye,
PhEyeSlash,
PhMagnet,
PhMaskHappy,
PhCircle,
PhImage,
PhMusicNote,
PhPauseCircle,
PhPencilSimpleLine,
PhPlus,
PhPolygon,
PhScissors,
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 { clearClipDragSource, clipDragSource } from '@/composables/useClipDragSource'
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
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
editorHidden: boolean
orderIndex: number
childCount: number
}
interface TimelineScriptTrack {
type: 'script'
scriptTrackId: string
name: string
orderIndex: number
locked: boolean
color: string | null
inGroup: boolean
parentGroupId?: string
groupChildIndex?: number
}
interface TimelineAudioTrack {
type: 'audio'
audioTrackId: string
name: string
orderIndex: number
locked: boolean
muted: boolean
color: string | null
inGroup: boolean
parentGroupId?: string
groupChildIndex?: number
}
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)
// pxPerSec / autoFit mirror the store's editor.timeline* fields so
// the timeline zoom / fit preference round-trips with the project
// file. Thin writableComputed shims keep the rest of this
// component's code (`pxPerSec.value = …`, `autoFit.value`) unchanged.
const pxPerSec = computed<number>({
get: () => store.editor.timelinePxPerSec,
set: (v) => { store.editor.timelinePxPerSec = v },
})
const autoFit = computed<boolean>({
get: () => store.editor.timelineAutoFit,
set: (v) => { store.editor.timelineAutoFit = v },
})
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 = computed<DisplayMode>({
get: () => store.editor.timelineDisplayMode,
set: (v) => { store.editor.timelineDisplayMode = v },
})
const snapEnabled = computed<boolean>({
get: () => store.editor.timelineSnap,
set: (v) => { store.editor.timelineSnap = v },
})
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
}
interface SnapTimeOpts {
ignoreKeyframeId?: string
/** True when snapping the playhead itself — omits the playhead
* from the candidate list so the scrub doesn't stick to its own
* previous position. Clip drags, clip trims, keyframe drags and
* marker drags all pick up the playhead as a magnet by default. */
ignorePlayhead?: boolean
}
function snapTime(t: number, opts: SnapTimeOpts = {}): number {
const candidates = allKeyframeTimes(opts.ignoreKeyframeId)
if (!opts.ignorePlayhead) candidates.push(store.playhead)
return snapToCandidates(t, candidates, {
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, { ignorePlayhead: true }))
}
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, { ignorePlayhead: true }))
}
const canCreateKf = computed(() => {
// Button is available as long as at least one selected layer is
// unlocked — the multi-select handler skips the locked ones per
// id, so a partially-locked selection still writes the usable
// frames.
const ids = store.selectedLayerIds.length
? store.selectedLayerIds
: store.selectedLayerId
? [store.selectedLayerId]
: []
if (!ids.length) return false
for (const id of ids) {
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) continue
if (layer.locked) continue
if (lockedGroupChildIds.value.has(id)) continue
return true
}
return false
})
function addKeyframeForSelected() {
// Fan out across the whole multi-selection so the user can pin a
// frame on every grouped object at once. Each layer gets its own
// sampled snapshot so the keyframe matches the current pose.
// Falls back to the single-selection anchor when no multi is set.
const ids = store.selectedLayerIds.length
? store.selectedLayerIds
: store.selectedLayerId
? [store.selectedLayerId]
: []
if (!ids.length) return
// Snap the playhead itself to the nearest keyframe before adding —
// the current playhead position is NOT a candidate (it's the value
// we're rounding).
const time = snapTime(store.playhead, { ignorePlayhead: true })
for (const id of ids) {
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer || isLayerIdLocked(id)) continue
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,
inGroup: false,
})
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,
inGroup: false,
})
return
}
const grp = groupById.value.get(id)
if (grp) {
out.push({
type: 'group',
groupId: grp.id,
name: grp.name,
collapsed: !!grp.collapsed,
color: grp.color ?? null,
locked: !!grp.locked,
editorHidden: !!grp.editorHidden,
orderIndex,
childCount: grp.childIds.length,
})
if (!grp.collapsed) {
grp.childIds.forEach((cid, childIndex) => {
const cl = layerById.get(cid)
if (cl) {
emitLayerTrack(cl, -1, true, out, grp.id, childIndex)
return
}
const cct = clipTrackById.value.get(cid)
if (cct) {
out.push({
type: 'clip',
clipTrackId: cct.id,
name: cct.name ?? t('timeline.clipTrackName'),
orderIndex: -1,
inGroup: true,
parentGroupId: grp.id,
groupChildIndex: childIndex,
})
return
}
const cst = scriptTrackById.value.get(cid)
if (cst) {
out.push({
type: 'script',
scriptTrackId: cst.id,
name: cst.name ?? t('timeline.scriptTrackName'),
orderIndex: -1,
locked: !!cst.locked,
color: cst.color ?? null,
inGroup: true,
parentGroupId: grp.id,
groupChildIndex: childIndex,
})
return
}
const cat = audioTrackById.value.get(cid)
if (cat) {
out.push({
type: 'audio',
audioTrackId: cat.id,
name: cat.name ?? t('timeline.audioTrackName'),
orderIndex: -1,
locked: !!cat.locked,
muted: !!cat.muted,
color: cat.color ?? null,
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') {
if (tr.locked) return false
if (lockedGroupChildIds.has(tr.scriptTrackId)) return false
return true
}
if (tr.type === 'audio') {
if (tr.locked) return false
if (lockedGroupChildIds.has(tr.audioTrackId)) return false
return true
}
// 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' ||
track.type === 'script' ||
track.type === 'audio'
) {
return track.groupChildIndex ?? null
}
return null
}
function trackParentGroupId(track: TimelineTrack): string | null {
if (
track.type === 'layer' ||
track.type === 'clip' ||
track.type === 'script' ||
track.type === 'audio'
) {
return track.parentGroupId ?? null
}
return null
}
function isGroupable(track: TimelineTrack): track is
| TimelineLayerTrack
| TimelineClipTrack
| TimelineScriptTrack
| TimelineAudioTrack {
return (
track.type === 'layer' ||
track.type === 'clip' ||
track.type === 'script' ||
track.type === 'audio'
)
}
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 (isGroupable(track) && 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 (isGroupable(track) && 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)
// Include the playhead as a snap magnet so dragged keyframes
// stick to it the same way they stick to sibling keyframes.
const snappedPrimary = snapToCandidates(
rawNewTime,
[...allKeyframeTimesExcluding(ignoreIds), store.playhead],
{
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 ScriptFrameMenuState {
frameId: string
x: number
y: number
}
const scriptFrameMenu = ref<ScriptFrameMenuState | null>(null)
const scriptFrameMenuRef = ref<HTMLDivElement | null>(null)
function onScriptFrameContextMenu(e: MouseEvent, frameId: string) {
e.preventDefault()
e.stopPropagation()
scriptFrameMenu.value = { frameId, x: e.clientX, y: e.clientY }
}
function duplicateScriptFrameFromMenu() {
const m = scriptFrameMenu.value
if (!m) return
const newId = store.duplicateScriptFrame(m.frameId)
scriptFrameMenu.value = null
if (newId) openScriptFrame(newId)
}
function deleteScriptFrameFromMenu() {
const m = scriptFrameMenu.value
if (!m) return
store.removeScriptFrame(m.frameId)
scriptFrameMenu.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, buttonMode = false): KfSegment[] {
if (kfs.length === 0) return []
const sorted = [...kfs].sort((a, b) => a.time - b.time)
const out: KfSegment[] = []
// Button-state timelines have discrete state frames rather than a
// continuous timeline. A still-frame kf only renders on its own
// state; the layer is NOT held into the following states. The
// connector line must stop at the still, not span past it.
if (buttonMode) {
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
// Stills terminate any interpolation — no connector emerges from
// a hold kf toward the next state.
if (a.hold) continue
out.push({
id: `${a.id}>${b.id}`,
fromTime: a.time,
toTime: b.time,
easing: b.easing,
held: false,
})
}
return out
}
// 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)
// Move the editor onto the slot we just populated, so the stage reflects it.
if (store.activeButtonAssetId) {
if (Math.abs(time - BUTTON_STATE_TIMES.normal) < 1e-3) store.setActiveButtonState('normal')
else if (Math.abs(time - BUTTON_STATE_TIMES.hover) < 1e-3) store.setActiveButtonState('hover')
else if (Math.abs(time - BUTTON_STATE_TIMES.pressed) < 1e-3) store.setActiveButtonState('pressed')
else if (Math.abs(time - BUTTON_STATE_TIMES.disabled) < 1e-3) store.setActiveButtonState('disabled')
}
}
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() {
// Drop the group directly above whatever the user is currently
// working on — layer selection is the only active-track signal
// today. Falls back to the top of the stack when nothing's active.
store.addTrackGroup(undefined, store.selectedLayerId ?? null)
}
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
}
interface ClipInstanceGhost {
targetTrackId: string
startTime: number
length: number
}
/** Hover-preview for dropping a library clip onto a clip lane. Mirrors
* `audioClipGhost`. Cleared whenever the cursor leaves the lane or
* the drop lands. */
const clipInstanceGhost = ref<ClipInstanceGhost | null>(null)
/** Full-window collision: does [start, start+length] overlap any
* existing clip instance on this track? Stricter than a point-in-clip
* check — catches the case where the drop point is free but the
* dropped tail would land on top of a neighbour. Mirrors
* `audioWindowCollides`. */
function clipWindowCollides(
trackId: string,
start: number,
length: number,
): boolean {
const end = start + length
const siblings = clipInstancesByTrack.value[trackId] ?? []
for (const ci of siblings) {
const cEnd = ci.startTime + visibleDurationOf(ci)
if (cEnd > start && ci.startTime < end) return true
}
return false
}
function onClipLaneDragOver(e: DragEvent, clipTrackId: string) {
const types = e.dataTransfer?.types
if (!types?.includes('application/x-animatr-clip')) return
e.preventDefault()
if (!e.dataTransfer) return
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromEvent(e, el)
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
const src = clipDragSource.value
const length = Math.max(0, src?.duration ?? 0)
const collides =
track?.locked || clipWindowCollides(clipTrackId, time, length)
e.dataTransfer.dropEffect = collides ? 'none' : 'copy'
if (!collides && src) {
clipInstanceGhost.value = {
targetTrackId: clipTrackId,
startTime: time,
length,
}
} else {
clipInstanceGhost.value = null
}
}
function onClipLaneDragLeave() {
clipInstanceGhost.value = null
}
function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
e.preventDefault()
clipInstanceGhost.value = null
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
if (track?.locked) {
clearClipDragSource()
return
}
const el = e.currentTarget as HTMLElement
const time = pixelTimeFromEvent(e, el)
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
// The library payload is authoritative for the window check — the
// composable source may have been cleared by a racing dragend.
const clip = clipId ? store.project.clips.find((c) => c.id === clipId) : null
const length = clip ? Math.max(0, clip.duration) : 0
clearClipDragSource()
if (!clipId) return
if (clipWindowCollides(clipTrackId, time, length)) return
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
// Snap the clip's left edge to the playhead (and keyframes) when
// snapping is on. The cascade in setClipInstancePosition still
// runs afterwards, so colliding with a neighbour on the track
// behaves the same as it did before.
const newStart = Math.max(0, snapTime(m.initialStartTime + deltaSec))
store.setClipInstancePosition(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
const instanceId = m.instanceId
clipMove.value = null
if (wasMoved) {
// suppress click/dblclick if we actually dragged
e.preventDefault()
return
}
// Pure click: select the instance so the canvas transformer attaches
// to its wrapper group. Without this, users have no way to grab a
// clip whose current-playhead content happens to be empty.
store.selectClipInstance(instanceId)
}
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`
}
/** Resolved peaks for a clip's asset — undefined while the decoder
* is still working (first render shows an empty block; waveform
* fades in once computation finishes). */
function clipPeaks(clip: AudioClip): Float32Array | undefined {
const asset = audioAssetById.value.get(clip.audioAssetId)
if (!asset || !asset.src) return undefined
const slot = getWaveformSlot(asset.id, asset.src)
if (!slot || slot.state !== 'ready') return undefined
return slicePeaksForClip(slot.peaks, asset.duration, clip.trimStart ?? 0, clip.trimEnd ?? 0)
}
/** Canvas ref-callback: redraw on every Vue update so the bars track
* the clip's current width (resizes on zoom + trim changes). Cheap
* because peaks are cached and the draw itself is a few hundred
* fillRects. */
function waveformColorFor(clip: AudioClip): string | undefined {
// Honor the track color so tinted audio tracks get a matching
// waveform fill. Accept #rgb/#rrggbb and append a fixed 55% alpha
// (matches the default sky-blue tint). Non-hex values fall back to
// the drawWaveform default.
const track = audioTrackById.value.get(clip.audioTrackId)
const color = track?.color
if (!color) return undefined
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(color)
if (short) {
const [, r, g, b] = short
return `#${r}${r}${g}${g}${b}${b}8c`
}
const full = /^#([0-9a-f]{6})$/i.exec(color)
if (full) return `#${full[1]}8c`
return color
}
function renderWaveformCanvas(clip: AudioClip) {
return (raw: Element | ComponentPublicInstance | null) => {
if (!raw || !(raw instanceof HTMLCanvasElement)) return
const el = raw
const peaks = clipPeaks(clip)
if (!peaks) return
const parent = el.parentElement
if (!parent) return
const pr = window.devicePixelRatio || 1
const cssW = Math.max(1, parent.clientWidth - 2)
const cssH = Math.max(1, parent.clientHeight - 4)
const pxW = Math.max(1, Math.round(cssW * pr))
const pxH = Math.max(1, Math.round(cssH * pr))
if (el.width !== pxW) el.width = pxW
if (el.height !== pxH) el.height = pxH
el.style.width = cssW + 'px'
el.style.height = cssH + 'px'
drawWaveform(el, peaks, { pixelRatio: pr, color: waveformColorFor(clip) })
}
}
/** 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)
}
}
interface AudioClipMenuState {
clipId: string
x: number
y: number
}
const audioClipMenu = ref<AudioClipMenuState | null>(null)
const audioClipMenuRef = ref<HTMLDivElement | null>(null)
function onAudioClipContextMenu(e: MouseEvent, clip: AudioClip) {
e.preventDefault()
e.stopPropagation()
audioClipMenu.value = { clipId: clip.id, x: e.clientX, y: e.clientY }
}
function duplicateAudioClipFromMenu() {
const m = audioClipMenu.value
if (!m) return
store.duplicateAudioClip(m.clipId)
audioClipMenu.value = null
}
function deleteAudioClipFromMenu() {
const m = audioClipMenu.value
if (!m) return
store.removeAudioClip(m.clipId)
audioClipMenu.value = null
}
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
}
interface AudioClipTrimState {
clipId: string
side: 'start' | 'end'
pointerId: number
startX: number
initialStartTime: number
initialTrimStart: number
initialTrimEnd: number
}
const audioClipTrim = ref<AudioClipTrimState | null>(null)
function onAudioClipHandlePointerDown(
e: PointerEvent,
clip: AudioClip,
side: 'start' | 'end',
) {
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)
audioClipTrim.value = {
clipId: clip.id,
side,
pointerId: e.pointerId,
startX: e.clientX,
initialStartTime: clip.startTime,
initialTrimStart: clip.trimStart ?? 0,
initialTrimEnd: clip.trimEnd ?? 0,
}
}
function onAudioClipHandlePointerMove(e: PointerEvent) {
const tr = audioClipTrim.value
if (!tr || tr.pointerId !== e.pointerId) return
const pps = effectivePxPerSec.value
if (pps <= 0) return
const rawDelta = (e.clientX - tr.startX) / pps
const clip = store.scene.audioClips?.find((c) => c.id === tr.clipId)
if (!clip) return
const asset = audioAssetById.value.get(clip.audioAssetId)
if (!asset) return
const MIN_VISIBLE = 0.05
const maxBudget = asset.duration - MIN_VISIBLE
if (tr.side === 'start') {
// Clamp locally so overshooting the natural start doesn't slide
// the whole clip — matches the regular-clip handle behavior.
const minDelta = Math.max(-tr.initialTrimStart, -tr.initialStartTime)
const maxDelta = maxBudget - tr.initialTrimStart - tr.initialTrimEnd
const delta = Math.min(Math.max(rawDelta, minDelta), maxDelta)
store.trimAudioClip(tr.clipId, {
trimStart: tr.initialTrimStart + delta,
startTime: tr.initialStartTime + delta,
})
} else {
store.trimAudioClip(tr.clipId, {
trimEnd: tr.initialTrimEnd - rawDelta,
})
}
}
function onAudioClipHandlePointerUp(e: PointerEvent) {
const tr = audioClipTrim.value
if (!tr || tr.pointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
audioClipTrim.value = null
}
// -------- 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 rawDelta = (e.clientX - t.startX) / pps
const inst = store.scene.clipInstances?.find((ci) => ci.id === t.instanceId)
const clip = inst ? store.scene.clips?.find((c) => c.id === inst.clipId) : null
if (!clip) return
if (t.side === 'start') {
// Compute the target left-edge time, snap it to the playhead /
// keyframes, then derive the delta. Clamping below makes sure
// we don't shift startTime past 0 or eat past the clip's
// available budget — same bounds as before.
const targetEdge = t.initialStartTime + rawDelta
const snappedEdge = snapTime(targetEdge)
const snappedDelta = snappedEdge - t.initialStartTime
const MIN_VISIBLE = 0.05
const maxBudget = clip.duration - MIN_VISIBLE
const minDelta = Math.max(-t.initialTrimStart, -t.initialStartTime)
const maxDelta = maxBudget - t.initialTrimStart - t.initialTrimEnd
const delta = Math.min(Math.max(snappedDelta, minDelta), maxDelta)
store.trimClipInstance(t.instanceId, {
trimStart: t.initialTrimStart + delta,
startTime: t.initialStartTime + delta,
})
} else {
// shift right edge right = less trimEnd (reveal more). Snap the
// edge position, then derive trimEnd.
const initialRightEdge = t.initialStartTime + clip.duration - t.initialTrimEnd
const snappedEdge = snapTime(initialRightEdge + rawDelta)
const newTrimEnd = clip.duration - (snappedEdge - t.initialStartTime)
store.trimClipInstance(t.instanceId, {
trimEnd: newTrimEnd,
})
}
}
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)
}
interface ClipInstanceMenuState {
instanceId: string
x: number
y: number
}
const clipInstanceMenu = ref<ClipInstanceMenuState | null>(null)
const clipInstanceMenuRef = ref<HTMLDivElement | null>(null)
function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
e.preventDefault()
e.stopPropagation()
clipInstanceMenu.value = { instanceId: ci.id, x: e.clientX, y: e.clientY }
}
function duplicateClipInstanceFromMenu() {
const m = clipInstanceMenu.value
if (!m) return
store.duplicateClipInstance(m.instanceId)
clipInstanceMenu.value = null
}
function deleteClipInstanceFromMenu() {
const m = clipInstanceMenu.value
if (!m) return
store.removeClipInstance(m.instanceId)
clipInstanceMenu.value = null
}
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
}
/** True only for a context-menu tied to an SVG-backed image layer.
* Gates the "Split SVG" entry so the menu doesn't show a dead action
* on raster layers. */
const canSplitSvgFromMenu = computed(() => {
const m = trackMenu.value
if (!m || m.kind !== 'layer') return false
const layer = store.scene.layers.find((l) => l.id === m.trackId)
if (!layer) return false
const asset = store.scene.assets.find((a) => a.id === layer.assetId)
if (!asset || asset.kind !== 'image') return false
return asset.src.startsWith('data:image/svg+xml')
})
function splitSvgFromMenu() {
const m = trackMenu.value
if (!m || m.kind !== 'layer') return
store.splitLayerSvg(m.trackId)
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 {
// Group: delete the group AND every track inside it.
store.removeTrackGroupWithChildren(trackMenu.value.trackId)
}
trackMenu.value = null
}
function ungroupFromMenu() {
if (!trackMenu.value || trackMenu.value.kind !== 'group') return
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 === 'layer') {
const layer = store.activeLayers.find((l) => l.id === trackMenu.value!.trackId)
if (!layer) return
const id = trackMenu.value.trackId
const current = layer.name
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameLayerDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameLayerDialog.confirm'),
})
if (next === null) return
store.setLayerName(id, next)
return
}
if (trackMenu.value.kind === 'clip') {
const track = store.scene.clipTracks?.find((t) => t.id === trackMenu.value!.trackId)
if (!track) return
const id = trackMenu.value.trackId
const current = track.name ?? t('timeline.clipTrackName')
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameClipTrackDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameClipTrackDialog.confirm'),
})
if (next === null) return
store.setClipTrackName(id, next)
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
}
if (scriptFrameMenu.value && scriptFrameMenuRef.value && e.target instanceof Node) {
if (!scriptFrameMenuRef.value.contains(e.target)) scriptFrameMenu.value = null
} else if (scriptFrameMenu.value) {
scriptFrameMenu.value = null
}
if (clipInstanceMenu.value && clipInstanceMenuRef.value && e.target instanceof Node) {
if (!clipInstanceMenuRef.value.contains(e.target)) clipInstanceMenu.value = null
} else if (clipInstanceMenu.value) {
clipInstanceMenu.value = null
}
if (audioClipMenu.value && audioClipMenuRef.value && e.target instanceof Node) {
if (!audioClipMenuRef.value.contains(e.target)) audioClipMenu.value = null
} else if (audioClipMenu.value) {
audioClipMenu.value = null
}
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
menu.value = null
trackMenu.value = null
scriptFrameMenu.value = null
clipInstanceMenu.value = null
audioClipMenu.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, scriptFrameMenu, clipInstanceMenu, audioClipMenu],
([m, tm, sm, cim, acm]) => {
if (m || tm || sm || cim || acm) {
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)
})
watch(scriptFrameMenu, (sm) => {
if (!sm) return
clampMenuIntoViewport(() => scriptFrameMenuRef.value, sm.x, sm.y)
})
watch(clipInstanceMenu, (cim) => {
if (!cim) return
clampMenuIntoViewport(() => clipInstanceMenuRef.value, cim.x, cim.y)
})
watch(audioClipMenu, (acm) => {
if (!acm) return
clampMenuIntoViewport(() => audioClipMenuRef.value, acm.x, acm.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, !!store.activeButtonAssetId)"
: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
class="eye-btn"
:class="{ hidden: !!clipTrackById.get(track.clipTrackId)?.editorHidden }"
v-tooltip="clipTrackById.get(track.clipTrackId)?.editorHidden ? t('timeline.showInEditor') : t('timeline.hideInEditor')"
@click.stop="
store.setClipTrackEditorHidden(
track.clipTrackId,
!clipTrackById.get(track.clipTrackId)?.editorHidden,
)
"
@mousedown.stop
@dragstart.prevent.stop
>
<component
:is="clipTrackById.get(track.clipTrackId)?.editorHidden ? PhEyeSlash : PhEye"
:size="12"
/>
</button>
<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)"
@dragleave.stop="onClipLaneDragLeave"
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-if="clipInstanceGhost && clipInstanceGhost.targetTrackId === track.clipTrackId"
class="clip-instance-ghost"
:style="{
left: pxFromTime(clipInstanceGhost.startTime) + 'px',
width: Math.max(6, pxFromTime(clipInstanceGhost.length)) + 'px',
}"
></span>
<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,
'in-group': track.inGroup,
'sub-track': track.inGroup,
}"
:style="trackColorStyle(track.color)"
@dragover="
!track.locked
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
: onTrackDragOver($event, track.orderIndex))
: null
"
@drop="
!track.locked
? (track.inGroup ? onGroupChildDrop($event) : 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"
@contextmenu="!track.locked ? onScriptFrameContextMenu($event, frame.id) : null"
>
<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="{
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: dragTrackId === track.audioTrackId,
colored: !!track.color,
locked: track.locked,
muted: track.muted,
'in-group': track.inGroup,
'sub-track': track.inGroup,
}"
:style="trackColorStyle(track.color)"
:data-audio-track-id="track.audioTrackId"
@dragover="
!track.locked
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
: onTrackDragOver($event, track.orderIndex))
: null
"
@drop="
!track.locked
? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
: null
"
@dragleave="onTrackDragLeave"
>
<span
class="track-name audio-track-name"
:draggable="!track.locked"
@contextmenu="!track.locked ? onAudioTrackContextMenu($event, track.audioTrackId) : null"
@dragstart="!track.locked ? onTrackDragStart($event, track.audioTrackId) : null"
@dragend="onTrackDragEnd"
>
<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,
'trimmed-start': (clip.trimStart ?? 0) > 0,
'trimmed-end': (clip.trimEnd ?? 0) > 0,
trimming: audioClipTrim?.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="clip-handle start"
draggable="false"
@pointerdown="onAudioClipHandlePointerDown($event, clip, 'start')"
@pointermove="onAudioClipHandlePointerMove"
@pointerup="onAudioClipHandlePointerUp"
@pointercancel="onAudioClipHandlePointerUp"
@dragstart.prevent.stop
@dblclick.stop
@click.stop
/>
<canvas
v-if="clipPeaks(clip)"
class="audio-clip-wave"
:ref="renderWaveformCanvas(clip)"
/>
<span class="audio-clip-label">
{{ audioAssetById.get(clip.audioAssetId)?.name ?? '—' }}
</span>
<span
class="clip-handle end"
draggable="false"
@pointerdown="onAudioClipHandlePointerDown($event, clip, 'end')"
@pointermove="onAudioClipHandlePointerMove"
@pointerup="onAudioClipHandlePointerUp"
@pointercancel="onAudioClipHandlePointerUp"
@dragstart.prevent.stop
@dblclick.stop
@click.stop
/>
</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="eye-btn"
:class="{ hidden: !!track.editorHidden }"
v-tooltip="track.editorHidden ? t('timeline.showInEditor') : t('timeline.hideInEditor')"
@click.stop="store.setGroupEditorHidden(track.groupId, !track.editorHidden)"
@mousedown.stop
@dragstart.prevent.stop
>
<component :is="track.editorHidden ? PhEyeSlash : PhEye" :size="12" />
</button>
<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>
<button class="kf-menu-item" @click="ungroupFromMenu">
<PhFolderMinus :size="12" class="kf-menu-icon" />
{{ t('timeline.ungroup') }}
</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 === 'clip'">
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="renameFromMenu">
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
{{ t('timeline.renameClipTrack') }}
</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" @click="renameFromMenu">
<PhPencilSimpleLine :size="12" class="kf-menu-icon" />
{{ t('timeline.renameLayer') }}
</button>
<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>
<button
v-if="canSplitSvgFromMenu"
class="kf-menu-item"
:disabled="trackMenu.locked"
@click="splitSvgFromMenu"
>
<PhScissors :size="12" class="kf-menu-icon" />
{{ t('timeline.splitSvg') }}
</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.deleteGroup')
}}
</button>
</div>
<div
v-if="scriptFrameMenu"
ref="scriptFrameMenuRef"
class="kf-menu"
:style="{ left: scriptFrameMenu.x + 'px', top: scriptFrameMenu.y + 'px' }"
>
<button class="kf-menu-item" @click="duplicateScriptFrameFromMenu">
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteScriptFrameFromMenu">
<PhTrash :size="12" class="kf-menu-icon" />
{{ t('timeline.delete') }}
</button>
</div>
<div
v-if="clipInstanceMenu"
ref="clipInstanceMenuRef"
class="kf-menu"
:style="{ left: clipInstanceMenu.x + 'px', top: clipInstanceMenu.y + 'px' }"
>
<button class="kf-menu-item" @click="duplicateClipInstanceFromMenu">
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteClipInstanceFromMenu">
<PhTrash :size="12" class="kf-menu-icon" />
{{ t('timeline.delete') }}
</button>
</div>
<div
v-if="audioClipMenu"
ref="audioClipMenuRef"
class="kf-menu"
:style="{ left: audioClipMenu.x + 'px', top: audioClipMenu.y + 'px' }"
>
<button class="kf-menu-item" @click="duplicateAudioClipFromMenu">
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteAudioClipFromMenu">
<PhTrash :size="12" class="kf-menu-icon" />
{{ t('timeline.delete') }}
</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;
/* Softer-than-the-other-toolbar-buttons treatment: no border, round
* hit-area, accent-filled on hover — matches the sidebar's section
* `+` buttons so the two "create new thing" affordances read the
* same visually. */
border: none;
border-radius: 50%;
background: transparent;
}
.snap-btn.add-kf-btn:hover:not(:disabled) {
background: var(--accent);
color: #fff;
border-color: transparent;
}
.snap-btn.add-kf-btn.active {
background: var(--accent);
color: #fff;
border-color: transparent;
}
.snap-btn.add-kf-btn:disabled {
background: transparent;
}
.mode-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg);
}
.mode-btn {
/* Match the height of the sibling .snap-btn icons so the timeline
* toolbar sits on one visual baseline — the wrapper .mode-toggle's
* 1px border is already accounted for by `box-sizing: border-box`
* higher up, so the inner 20px hit area stays flush with neighbours. */
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
padding: 0 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,
.track.locked .track-name .eye-btn {
/* Keep the eye toggle clickable on locked tracks — muting a layer
in the editor is orthogonal to locking it, and asking the user
to unlock first just to toggle visibility is annoying. */
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;
}
.clip-lane {
position: relative;
}
/* ---- Script track ---- */
.track.script-track {
height: 30px;
}
.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: var(--track-color, #f59e0b);
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.35);
}
.script-frame-count {
font-size: 9px;
font-weight: 600;
color: var(--track-color, #f59e0b);
background: color-mix(in srgb, var(--track-color, #f59e0b) 18%, transparent);
border-radius: 999px;
padding: 0 5px;
line-height: 13px;
min-width: 13px;
text-align: center;
}
/* ---- Audio track ---- */
.track.audio-track {
height: 30px;
}
.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,
color-mix(in srgb, var(--track-color, #0ea5e9) 35%, transparent) 0%,
color-mix(in srgb, var(--track-color, #0ea5e9) 15%, transparent) 100%
);
border: 1px solid color-mix(in srgb, var(--track-color, #0ea5e9) 60%, transparent);
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-wave {
position: absolute;
left: 1px;
right: 1px;
top: 2px;
pointer-events: none;
opacity: 0.85;
}
.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,
.clip-instance-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 {
position: relative;
font-size: 10px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
/* Tiny shadow keeps the text legible over the waveform fill. */
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
z-index: 1;
}
.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,
.audio-clip-block:hover .clip-handle {
opacity: 1;
}
.clip-block.trimmed-start .clip-handle.start,
.clip-block.trimmed-end .clip-handle.end,
.audio-clip-block.trimmed-start .clip-handle.start,
.audio-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,
.audio-clip-block.trimmed-start .clip-handle.start::after,
.audio-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,
.audio-clip-block.trimming .clip-handle {
opacity: 1;
}
.clip-block.trimming,
.audio-clip-block.trimming {
box-shadow: 0 0 0 1px #ffc857;
}
.track.audio-track.locked .clip-handle {
display: none;
}
.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>