This commit is contained in:
th committed 2026-04-20 00:29:12 +02:00
1 parent 3ef5fb5a2d
commit 43933f93d1
11 files changed
+813 -76

No files matched your search

+444 -60
View File
@@ -2,9 +2,13 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
PhArrowsSplit,
PhCaretDown,
PhCaretRight,
PhCheck,
PhCopy,
PhDotsSixVertical,
PhFolder,
PhFolderPlus,
PhLock,
PhLockOpen,
PhMagnet,
@@ -14,7 +18,7 @@ import {
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog } from '@/components/ui'
import { confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
import type {
@@ -24,6 +28,7 @@ import type {
Easing,
Keyframe,
Layer,
TrackGroup,
} from '@/types/scene'
interface TimelineLayerTrack {
@@ -35,6 +40,7 @@ interface TimelineLayerTrack {
label: string
isParent: boolean
orderIndex: number
inGroup: boolean
}
interface TimelineClipTrack {
@@ -42,9 +48,20 @@ interface TimelineClipTrack {
clipTrackId: string
name: string
orderIndex: number
inGroup: boolean
}
type TimelineTrack = TimelineLayerTrack | TimelineClipTrack
interface TimelineGroupTrack {
type: 'group'
groupId: string
name: string
collapsed: boolean
color: string | null
orderIndex: number
childCount: number
}
type TimelineTrack = TimelineLayerTrack | TimelineClipTrack | TimelineGroupTrack
const store = useSceneStore()
@@ -67,8 +84,74 @@ const clipTrackById = computed(() => {
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 NAME_WIDTH = 140
const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth'
const TRACK_NAME_WIDTH_DEFAULT = 140
const TRACK_NAME_WIDTH_MIN = 80
const TRACK_NAME_WIDTH_MAX = 400
function readStoredTrackNameWidth(): number {
try {
const raw = localStorage.getItem(TRACK_NAME_WIDTH_KEY)
if (raw === null) return TRACK_NAME_WIDTH_DEFAULT
const n = Number.parseInt(raw, 10)
if (Number.isFinite(n) && n >= TRACK_NAME_WIDTH_MIN) return Math.min(n, TRACK_NAME_WIDTH_MAX)
} catch {
/* ignore */
}
return TRACK_NAME_WIDTH_DEFAULT
}
const trackNameWidth = ref(readStoredTrackNameWidth())
const timelineRootStyle = computed(() => ({
'--track-name-width': `${trackNameWidth.value}px`,
}))
let nameResizeStartX = 0
let nameResizeStartW = 0
let nameResizePointerId = -1
const nameResizing = ref(false)
function onNameResizeStart(e: PointerEvent) {
if (e.button !== 0) return
e.preventDefault()
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
nameResizePointerId = e.pointerId
nameResizeStartX = e.clientX
nameResizeStartW = trackNameWidth.value
nameResizing.value = true
document.body.style.userSelect = 'none'
}
function onNameResizeMove(e: PointerEvent) {
if (!nameResizing.value || e.pointerId !== nameResizePointerId) return
const dx = e.clientX - nameResizeStartX
trackNameWidth.value = Math.max(
TRACK_NAME_WIDTH_MIN,
Math.min(TRACK_NAME_WIDTH_MAX, nameResizeStartW + dx),
)
}
function onNameResizeEnd(e: PointerEvent) {
if (!nameResizing.value || e.pointerId !== nameResizePointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
nameResizePointerId = -1
nameResizing.value = false
document.body.style.userSelect = ''
try {
localStorage.setItem(TRACK_NAME_WIDTH_KEY, String(trackNameWidth.value))
} catch {
/* ignore */
}
}
const MIN_PX_PER_SEC = 2
const MAX_PX_PER_SEC = 2000
@@ -82,7 +165,7 @@ function clamp(v: number, lo: number, hi: number) {
}
const fitPxPerSec = computed(() => {
const avail = Math.max(60, viewportWidth.value - NAME_WIDTH)
const avail = Math.max(60, viewportWidth.value - trackNameWidth.value)
const d = Math.max(0.1, duration.value)
return avail / d
})
@@ -93,7 +176,7 @@ const effectivePxPerSec = computed(() => {
})
const laneWidth = computed(() => duration.value * effectivePxPerSec.value)
const totalWidth = computed(() => NAME_WIDTH + laneWidth.value)
const totalWidth = computed(() => trackNameWidth.value + laneWidth.value)
type DisplayMode = 'seconds' | 'frames'
const displayMode = ref<DisplayMode>('seconds')
@@ -257,18 +340,21 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
store.removeKeyframe(layerId, keyframeId)
}
function emitLayerTrack(layer: Layer, orderIndex: number, inGroup: boolean, out: TimelineTrack[]) {
if (!layer.separated) {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex, inGroup })
} else {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex, inGroup })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex, inGroup })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex, inGroup })
}
}
const tracks = computed<TimelineTrack[]>(() => {
const out: TimelineTrack[] = []
if (store.activeClipId) {
// Clip-edit scope: only show clip's layers (no clip tracks inside a clip)
store.activeLayers.forEach((layer, orderIndex) => {
if (!layer.separated) {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
} else {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex })
}
emitLayerTrack(layer, orderIndex, false, out)
})
return out
}
@@ -278,30 +364,52 @@ const tracks = computed<TimelineTrack[]>(() => {
order.forEach((id, orderIndex) => {
const layer = layerById.get(id)
if (layer) {
if (!layer.separated) {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
} else {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex })
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex })
}
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 })
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? t('timeline.clipTrackName'), orderIndex, inGroup: false })
return
}
const grp = groupById.value.get(id)
if (grp) {
out.push({
type: 'group',
groupId: grp.id,
name: grp.name,
collapsed: !!grp.collapsed,
color: grp.color ?? null,
orderIndex,
childCount: grp.childIds.length,
})
if (!grp.collapsed) {
for (const cid of grp.childIds) {
const cl = layerById.get(cid)
if (cl) {
emitLayerTrack(cl, -1, true, out)
continue
}
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 })
}
}
}
}
})
return out
})
function trackDndId(track: TimelineTrack): string {
return track.type === 'layer' ? track.layerId : track.clipTrackId
if (track.type === 'layer') return track.layerId
if (track.type === 'clip') return track.clipTrackId
return track.groupId
}
function isTrackHead(track: TimelineTrack): boolean {
if (track.type === 'clip') return true
return track.isParent
if (track.type === 'layer') return track.isParent
return true
}
function isDropBefore(track: TimelineTrack): boolean {
@@ -595,7 +703,7 @@ function duplicateFromMenu() {
}
interface TrackMenuState {
kind: 'layer' | 'clip'
kind: 'layer' | 'clip' | 'group'
trackId: string
x: number
y: number
@@ -664,6 +772,59 @@ function onTrackDrop(e: DragEvent) {
function onTrackDragEnd() {
dragTrackId.value = null
dropIndex.value = null
dropGroupId.value = null
}
const dropGroupId = ref<string | null>(null)
function onGroupBodyDragOver(e: DragEvent, groupId: string) {
if (!dragTrackId.value) return
// A group can't be moved into itself
if (dragTrackId.value === groupId) return
// A group itself can't be moved into another group (keep groups top-level)
if (groupById.value.has(dragTrackId.value)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
dropGroupId.value = groupId
dropIndex.value = null
}
function onGroupBodyDragLeave() {
// kept across frames; cleared on drop or dragend
}
function onGroupBodyDrop(e: DragEvent, groupId: string) {
e.preventDefault()
const srcId = dragTrackId.value
dragTrackId.value = null
dropIndex.value = null
dropGroupId.value = null
if (!srcId) return
if (srcId === groupId) return
if (groupById.value.has(srcId)) return
store.moveTrackIntoGroup(srcId, groupId)
}
function toggleGroupCollapsed(groupId: string) {
store.toggleGroupCollapsed(groupId)
}
function addGroup() {
store.addTrackGroup()
}
async function renameGroupPrompt(groupId: string, current: string) {
const next = await promptDialog({
title: t('timeline.renameGroupDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameGroupDialog.confirm'),
})
if (next === null) return
store.renameGroup(groupId, next)
}
async function ungroup(groupId: string) {
store.removeTrackGroup(groupId)
}
interface BoxSelectState {
@@ -894,7 +1055,7 @@ 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 = 140
const NAME_COL = trackNameWidth.value
const laneLeft = rect.left + NAME_COL
const laneWidth = rect.width - NAME_COL
if (laneWidth <= 0) return 0
@@ -1031,6 +1192,22 @@ function onTrackContextMenu(e: MouseEvent, layerId: string) {
}
}
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()
@@ -1074,12 +1251,31 @@ function deleteTrack() {
if (!trackMenu.value) return
if (trackMenu.value.kind === 'layer') {
store.removeLayer(trackMenu.value.trackId)
} else {
} else if (trackMenu.value.kind === 'clip') {
store.removeClipTrack(trackMenu.value.trackId)
} else {
store.removeTrackGroup(trackMenu.value.trackId)
}
trackMenu.value = null
}
async function renameFromMenu() {
if (!trackMenu.value) return
if (trackMenu.value.kind !== 'group') return
const group = groupById.value.get(trackMenu.value.trackId)
if (!group) return
const id = trackMenu.value.trackId
const current = group.name
trackMenu.value = null
const next = await promptDialog({
title: t('timeline.renameGroupDialog.title'),
defaultValue: current,
confirmLabel: t('timeline.renameGroupDialog.confirm'),
})
if (next === null) return
store.renameGroup(id, next)
}
function onDocumentPointerDown(e: PointerEvent) {
if (menu.value && menuRef.value && e.target instanceof Node) {
if (!menuRef.value.contains(e.target)) menu.value = null
@@ -1152,7 +1348,7 @@ function onWheel(e: WheelEvent) {
e.preventDefault()
const rect = scrollEl.value.getBoundingClientRect()
const cursorInViewport = e.clientX - rect.left
const laneCursor = cursorInViewport - NAME_WIDTH + scrollEl.value.scrollLeft
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)
@@ -1162,7 +1358,7 @@ function onWheel(e: WheelEvent) {
requestAnimationFrame(() => {
if (!scrollEl.value) return
const targetLane = timeAtCursor * newPps
scrollEl.value.scrollLeft = Math.max(0, targetLane - (cursorInViewport - NAME_WIDTH))
scrollEl.value.scrollLeft = Math.max(0, targetLane - (cursorInViewport - trackNameWidth.value))
})
return
}
@@ -1194,7 +1390,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="timeline">
<div class="timeline" :style="timelineRootStyle">
<div ref="scrollEl" class="tl-scroll" @wheel="onWheel">
<div class="tl-content" :style="{ width: totalWidth + 'px' }">
<div class="scrub-bar">
@@ -1219,6 +1415,14 @@ onBeforeUnmount(() => {
>
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
</button>
<button
v-if="!inClipEdit"
class="snap-btn"
:title="t('timeline.addGroupTooltip')"
@click="addGroup"
>
<PhFolderPlus :size="12" />
</button>
</div>
<div
class="scrub-ruler"
@@ -1252,7 +1456,13 @@ onBeforeUnmount(() => {
@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}` : `c:${track.clipTrackId}`">
<template v-for="(track, idx) in tracks" :key="
track.type === 'layer'
? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}`
: track.type === 'clip'
? `c:${track.clipTrackId}:${track.inGroup ? 'g' : 't'}`
: `gr:${track.groupId}`
">
<!-- Layer track -->
<div
v-if="track.type === 'layer'"
@@ -1260,15 +1470,16 @@ onBeforeUnmount(() => {
:class="{
'sub-track': !track.isParent,
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
'drop-before': !track.inGroup && isDropBefore(track),
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
dragging: track.isParent && dragTrackId === track.layerId,
colored: !!track.layer.color,
locked: !!track.layer.locked,
'in-group': track.inGroup,
}"
:style="trackColorStyle(track.layer.color)"
@dragover="track.isParent ? onTrackDragOver($event, track.orderIndex) : null"
@drop="track.isParent ? onTrackDrop($event) : null"
@dragover="track.isParent && !track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
@drop="track.isParent && !track.inGroup ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave"
>
<span
@@ -1342,18 +1553,19 @@ onBeforeUnmount(() => {
<!-- Clip track -->
<div
v-else
v-else-if="track.type === 'clip'"
class="track clip-track"
:class="{
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
'drop-before': !track.inGroup && isDropBefore(track),
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
dragging: dragTrackId === track.clipTrackId,
colored: !!clipTrackById.get(track.clipTrackId)?.color,
locked: !!clipTrackById.get(track.clipTrackId)?.locked,
'in-group': track.inGroup,
}"
:style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)"
@dragover="onTrackDragOver($event, track.orderIndex)"
@drop="onTrackDrop($event)"
@dragover="!track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
@drop="!track.inGroup ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave"
>
<span
@@ -1426,6 +1638,56 @@ onBeforeUnmount(() => {
</span>
</div>
</div>
<!-- Group header -->
<div
v-else
class="track group-track"
:class="{
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: dragTrackId === track.groupId,
colored: !!track.color,
'drop-into': dropGroupId === track.groupId,
collapsed: track.collapsed,
}"
:style="trackColorStyle(track.color)"
@dragover="onTrackDragOver($event, track.orderIndex)"
@drop="onTrackDrop($event)"
@dragleave="onTrackDragLeave"
>
<span
class="track-name group-track-name"
draggable="true"
@click="toggleGroupCollapsed(track.groupId)"
@dblclick="renameGroupPrompt(track.groupId, track.name)"
@contextmenu="onGroupContextMenu($event, track.groupId)"
@dragstart="onTrackDragStart($event, track.groupId)"
@dragend="onTrackDragEnd"
>
<PhDotsSixVertical :size="12" class="drag-handle" />
<component
:is="track.collapsed ? PhCaretRight : PhCaretDown"
:size="11"
class="group-caret"
/>
<PhFolder :size="12" class="name-badge" />
{{ track.name }}
<span class="group-count">{{ t('timeline.groupChildCount', { n: track.childCount }) }}</span>
</span>
<div
class="track-lane group-lane"
:class="{ 'drop-into': dropGroupId === track.groupId }"
:style="{ width: laneWidth + 'px' }"
@dragover="onGroupBodyDragOver($event, track.groupId)"
@drop="onGroupBodyDrop($event, track.groupId)"
@dragleave="onGroupBodyDragLeave"
>
<span v-if="track.collapsed && track.childCount === 0" class="group-empty-hint">
{{ t('timeline.groupEmpty') }}
</span>
</div>
</div>
</template>
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
<div
@@ -1443,6 +1705,16 @@ onBeforeUnmount(() => {
</div>
</div>
<div
class="name-col-resize"
:class="{ resizing: nameResizing }"
:style="{ left: trackNameWidth + 'px' }"
:title="t('timeline.resizeTrackColumn')"
@pointerdown="onNameResizeStart"
@pointermove="onNameResizeMove"
@pointerup="onNameResizeEnd"
@pointercancel="onNameResizeEnd"
></div>
<div
v-if="menu"
@@ -1525,32 +1797,48 @@ onBeforeUnmount(() => {
</button>
</div>
</div>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="toggleLocked">
<span class="check">
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
</span>
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" class="kf-menu-icon" />
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
</button>
<template v-if="trackMenu.kind === 'layer'">
<button
class="kf-menu-item"
:disabled="trackMenu.locked"
@click="toggleSeparated"
>
<span class="check">
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
</span>
<PhArrowsSplit :size="12" class="kf-menu-icon" />
{{ t('timeline.separate') }}
<template v-if="trackMenu.kind === 'group'">
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="renameFromMenu">
<span class="check"></span>
<PhArrowsSplit :size="12" class="kf-menu-icon" style="visibility: hidden" />
{{ t('timeline.renameGroup') }}
</button>
</template>
<template v-else>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="toggleLocked">
<span class="check">
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
</span>
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" class="kf-menu-icon" />
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
</button>
<template v-if="trackMenu.kind === 'layer'">
<button
class="kf-menu-item"
:disabled="trackMenu.locked"
@click="toggleSeparated"
>
<span class="check">
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
</span>
<PhArrowsSplit :size="12" class="kf-menu-icon" />
{{ t('timeline.separate') }}
</button>
</template>
</template>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteTrack">
<span class="check"></span>
<PhTrash :size="12" class="kf-menu-icon" />
{{ trackMenu.kind === 'layer' ? t('timeline.deleteLayer') : t('timeline.deleteClipTrack') }}
{{
trackMenu.kind === 'layer'
? t('timeline.deleteLayer')
: trackMenu.kind === 'clip'
? t('timeline.deleteClipTrack')
: t('timeline.ungroup')
}}
</button>
</div>
</div>
@@ -1564,6 +1852,37 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.name-col-resize {
position: absolute;
top: 0;
bottom: 0;
width: 8px;
transform: translateX(-4px);
cursor: ew-resize;
z-index: 15;
touch-action: none;
}
.name-col-resize::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 4px;
width: 1px;
background: transparent;
transition: background 120ms var(--ease);
}
.name-col-resize:hover::after,
.name-col-resize.resizing::after {
background: var(--accent);
}
.name-col-resize.resizing {
background: rgba(110, 168, 255, 0.08);
}
.tl-scroll {
width: 100%;
height: 100%;
@@ -2273,6 +2592,71 @@ onBeforeUnmount(() => {
opacity: 1 !important;
}
.track.group-track {
height: 28px;
background: linear-gradient(
to bottom,
rgba(110, 168, 255, 0.05) 0%,
rgba(110, 168, 255, 0.02) 100%
);
font-weight: 500;
}
.track.group-track .track-name {
background: var(--panel-alt);
font-size: 12px;
color: var(--text);
gap: 4px;
cursor: pointer;
}
.track.group-track .track-name:hover {
background: var(--panel-hover);
}
.group-caret {
color: var(--text-dim);
flex-shrink: 0;
}
.group-count {
margin-left: auto;
font-size: 10px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.group-lane {
background: transparent;
pointer-events: auto;
position: relative;
}
.track.group-track.drop-into > .track-name,
.track.group-track .group-lane.drop-into {
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent);
}
.group-empty-hint {
position: sticky;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: var(--text-dim);
opacity: 0.6;
font-style: italic;
}
.track.in-group .track-name {
padding-left: 22px;
}
.track.in-group.sub-track .track-name {
padding-left: 34px;
}
.clip-track-dropzone {
height: 26px;
display: flex;