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

No files matched your search

+10 -1
View File
@@ -161,7 +161,16 @@
var iE=instances[ii]; var iE=instances[ii];
(instByTrack[iE.clipTrackId]=instByTrack[iE.clipTrackId]||[]).push(iE); (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 oj=0;oj<scene.layers.length;oj++) order.push(scene.layers[oj].id); } if(!order.length){ for(var oj=0;oj<scene.layers.length;oj++) order.push(scene.layers[oj].id); }
for(var oi=order.length-1;oi>=0;oi--){ for(var oi=order.length-1;oi>=0;oi--){
var id=order[oi]; var id=order[oi];
+3 -1
View File
@@ -8,6 +8,7 @@ import { loadImage } from '@/lib/fileLoader'
import type { AnimatableProps, Asset, Layer } from '@/types/scene' import type { AnimatableProps, Asset, Layer } from '@/types/scene'
import { t } from '@/i18n' import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection' import { useKfSelection } from '@/composables/useKfSelection'
import { expandedTrackOrder } from '@/lib/sceneOrder'
const store = useSceneStore() const store = useSceneStore()
const { selectedKfs } = useKfSelection() const { selectedKfs } = useKfSelection()
@@ -182,7 +183,8 @@ const stackItems = computed<StackItem[]>(() => {
instancesByTrack.set(ci.clipTrackId, arr) 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 // iterate bottom → top: reverse the array
for (let i = order.length - 1; i >= 0; i--) { for (let i = order.length - 1; i >= 0; i--) {
const id = order[i] const id = order[i]
+425 -41
View File
@@ -2,9 +2,13 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { import {
PhArrowsSplit, PhArrowsSplit,
PhCaretDown,
PhCaretRight,
PhCheck, PhCheck,
PhCopy, PhCopy,
PhDotsSixVertical, PhDotsSixVertical,
PhFolder,
PhFolderPlus,
PhLock, PhLock,
PhLockOpen, PhLockOpen,
PhMagnet, PhMagnet,
@@ -14,7 +18,7 @@ import {
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate' import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap' import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog } from '@/components/ui' import { confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n' import { t } from '@/i18n'
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection' import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
import type { import type {
@@ -24,6 +28,7 @@ import type {
Easing, Easing,
Keyframe, Keyframe,
Layer, Layer,
TrackGroup,
} from '@/types/scene' } from '@/types/scene'
interface TimelineLayerTrack { interface TimelineLayerTrack {
@@ -35,6 +40,7 @@ interface TimelineLayerTrack {
label: string label: string
isParent: boolean isParent: boolean
orderIndex: number orderIndex: number
inGroup: boolean
} }
interface TimelineClipTrack { interface TimelineClipTrack {
@@ -42,9 +48,20 @@ interface TimelineClipTrack {
clipTrackId: string clipTrackId: string
name: string name: string
orderIndex: number 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() const store = useSceneStore()
@@ -67,8 +84,74 @@ const clipTrackById = computed(() => {
for (const t of store.scene.clipTracks ?? []) m.set(t.id, t) for (const t of store.scene.clipTracks ?? []) m.set(t.id, t)
return m 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 MIN_PX_PER_SEC = 2
const MAX_PX_PER_SEC = 2000 const MAX_PX_PER_SEC = 2000
@@ -82,7 +165,7 @@ function clamp(v: number, lo: number, hi: number) {
} }
const fitPxPerSec = computed(() => { 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) const d = Math.max(0.1, duration.value)
return avail / d return avail / d
}) })
@@ -93,7 +176,7 @@ const effectivePxPerSec = computed(() => {
}) })
const laneWidth = computed(() => duration.value * effectivePxPerSec.value) 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' type DisplayMode = 'seconds' | 'frames'
const displayMode = ref<DisplayMode>('seconds') const displayMode = ref<DisplayMode>('seconds')
@@ -257,18 +340,21 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
store.removeKeyframe(layerId, keyframeId) 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 tracks = computed<TimelineTrack[]>(() => {
const out: TimelineTrack[] = [] const out: TimelineTrack[] = []
if (store.activeClipId) { if (store.activeClipId) {
// Clip-edit scope: only show clip's layers (no clip tracks inside a clip)
store.activeLayers.forEach((layer, orderIndex) => { store.activeLayers.forEach((layer, orderIndex) => {
if (!layer.separated) { emitLayerTrack(layer, orderIndex, false, out)
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 })
}
}) })
return out return out
} }
@@ -278,30 +364,52 @@ const tracks = computed<TimelineTrack[]>(() => {
order.forEach((id, orderIndex) => { order.forEach((id, orderIndex) => {
const layer = layerById.get(id) const layer = layerById.get(id)
if (layer) { if (layer) {
if (!layer.separated) { emitLayerTrack(layer, orderIndex, false, out)
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 })
}
return return
} }
const ct = clipTrackById.value.get(id) const ct = clipTrackById.value.get(id)
if (ct) { 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 return out
}) })
function trackDndId(track: TimelineTrack): string { 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 { function isTrackHead(track: TimelineTrack): boolean {
if (track.type === 'clip') return true if (track.type === 'layer') return track.isParent
return track.isParent return true
} }
function isDropBefore(track: TimelineTrack): boolean { function isDropBefore(track: TimelineTrack): boolean {
@@ -595,7 +703,7 @@ function duplicateFromMenu() {
} }
interface TrackMenuState { interface TrackMenuState {
kind: 'layer' | 'clip' kind: 'layer' | 'clip' | 'group'
trackId: string trackId: string
x: number x: number
y: number y: number
@@ -664,6 +772,59 @@ function onTrackDrop(e: DragEvent) {
function onTrackDragEnd() { function onTrackDragEnd() {
dragTrackId.value = null dragTrackId.value = null
dropIndex.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 { 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. // Account for sticky track-name column offset: compute based on the full tl-content lane.
const rect = el.getBoundingClientRect() const rect = el.getBoundingClientRect()
if (rect.width <= 0) return 0 if (rect.width <= 0) return 0
const NAME_COL = 140 const NAME_COL = trackNameWidth.value
const laneLeft = rect.left + NAME_COL const laneLeft = rect.left + NAME_COL
const laneWidth = rect.width - NAME_COL const laneWidth = rect.width - NAME_COL
if (laneWidth <= 0) return 0 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) { function onClipTrackContextMenu(e: MouseEvent, clipTrackId: string) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@@ -1074,12 +1251,31 @@ function deleteTrack() {
if (!trackMenu.value) return if (!trackMenu.value) return
if (trackMenu.value.kind === 'layer') { if (trackMenu.value.kind === 'layer') {
store.removeLayer(trackMenu.value.trackId) store.removeLayer(trackMenu.value.trackId)
} else { } else if (trackMenu.value.kind === 'clip') {
store.removeClipTrack(trackMenu.value.trackId) store.removeClipTrack(trackMenu.value.trackId)
} else {
store.removeTrackGroup(trackMenu.value.trackId)
} }
trackMenu.value = null 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) { function onDocumentPointerDown(e: PointerEvent) {
if (menu.value && menuRef.value && e.target instanceof Node) { if (menu.value && menuRef.value && e.target instanceof Node) {
if (!menuRef.value.contains(e.target)) menu.value = null if (!menuRef.value.contains(e.target)) menu.value = null
@@ -1152,7 +1348,7 @@ function onWheel(e: WheelEvent) {
e.preventDefault() e.preventDefault()
const rect = scrollEl.value.getBoundingClientRect() const rect = scrollEl.value.getBoundingClientRect()
const cursorInViewport = e.clientX - rect.left 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 currentPps = effectivePxPerSec.value
const timeAtCursor = Math.max(0, laneCursor) / currentPps const timeAtCursor = Math.max(0, laneCursor) / currentPps
const factor = Math.pow(1.0015, -e.deltaY) const factor = Math.pow(1.0015, -e.deltaY)
@@ -1162,7 +1358,7 @@ function onWheel(e: WheelEvent) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!scrollEl.value) return if (!scrollEl.value) return
const targetLane = timeAtCursor * newPps 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 return
} }
@@ -1194,7 +1390,7 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<div class="timeline"> <div class="timeline" :style="timelineRootStyle">
<div ref="scrollEl" class="tl-scroll" @wheel="onWheel"> <div ref="scrollEl" class="tl-scroll" @wheel="onWheel">
<div class="tl-content" :style="{ width: totalWidth + 'px' }"> <div class="tl-content" :style="{ width: totalWidth + 'px' }">
<div class="scrub-bar"> <div class="scrub-bar">
@@ -1219,6 +1415,14 @@ onBeforeUnmount(() => {
> >
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" /> <PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
</button> </button>
<button
v-if="!inClipEdit"
class="snap-btn"
:title="t('timeline.addGroupTooltip')"
@click="addGroup"
>
<PhFolderPlus :size="12" />
</button>
</div> </div>
<div <div
class="scrub-ruler" class="scrub-ruler"
@@ -1252,7 +1456,13 @@ onBeforeUnmount(() => {
@pointercancel="onTracksPointerUp" @pointercancel="onTracksPointerUp"
> >
<div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div> <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 --> <!-- Layer track -->
<div <div
v-if="track.type === 'layer'" v-if="track.type === 'layer'"
@@ -1260,15 +1470,16 @@ onBeforeUnmount(() => {
:class="{ :class="{
'sub-track': !track.isParent, 'sub-track': !track.isParent,
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId, inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
'drop-before': isDropBefore(track), 'drop-before': !track.inGroup && isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]), 'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
dragging: track.isParent && dragTrackId === track.layerId, dragging: track.isParent && dragTrackId === track.layerId,
colored: !!track.layer.color, colored: !!track.layer.color,
locked: !!track.layer.locked, locked: !!track.layer.locked,
'in-group': track.inGroup,
}" }"
:style="trackColorStyle(track.layer.color)" :style="trackColorStyle(track.layer.color)"
@dragover="track.isParent ? onTrackDragOver($event, track.orderIndex) : null" @dragover="track.isParent && !track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
@drop="track.isParent ? onTrackDrop($event) : null" @drop="track.isParent && !track.inGroup ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave" @dragleave="onTrackDragLeave"
> >
<span <span
@@ -1342,18 +1553,19 @@ onBeforeUnmount(() => {
<!-- Clip track --> <!-- Clip track -->
<div <div
v-else v-else-if="track.type === 'clip'"
class="track clip-track" class="track clip-track"
:class="{ :class="{
'drop-before': isDropBefore(track), 'drop-before': !track.inGroup && isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]), 'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
dragging: dragTrackId === track.clipTrackId, dragging: dragTrackId === track.clipTrackId,
colored: !!clipTrackById.get(track.clipTrackId)?.color, colored: !!clipTrackById.get(track.clipTrackId)?.color,
locked: !!clipTrackById.get(track.clipTrackId)?.locked, locked: !!clipTrackById.get(track.clipTrackId)?.locked,
'in-group': track.inGroup,
}" }"
:style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)" :style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)"
@dragover="onTrackDragOver($event, track.orderIndex)" @dragover="!track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
@drop="onTrackDrop($event)" @drop="!track.inGroup ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave" @dragleave="onTrackDragLeave"
> >
<span <span
@@ -1426,6 +1638,56 @@ onBeforeUnmount(() => {
</span> </span>
</div> </div>
</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> </template>
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p> <p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
<div <div
@@ -1443,6 +1705,16 @@ onBeforeUnmount(() => {
</div> </div>
</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 <div
v-if="menu" v-if="menu"
@@ -1525,6 +1797,15 @@ onBeforeUnmount(() => {
</button> </button>
</div> </div>
</div> </div>
<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> <div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="toggleLocked"> <button class="kf-menu-item" @click="toggleLocked">
<span class="check"> <span class="check">
@@ -1546,11 +1827,18 @@ onBeforeUnmount(() => {
{{ t('timeline.separate') }} {{ t('timeline.separate') }}
</button> </button>
</template> </template>
</template>
<div class="kf-menu-divider"></div> <div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteTrack"> <button class="kf-menu-item danger" @click="deleteTrack">
<span class="check"></span> <span class="check"></span>
<PhTrash :size="12" class="kf-menu-icon" /> <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> </button>
</div> </div>
</div> </div>
@@ -1564,6 +1852,37 @@ onBeforeUnmount(() => {
overflow: hidden; 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 { .tl-scroll {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -2273,6 +2592,71 @@ onBeforeUnmount(() => {
opacity: 1 !important; opacity: 1 !important;
} }
.track.group-track {
height: 28px;
background: linear-gradient(
to bottom,
rgba(110, 168, 255, 0.05) 0%,
rgba(110, 168, 255, 0.02) 100%
);
font-weight: 500;
}
.track.group-track .track-name {
background: var(--panel-alt);
font-size: 12px;
color: var(--text);
gap: 4px;
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 { .clip-track-dropzone {
height: 26px; height: 26px;
display: flex; display: flex;
+200
View File
@@ -12,6 +12,7 @@ import { createHistory } from '@/composables/useHistory'
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection' import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
import { APP_NAME, APP_VERSION } from '@/version' import { APP_NAME, APP_VERSION } from '@/version'
import { drawSceneFrame } from '@/lib/render' import { drawSceneFrame } from '@/lib/render'
import { expandedTrackOrder } from '@/lib/sceneOrder'
import { import {
deleteStoredProject, deleteStoredProject,
getStoredProject, 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', () => { suite('app smoke — live DOM', () => {
test('app root, topbar, timeline, stage are mounted', () => { test('app root, topbar, timeline, stage are mounted', () => {
expect(document.querySelector('.app')).toBeTruthy() expect(document.querySelector('.app')).toBeTruthy()
@@ -1254,6 +1449,11 @@ export function registerAllTests(): void {
const dz = document.querySelector('.clip-track-dropzone') const dz = document.querySelector('.clip-track-dropzone')
expect(dz).toBeTruthy() 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', () => { suite('version — app metadata', () => {
+11
View File
@@ -128,6 +128,17 @@ export const en = {
addClipTrack: 'Add clip track', addClipTrack: 'Add clip track',
addClipTrackTooltip: 'Add another clip track', addClipTrackTooltip: 'Add another clip track',
dropNewTrackHint: 'Drop a clip here to add a new 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', clipTrackName: 'Clip track',
toggleTimeUnit: 'Toggle time unit', toggleTimeUnit: 'Toggle time unit',
snapOn: 'Snapping on (playhead & keyframes snap together)', snapOn: 'Snapping on (playhead & keyframes snap together)',
+10 -1
View File
@@ -230,7 +230,16 @@ const ENGINE_SOURCE = `(function(){
var iE=instances[iInst]; var iE=instances[iInst];
(instByTrack[iE.clipTrackId]=instByTrack[iE.clipTrackId]||[]).push(iE); (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);} 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--){ for(var oi=order.length-1;oi>=0;oi--){
var id=order[oi]; var id=order[oi];
+4 -1
View File
@@ -1,5 +1,6 @@
import type { AnimatableProps, Asset, Layer, Scene } from '@/types/scene' import type { AnimatableProps, Asset, Layer, Scene } from '@/types/scene'
import { sampleLayer } from './interpolate' import { sampleLayer } from './interpolate'
import { expandedTrackOrder } from './sceneOrder'
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> { export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
const result: 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]>() const clipById = new Map<string, NonNullable<typeof scene.clips>[number]>()
for (const c of scene.clips ?? []) clipById.set(c.id, c) 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--) { for (let i = order.length - 1; i >= 0; i--) {
const id = order[i] const id = order[i]
const layer = layerById.get(id) 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
}
+99 -7
View File
@@ -9,6 +9,7 @@ import type {
Keyframe, Keyframe,
Layer, Layer,
Scene, Scene,
TrackGroup,
} from '@/types/scene' } from '@/types/scene'
import { DEFAULT_BASE_PROPS } from '@/types/scene' import { DEFAULT_BASE_PROPS } from '@/types/scene'
import { sampleLayer } from '@/lib/interpolate' import { sampleLayer } from '@/lib/interpolate'
@@ -105,21 +106,50 @@ function normalizeScene(scene: Scene): Scene {
scene.clips ||= [] scene.clips ||= []
scene.clipTracks ||= [] scene.clipTracks ||= []
scene.clipInstances ||= [] scene.clipInstances ||= []
if (!scene.trackOrder) { scene.groups ||= []
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>() const known = new Set<string>()
for (const l of scene.layers) known.add(l.id) for (const l of scene.layers) known.add(l.id)
for (const t of scene.clipTracks) known.add(t.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)) 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) 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 return scene
} }
export const useSceneStore = defineStore('scene', { export const useSceneStore = defineStore('scene', {
state: (): State => ({ state: (): State => ({
scene: normalizeScene({ scene: normalizeScene({
@@ -407,6 +437,12 @@ export const useSceneStore = defineStore('scene', {
if (clipTrack) { if (clipTrack) {
if (color === null) delete clipTrack.color if (color === null) delete clipTrack.color
else clipTrack.color = 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( shiftKeyframesPosition(
@@ -615,6 +651,62 @@ export const useSceneStore = defineStore('scene', {
if (!this.scene.clipInstances) return if (!this.scene.clipInstances) return
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.id !== id) 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) { enterClip(id: string) {
const clip = this.scene.clips?.find((c) => c.id === id) const clip = this.scene.clips?.find((c) => c.id === id)
if (!clip) return if (!clip) return
+9
View File
@@ -95,6 +95,14 @@ export interface ClipTrack {
locked?: boolean locked?: boolean
} }
export interface TrackGroup {
id: string
name: string
childIds: string[]
collapsed?: boolean
color?: string | null
}
export interface ClipInstance { export interface ClipInstance {
id: string id: string
clipId: string clipId: string
@@ -119,6 +127,7 @@ export interface Scene {
clipTracks?: ClipTrack[] clipTracks?: ClipTrack[]
clipInstances?: ClipInstance[] clipInstances?: ClipInstance[]
trackOrder?: string[] trackOrder?: string[]
groups?: TrackGroup[]
} }
export const DEFAULT_BASE_PROPS: AnimatableProps = { export const DEFAULT_BASE_PROPS: AnimatableProps = {
+2
View File
@@ -63,3 +63,5 @@ ein element rein draggen muss das element (grafik) als draggable anzeigen, nicht
context menüs ragen unten immernoch aus dem viewport raus context menüs ragen unten immernoch aus dem viewport raus
rechts click menu muss überall unterdrückt werden wo wir noch keins haben. das browser context menu darf nirgends angezeigt werden rechts click menu muss überall unterdrückt werden wo wir noch keins haben. das browser context menu darf nirgends angezeigt werden
maskenlayer sind tracks mit einem pfad-objekt (circle, rect, text) alles unter einem masken track wird davon maskiert. allerdings immer nur in einer gruppe. elemente außerhalb der gruppe mit der maske werden normal dargestellt. es kann mehrere masken tracks geben. ein maskentrack kann entweder positiv oder negativ maskieren. ein maskenlayer mus zwei ansichten haben: normale elemente (damit man sie zieht zum bearbeiten) und masking (damit sie maskieren)