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