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

+3 -1
View File
@@ -8,6 +8,7 @@ import { loadImage } from '@/lib/fileLoader'
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
import { expandedTrackOrder } from '@/lib/sceneOrder'
const store = useSceneStore()
const { selectedKfs } = useKfSelection()
@@ -182,7 +183,8 @@ const stackItems = computed<StackItem[]>(() => {
instancesByTrack.set(ci.clipTrackId, arr)
}
const order = store.scene.trackOrder ?? store.scene.layers.map((l) => l.id)
const expanded = expandedTrackOrder(store.scene)
const order = expanded.length ? expanded : store.scene.layers.map((l) => l.id)
// iterate bottom → top: reverse the array
for (let i = order.length - 1; i >= 0; i--) {
const id = order[i]
+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;
+200
View File
@@ -12,6 +12,7 @@ import { createHistory } from '@/composables/useHistory'
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
import { APP_NAME, APP_VERSION } from '@/version'
import { drawSceneFrame } from '@/lib/render'
import { expandedTrackOrder } from '@/lib/sceneOrder'
import {
deleteStoredProject,
getStoredProject,
@@ -1221,6 +1222,200 @@ export function registerAllTests(): void {
})
})
suite('scene store — track groups', () => {
test('addTrackGroup creates an empty group at the top of trackOrder', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'G1', width: 10, height: 10, fill: '#111' })
s.addLayer(a.id)
const g = s.addTrackGroup('My group')
expect(s.scene.groups).toHaveLength(1)
expect(g.childIds).toHaveLength(0)
expect(s.scene.trackOrder![0]).toBe(g.id)
})
test('moveTrackIntoGroup moves track from top-level to group childIds', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'G2', width: 10, height: 10, fill: '#222' })
const l = s.addLayer(a.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
expect(g.childIds).toContain(l.id)
expect((s.scene.trackOrder ?? []).includes(l.id)).toBe(false)
})
test('moveTrackOutOfGroup promotes track back to top-level', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'G3', width: 10, height: 10, fill: '#333' })
const l = s.addLayer(a.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.moveTrackOutOfGroup(l.id)
expect(g.childIds.includes(l.id)).toBe(false)
expect(s.scene.trackOrder).toContain(l.id)
})
test('removeTrackGroup promotes children back to top-level', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'G4', width: 10, height: 10, fill: '#444' })
const l = s.addLayer(a.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.removeTrackGroup(g.id)
expect(s.scene.groups).toHaveLength(0)
expect(s.scene.trackOrder).toContain(l.id)
})
test('toggleGroupCollapsed flips the collapsed flag', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('G')
expect(!!g.collapsed).toBe(false)
s.toggleGroupCollapsed(g.id)
expect(g.collapsed).toBe(true)
s.toggleGroupCollapsed(g.id)
expect(g.collapsed).toBe(false)
})
test('renameGroup updates the name', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('Old')
s.renameGroup(g.id, 'New')
expect(g.name).toBe('New')
})
test('a track can only be in one group at a time', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#555' })
const l = s.addLayer(a.id)
const g1 = s.addTrackGroup('A')
const g2 = s.addTrackGroup('B')
s.moveTrackIntoGroup(l.id, g1.id)
s.moveTrackIntoGroup(l.id, g2.id)
expect(g1.childIds.includes(l.id)).toBe(false)
expect(g2.childIds).toContain(l.id)
})
test('expandedTrackOrder expands group IDs to their childIds inline', () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#abc' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#def' })
const l1 = s.addLayer(a1.id)
const l2 = s.addLayer(a2.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id)
// scene.trackOrder: [group, l2] (l1 is inside group)
// expanded: [l1, l2]
const expanded = expandedTrackOrder(s.scene)
expect(expanded).toEqual([l1.id, l2.id])
})
test('moveTrackIntoGroup respects atIndex for insertion', () => {
const s = makeFreshStore()
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#111' })
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#222' })
const a3 = s.addAsset({ kind: 'rect', name: 'C', width: 10, height: 10, fill: '#333' })
const l1 = s.addLayer(a1.id)
const l2 = s.addLayer(a2.id)
const l3 = s.addLayer(a3.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id) // [l1]
s.moveTrackIntoGroup(l2.id, g.id) // [l1, l2]
s.moveTrackIntoGroup(l3.id, g.id, 1) // [l1, l3, l2]
expect(g.childIds).toEqual([l1.id, l3.id, l2.id])
})
test('removeTrackGroup inserts children at the former group position', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#aaa' })
const top1 = s.addLayer(a.id)
const top2 = s.addLayer(a.id)
const childA = s.addLayer(a.id)
const childB = s.addLayer(a.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(childA.id, g.id)
s.moveTrackIntoGroup(childB.id, g.id)
// trackOrder currently: [childB? no, children are removed from top-level]
// trackOrder contains: [g, top2, top1] (newest addLayer unshifts)
// Wait: addLayer unshifts; the creation order is top2(later), then top1(earlier)
// Then addLayer(childA), childB — all unshifted. Then moved into group.
// Let's just position group explicitly to be sure:
const order = s.scene.trackOrder ?? []
const gIdx = order.indexOf(g.id)
expect(gIdx).toBeGreaterThan(-1)
s.removeTrackGroup(g.id)
const newOrder = s.scene.trackOrder ?? []
// children should now sit at the former group position
expect(newOrder[gIdx]).toBe(childA.id)
expect(newOrder[gIdx + 1]).toBe(childB.id)
void top1
void top2
})
test('renameGroup ignores empty / whitespace names', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('Original')
s.renameGroup(g.id, '')
expect(g.name).toBe('Original')
s.renameGroup(g.id, ' ')
expect(g.name).toBe('Original')
s.renameGroup(g.id, ' Renamed ')
expect(g.name).toBe('Renamed')
})
test('removeTrackGroup on unknown id is a no-op', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('G')
const beforeCount = (s.scene.groups ?? []).length
s.removeTrackGroup('does-not-exist')
expect((s.scene.groups ?? []).length).toBe(beforeCount)
// The real group still exists
expect(s.scene.groups?.find((x) => x.id === g.id)).toBeTruthy()
})
test('setTrackColor applies to groups too', () => {
const s = makeFreshStore()
const g = s.addTrackGroup('Col')
s.setTrackColor(g.id, '#ff0000')
expect(g.color).toBe('#ff0000')
s.setTrackColor(g.id, null)
expect(g.color).toBe(undefined)
})
test('moveTrackIntoGroup with same group twice moves within group', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#555' })
const l1 = s.addLayer(a.id)
const l2 = s.addLayer(a.id)
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id)
s.moveTrackIntoGroup(l2.id, g.id)
// l1 currently at index 0. Move l1 to index 1.
s.moveTrackIntoGroup(l1.id, g.id, 1)
expect(g.childIds).toEqual([l2.id, l1.id])
})
test('normalizeScene (via loadScene) removes orphan childIds and deduplicates', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'N', width: 10, height: 10, fill: '#666' })
const l = s.addLayer(a.id)
// Snapshot with a broken state
const snap = JSON.parse(JSON.stringify(s.scene))
snap.groups = [
{ id: 'g1', name: 'G1', childIds: [l.id, 'ghost-layer', l.id] },
{ id: 'g2', name: 'G2', childIds: [l.id] },
]
snap.trackOrder = [l.id, 'g1', 'g2']
s.loadScene(snap)
// Orphan 'ghost-layer' filtered out
const g1 = s.scene.groups?.find((g) => g.id === 'g1')!
const g2 = s.scene.groups?.find((g) => g.id === 'g2')!
expect(g1.childIds.includes('ghost-layer')).toBe(false)
// Dedupe: l.id appears only once across all groups
const inG1 = g1.childIds.filter((id) => id === l.id).length
const inG2 = g2.childIds.filter((id) => id === l.id).length
expect(inG1 + inG2).toBe(1)
})
})
suite('app smoke — live DOM', () => {
test('app root, topbar, timeline, stage are mounted', () => {
expect(document.querySelector('.app')).toBeTruthy()
@@ -1254,6 +1449,11 @@ export function registerAllTests(): void {
const dz = document.querySelector('.clip-track-dropzone')
expect(dz).toBeTruthy()
})
test('timeline has add-group button', () => {
const btn = document.querySelector('button[title="Create a new track group"]')
expect(btn).toBeTruthy()
})
})
suite('version — app metadata', () => {
+11
View File
@@ -128,6 +128,17 @@ export const en = {
addClipTrack: 'Add clip track',
addClipTrackTooltip: 'Add another clip track',
dropNewTrackHint: 'Drop a clip here to add a new track',
addGroup: 'Add group',
addGroupTooltip: 'Create a new track group',
groupChildCount: '{n} tracks',
groupEmpty: 'Drop tracks here',
renameGroupDialog: {
title: 'Rename group',
confirm: 'Apply',
},
ungroup: 'Ungroup',
renameGroup: 'Rename group',
resizeTrackColumn: 'Drag to resize track name column',
clipTrackName: 'Clip track',
toggleTimeUnit: 'Toggle time unit',
snapOn: 'Snapping on (playhead & keyframes snap together)',
+10 -1
View File
@@ -230,7 +230,16 @@ const ENGINE_SOURCE = `(function(){
var iE=instances[iInst];
(instByTrack[iE.clipTrackId]=instByTrack[iE.clipTrackId]||[]).push(iE);
}
var order=scene.trackOrder||[];
var rawOrder=scene.trackOrder||[];
var groupById2={}; var groups2=scene.groups||[];
for(var gi2=0;gi2<groups2.length;gi2++) groupById2[groups2[gi2].id]=groups2[gi2];
var order=[];
for(var roi=0;roi<rawOrder.length;roi++){
var rid=rawOrder[roi];
var grp=groupById2[rid];
if(grp){ for(var gc=0;gc<grp.childIds.length;gc++) order.push(grp.childIds[gc]); }
else order.push(rid);
}
if(!order.length){for(var lj=0;lj<scene.layers.length;lj++)order.push(scene.layers[lj].id);}
for(var oi=order.length-1;oi>=0;oi--){
var id=order[oi];
+4 -1
View File
@@ -1,5 +1,6 @@
import type { AnimatableProps, Asset, Layer, Scene } from '@/types/scene'
import { sampleLayer } from './interpolate'
import { expandedTrackOrder } from './sceneOrder'
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
const result: Record<string, HTMLImageElement> = {}
@@ -174,7 +175,9 @@ export function drawSceneFrame(
const clipById = new Map<string, NonNullable<typeof scene.clips>[number]>()
for (const c of scene.clips ?? []) clipById.set(c.id, c)
const order = scene.trackOrder ?? scene.layers.map((l) => l.id)
const order = expandedTrackOrder(scene).length
? expandedTrackOrder(scene)
: scene.layers.map((l) => l.id)
for (let i = order.length - 1; i >= 0; i--) {
const id = order[i]
const layer = layerById.get(id)
+16
View File
@@ -0,0 +1,16 @@
import type { Scene, TrackGroup } from '@/types/scene'
export function expandedTrackOrder(scene: Scene): string[] {
const out: string[] = []
const groupById = new Map<string, TrackGroup>()
for (const g of scene.groups ?? []) groupById.set(g.id, g)
for (const id of scene.trackOrder ?? []) {
const group = groupById.get(id)
if (group) {
for (const cid of group.childIds) out.push(cid)
} else {
out.push(id)
}
}
return out
}
+103 -11
View File
@@ -9,6 +9,7 @@ import type {
Keyframe,
Layer,
Scene,
TrackGroup,
} from '@/types/scene'
import { DEFAULT_BASE_PROPS } from '@/types/scene'
import { sampleLayer } from '@/lib/interpolate'
@@ -105,21 +106,50 @@ function normalizeScene(scene: Scene): Scene {
scene.clips ||= []
scene.clipTracks ||= []
scene.clipInstances ||= []
if (!scene.trackOrder) {
scene.trackOrder = []
for (const l of scene.layers) scene.trackOrder.push(l.id)
for (const t of scene.clipTracks) scene.trackOrder.push(t.id)
} else {
const known = new Set<string>()
for (const l of scene.layers) known.add(l.id)
for (const t of scene.clipTracks) known.add(t.id)
scene.trackOrder = scene.trackOrder.filter((id) => known.has(id))
for (const l of scene.layers) if (!scene.trackOrder.includes(l.id)) scene.trackOrder.unshift(l.id)
for (const t of scene.clipTracks) if (!scene.trackOrder.includes(t.id)) scene.trackOrder.push(t.id)
scene.groups ||= []
const known = new Set<string>()
for (const l of scene.layers) known.add(l.id)
for (const t of scene.clipTracks) known.add(t.id)
for (const g of scene.groups) known.add(g.id)
// Cleanup: group childIds should only reference existing layers/clipTracks
const layerAndClipIds = new Set<string>()
for (const l of scene.layers) layerAndClipIds.add(l.id)
for (const t of scene.clipTracks) layerAndClipIds.add(t.id)
for (const g of scene.groups) {
g.childIds = g.childIds.filter((id) => layerAndClipIds.has(id))
}
// Dedupe: any given track can only be in one group
const inGroup = new Set<string>()
for (const g of scene.groups) {
g.childIds = g.childIds.filter((id) => {
if (inGroup.has(id)) return false
inGroup.add(id)
return true
})
}
if (!scene.trackOrder) scene.trackOrder = []
scene.trackOrder = scene.trackOrder.filter((id) => known.has(id))
for (const l of scene.layers) {
if (!inGroup.has(l.id) && !scene.trackOrder.includes(l.id)) {
scene.trackOrder.unshift(l.id)
}
}
for (const t of scene.clipTracks) {
if (!inGroup.has(t.id) && !scene.trackOrder.includes(t.id)) {
scene.trackOrder.push(t.id)
}
}
for (const g of scene.groups) {
if (!scene.trackOrder.includes(g.id)) scene.trackOrder.push(g.id)
}
return scene
}
export const useSceneStore = defineStore('scene', {
state: (): State => ({
scene: normalizeScene({
@@ -407,6 +437,12 @@ export const useSceneStore = defineStore('scene', {
if (clipTrack) {
if (color === null) delete clipTrack.color
else clipTrack.color = color
return
}
const group = this.scene.groups?.find((g) => g.id === trackId)
if (group) {
if (color === null) delete group.color
else group.color = color
}
},
shiftKeyframesPosition(
@@ -615,6 +651,62 @@ export const useSceneStore = defineStore('scene', {
if (!this.scene.clipInstances) return
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.id !== id)
},
addTrackGroup(name?: string): TrackGroup {
const group: TrackGroup = {
id: uid(),
name: name?.trim() || 'Group',
childIds: [],
}
;(this.scene.groups ||= []).push(group)
;(this.scene.trackOrder ||= []).unshift(group.id)
return group
},
removeTrackGroup(id: string) {
const group = this.scene.groups?.find((g) => g.id === id)
if (!group) return
const order = this.scene.trackOrder ?? []
const idx = order.indexOf(id)
if (idx >= 0) order.splice(idx, 1, ...group.childIds)
this.scene.groups = (this.scene.groups ?? []).filter((g) => g.id !== id)
},
renameGroup(id: string, name: string) {
const group = this.scene.groups?.find((g) => g.id === id)
if (!group) return
const n = name.trim()
if (n) group.name = n
},
toggleGroupCollapsed(id: string) {
const group = this.scene.groups?.find((g) => g.id === id)
if (!group) return
group.collapsed = !group.collapsed
},
moveTrackIntoGroup(trackId: string, groupId: string, atIndex?: number) {
const target = this.scene.groups?.find((g) => g.id === groupId)
if (!target) return
if (trackId === groupId) return
// Remove from top-level trackOrder
const order = this.scene.trackOrder ?? []
const ti = order.indexOf(trackId)
if (ti >= 0) order.splice(ti, 1)
// Remove from any other group's childIds
for (const g of this.scene.groups ?? []) {
const gi = g.childIds.indexOf(trackId)
if (gi >= 0) g.childIds.splice(gi, 1)
}
const at = atIndex === undefined ? target.childIds.length : Math.max(0, Math.min(atIndex, target.childIds.length))
target.childIds.splice(at, 0, trackId)
},
moveTrackOutOfGroup(trackId: string, toTopLevelIndex?: number) {
for (const g of this.scene.groups ?? []) {
const gi = g.childIds.indexOf(trackId)
if (gi >= 0) g.childIds.splice(gi, 1)
}
const order = (this.scene.trackOrder ||= [])
if (!order.includes(trackId)) {
const at = toTopLevelIndex === undefined ? 0 : Math.max(0, Math.min(toTopLevelIndex, order.length))
order.splice(at, 0, trackId)
}
},
enterClip(id: string) {
const clip = this.scene.clips?.find((c) => c.id === id)
if (!clip) return
+9
View File
@@ -95,6 +95,14 @@ export interface ClipTrack {
locked?: boolean
}
export interface TrackGroup {
id: string
name: string
childIds: string[]
collapsed?: boolean
color?: string | null
}
export interface ClipInstance {
id: string
clipId: string
@@ -119,6 +127,7 @@ export interface Scene {
clipTracks?: ClipTrack[]
clipInstances?: ClipInstance[]
trackOrder?: string[]
groups?: TrackGroup[]
}
export const DEFAULT_BASE_PROPS: AnimatableProps = {