bak
This commit is contained in:
1 parent
3ef5fb5a2d
commit
43933f93d1
11 files changed
+813
-76
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user