bak
This commit is contained in:
1 parent
3ef5fb5a2d
commit
43933f93d1
11 files changed
+813
-76
No files matched your search
+10
-1
@@ -161,7 +161,16 @@
|
||||
var iE=instances[ii];
|
||||
(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); }
|
||||
for(var oi=order.length-1;oi>=0;oi--){
|
||||
var id=order[oi];
|
||||
|
||||
@@ -8,6 +8,7 @@ import { loadImage } from '@/lib/fileLoader'
|
||||
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
|
||||
import { t } from '@/i18n'
|
||||
import { useKfSelection } from '@/composables/useKfSelection'
|
||||
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
||||
|
||||
const store = useSceneStore()
|
||||
const { selectedKfs } = useKfSelection()
|
||||
@@ -182,7 +183,8 @@ const stackItems = computed<StackItem[]>(() => {
|
||||
instancesByTrack.set(ci.clipTrackId, arr)
|
||||
}
|
||||
|
||||
const order = store.scene.trackOrder ?? store.scene.layers.map((l) => l.id)
|
||||
const expanded = expandedTrackOrder(store.scene)
|
||||
const order = expanded.length ? expanded : store.scene.layers.map((l) => l.id)
|
||||
// iterate bottom → top: reverse the array
|
||||
for (let i = order.length - 1; i >= 0; i--) {
|
||||
const id = order[i]
|
||||
|
||||
+444
-60
@@ -2,9 +2,13 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
PhArrowsSplit,
|
||||
PhCaretDown,
|
||||
PhCaretRight,
|
||||
PhCheck,
|
||||
PhCopy,
|
||||
PhDotsSixVertical,
|
||||
PhFolder,
|
||||
PhFolderPlus,
|
||||
PhLock,
|
||||
PhLockOpen,
|
||||
PhMagnet,
|
||||
@@ -14,7 +18,7 @@ import {
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
import { confirmDialog } from '@/components/ui'
|
||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||
import { t } from '@/i18n'
|
||||
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
|
||||
import type {
|
||||
@@ -24,6 +28,7 @@ import type {
|
||||
Easing,
|
||||
Keyframe,
|
||||
Layer,
|
||||
TrackGroup,
|
||||
} from '@/types/scene'
|
||||
|
||||
interface TimelineLayerTrack {
|
||||
@@ -35,6 +40,7 @@ interface TimelineLayerTrack {
|
||||
label: string
|
||||
isParent: boolean
|
||||
orderIndex: number
|
||||
inGroup: boolean
|
||||
}
|
||||
|
||||
interface TimelineClipTrack {
|
||||
@@ -42,9 +48,20 @@ interface TimelineClipTrack {
|
||||
clipTrackId: string
|
||||
name: string
|
||||
orderIndex: number
|
||||
inGroup: boolean
|
||||
}
|
||||
|
||||
type TimelineTrack = TimelineLayerTrack | TimelineClipTrack
|
||||
interface TimelineGroupTrack {
|
||||
type: 'group'
|
||||
groupId: string
|
||||
name: string
|
||||
collapsed: boolean
|
||||
color: string | null
|
||||
orderIndex: number
|
||||
childCount: number
|
||||
}
|
||||
|
||||
type TimelineTrack = TimelineLayerTrack | TimelineClipTrack | TimelineGroupTrack
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
@@ -67,8 +84,74 @@ const clipTrackById = computed(() => {
|
||||
for (const t of store.scene.clipTracks ?? []) m.set(t.id, t)
|
||||
return m
|
||||
})
|
||||
const groupById = computed(() => {
|
||||
const m = new Map<string, TrackGroup>()
|
||||
for (const g of store.scene.groups ?? []) m.set(g.id, g)
|
||||
return m
|
||||
})
|
||||
|
||||
const NAME_WIDTH = 140
|
||||
const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth'
|
||||
const TRACK_NAME_WIDTH_DEFAULT = 140
|
||||
const TRACK_NAME_WIDTH_MIN = 80
|
||||
const TRACK_NAME_WIDTH_MAX = 400
|
||||
|
||||
function readStoredTrackNameWidth(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(TRACK_NAME_WIDTH_KEY)
|
||||
if (raw === null) return TRACK_NAME_WIDTH_DEFAULT
|
||||
const n = Number.parseInt(raw, 10)
|
||||
if (Number.isFinite(n) && n >= TRACK_NAME_WIDTH_MIN) return Math.min(n, TRACK_NAME_WIDTH_MAX)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return TRACK_NAME_WIDTH_DEFAULT
|
||||
}
|
||||
|
||||
const trackNameWidth = ref(readStoredTrackNameWidth())
|
||||
|
||||
const timelineRootStyle = computed(() => ({
|
||||
'--track-name-width': `${trackNameWidth.value}px`,
|
||||
}))
|
||||
|
||||
let nameResizeStartX = 0
|
||||
let nameResizeStartW = 0
|
||||
let nameResizePointerId = -1
|
||||
const nameResizing = ref(false)
|
||||
|
||||
function onNameResizeStart(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
const el = e.currentTarget as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
nameResizePointerId = e.pointerId
|
||||
nameResizeStartX = e.clientX
|
||||
nameResizeStartW = trackNameWidth.value
|
||||
nameResizing.value = true
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
|
||||
function onNameResizeMove(e: PointerEvent) {
|
||||
if (!nameResizing.value || e.pointerId !== nameResizePointerId) return
|
||||
const dx = e.clientX - nameResizeStartX
|
||||
trackNameWidth.value = Math.max(
|
||||
TRACK_NAME_WIDTH_MIN,
|
||||
Math.min(TRACK_NAME_WIDTH_MAX, nameResizeStartW + dx),
|
||||
)
|
||||
}
|
||||
|
||||
function onNameResizeEnd(e: PointerEvent) {
|
||||
if (!nameResizing.value || e.pointerId !== nameResizePointerId) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||||
nameResizePointerId = -1
|
||||
nameResizing.value = false
|
||||
document.body.style.userSelect = ''
|
||||
try {
|
||||
localStorage.setItem(TRACK_NAME_WIDTH_KEY, String(trackNameWidth.value))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const MIN_PX_PER_SEC = 2
|
||||
const MAX_PX_PER_SEC = 2000
|
||||
|
||||
@@ -82,7 +165,7 @@ function clamp(v: number, lo: number, hi: number) {
|
||||
}
|
||||
|
||||
const fitPxPerSec = computed(() => {
|
||||
const avail = Math.max(60, viewportWidth.value - NAME_WIDTH)
|
||||
const avail = Math.max(60, viewportWidth.value - trackNameWidth.value)
|
||||
const d = Math.max(0.1, duration.value)
|
||||
return avail / d
|
||||
})
|
||||
@@ -93,7 +176,7 @@ const effectivePxPerSec = computed(() => {
|
||||
})
|
||||
|
||||
const laneWidth = computed(() => duration.value * effectivePxPerSec.value)
|
||||
const totalWidth = computed(() => NAME_WIDTH + laneWidth.value)
|
||||
const totalWidth = computed(() => trackNameWidth.value + laneWidth.value)
|
||||
|
||||
type DisplayMode = 'seconds' | 'frames'
|
||||
const displayMode = ref<DisplayMode>('seconds')
|
||||
@@ -257,18 +340,21 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
|
||||
store.removeKeyframe(layerId, keyframeId)
|
||||
}
|
||||
|
||||
function emitLayerTrack(layer: Layer, orderIndex: number, inGroup: boolean, out: TimelineTrack[]) {
|
||||
if (!layer.separated) {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex, inGroup })
|
||||
} else {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex, inGroup })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex, inGroup })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex, inGroup })
|
||||
}
|
||||
}
|
||||
|
||||
const tracks = computed<TimelineTrack[]>(() => {
|
||||
const out: TimelineTrack[] = []
|
||||
if (store.activeClipId) {
|
||||
// Clip-edit scope: only show clip's layers (no clip tracks inside a clip)
|
||||
store.activeLayers.forEach((layer, orderIndex) => {
|
||||
if (!layer.separated) {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||
} else {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex })
|
||||
}
|
||||
emitLayerTrack(layer, orderIndex, false, out)
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -278,30 +364,52 @@ const tracks = computed<TimelineTrack[]>(() => {
|
||||
order.forEach((id, orderIndex) => {
|
||||
const layer = layerById.get(id)
|
||||
if (layer) {
|
||||
if (!layer.separated) {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||
} else {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex })
|
||||
}
|
||||
emitLayerTrack(layer, orderIndex, false, out)
|
||||
return
|
||||
}
|
||||
const ct = clipTrackById.value.get(id)
|
||||
if (ct) {
|
||||
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? t('timeline.clipTrackName'), orderIndex })
|
||||
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? t('timeline.clipTrackName'), orderIndex, inGroup: false })
|
||||
return
|
||||
}
|
||||
const grp = groupById.value.get(id)
|
||||
if (grp) {
|
||||
out.push({
|
||||
type: 'group',
|
||||
groupId: grp.id,
|
||||
name: grp.name,
|
||||
collapsed: !!grp.collapsed,
|
||||
color: grp.color ?? null,
|
||||
orderIndex,
|
||||
childCount: grp.childIds.length,
|
||||
})
|
||||
if (!grp.collapsed) {
|
||||
for (const cid of grp.childIds) {
|
||||
const cl = layerById.get(cid)
|
||||
if (cl) {
|
||||
emitLayerTrack(cl, -1, true, out)
|
||||
continue
|
||||
}
|
||||
const cct = clipTrackById.value.get(cid)
|
||||
if (cct) {
|
||||
out.push({ type: 'clip', clipTrackId: cct.id, name: cct.name ?? t('timeline.clipTrackName'), orderIndex: -1, inGroup: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
})
|
||||
|
||||
function trackDndId(track: TimelineTrack): string {
|
||||
return track.type === 'layer' ? track.layerId : track.clipTrackId
|
||||
if (track.type === 'layer') return track.layerId
|
||||
if (track.type === 'clip') return track.clipTrackId
|
||||
return track.groupId
|
||||
}
|
||||
|
||||
function isTrackHead(track: TimelineTrack): boolean {
|
||||
if (track.type === 'clip') return true
|
||||
return track.isParent
|
||||
if (track.type === 'layer') return track.isParent
|
||||
return true
|
||||
}
|
||||
|
||||
function isDropBefore(track: TimelineTrack): boolean {
|
||||
@@ -595,7 +703,7 @@ function duplicateFromMenu() {
|
||||
}
|
||||
|
||||
interface TrackMenuState {
|
||||
kind: 'layer' | 'clip'
|
||||
kind: 'layer' | 'clip' | 'group'
|
||||
trackId: string
|
||||
x: number
|
||||
y: number
|
||||
@@ -664,6 +772,59 @@ function onTrackDrop(e: DragEvent) {
|
||||
function onTrackDragEnd() {
|
||||
dragTrackId.value = null
|
||||
dropIndex.value = null
|
||||
dropGroupId.value = null
|
||||
}
|
||||
|
||||
const dropGroupId = ref<string | null>(null)
|
||||
|
||||
function onGroupBodyDragOver(e: DragEvent, groupId: string) {
|
||||
if (!dragTrackId.value) return
|
||||
// A group can't be moved into itself
|
||||
if (dragTrackId.value === groupId) return
|
||||
// A group itself can't be moved into another group (keep groups top-level)
|
||||
if (groupById.value.has(dragTrackId.value)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||||
dropGroupId.value = groupId
|
||||
dropIndex.value = null
|
||||
}
|
||||
|
||||
function onGroupBodyDragLeave() {
|
||||
// kept across frames; cleared on drop or dragend
|
||||
}
|
||||
|
||||
function onGroupBodyDrop(e: DragEvent, groupId: string) {
|
||||
e.preventDefault()
|
||||
const srcId = dragTrackId.value
|
||||
dragTrackId.value = null
|
||||
dropIndex.value = null
|
||||
dropGroupId.value = null
|
||||
if (!srcId) return
|
||||
if (srcId === groupId) return
|
||||
if (groupById.value.has(srcId)) return
|
||||
store.moveTrackIntoGroup(srcId, groupId)
|
||||
}
|
||||
|
||||
function toggleGroupCollapsed(groupId: string) {
|
||||
store.toggleGroupCollapsed(groupId)
|
||||
}
|
||||
|
||||
function addGroup() {
|
||||
store.addTrackGroup()
|
||||
}
|
||||
|
||||
async function renameGroupPrompt(groupId: string, current: string) {
|
||||
const next = await promptDialog({
|
||||
title: t('timeline.renameGroupDialog.title'),
|
||||
defaultValue: current,
|
||||
confirmLabel: t('timeline.renameGroupDialog.confirm'),
|
||||
})
|
||||
if (next === null) return
|
||||
store.renameGroup(groupId, next)
|
||||
}
|
||||
|
||||
async function ungroup(groupId: string) {
|
||||
store.removeTrackGroup(groupId)
|
||||
}
|
||||
|
||||
interface BoxSelectState {
|
||||
@@ -894,7 +1055,7 @@ function pixelTimeFromLane(e: DragEvent, el: HTMLElement): number {
|
||||
// Account for sticky track-name column offset: compute based on the full tl-content lane.
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0) return 0
|
||||
const NAME_COL = 140
|
||||
const NAME_COL = trackNameWidth.value
|
||||
const laneLeft = rect.left + NAME_COL
|
||||
const laneWidth = rect.width - NAME_COL
|
||||
if (laneWidth <= 0) return 0
|
||||
@@ -1031,6 +1192,22 @@ function onTrackContextMenu(e: MouseEvent, layerId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function onGroupContextMenu(e: MouseEvent, groupId: string) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const group = groupById.value.get(groupId)
|
||||
if (!group) return
|
||||
trackMenu.value = {
|
||||
kind: 'group',
|
||||
trackId: groupId,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
separated: false,
|
||||
currentColor: group.color ?? null,
|
||||
locked: false,
|
||||
}
|
||||
}
|
||||
|
||||
function onClipTrackContextMenu(e: MouseEvent, clipTrackId: string) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -1074,12 +1251,31 @@ function deleteTrack() {
|
||||
if (!trackMenu.value) return
|
||||
if (trackMenu.value.kind === 'layer') {
|
||||
store.removeLayer(trackMenu.value.trackId)
|
||||
} else {
|
||||
} else if (trackMenu.value.kind === 'clip') {
|
||||
store.removeClipTrack(trackMenu.value.trackId)
|
||||
} else {
|
||||
store.removeTrackGroup(trackMenu.value.trackId)
|
||||
}
|
||||
trackMenu.value = null
|
||||
}
|
||||
|
||||
async function renameFromMenu() {
|
||||
if (!trackMenu.value) return
|
||||
if (trackMenu.value.kind !== 'group') return
|
||||
const group = groupById.value.get(trackMenu.value.trackId)
|
||||
if (!group) return
|
||||
const id = trackMenu.value.trackId
|
||||
const current = group.name
|
||||
trackMenu.value = null
|
||||
const next = await promptDialog({
|
||||
title: t('timeline.renameGroupDialog.title'),
|
||||
defaultValue: current,
|
||||
confirmLabel: t('timeline.renameGroupDialog.confirm'),
|
||||
})
|
||||
if (next === null) return
|
||||
store.renameGroup(id, next)
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(e: PointerEvent) {
|
||||
if (menu.value && menuRef.value && e.target instanceof Node) {
|
||||
if (!menuRef.value.contains(e.target)) menu.value = null
|
||||
@@ -1152,7 +1348,7 @@ function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
const rect = scrollEl.value.getBoundingClientRect()
|
||||
const cursorInViewport = e.clientX - rect.left
|
||||
const laneCursor = cursorInViewport - NAME_WIDTH + scrollEl.value.scrollLeft
|
||||
const laneCursor = cursorInViewport - trackNameWidth.value + scrollEl.value.scrollLeft
|
||||
const currentPps = effectivePxPerSec.value
|
||||
const timeAtCursor = Math.max(0, laneCursor) / currentPps
|
||||
const factor = Math.pow(1.0015, -e.deltaY)
|
||||
@@ -1162,7 +1358,7 @@ function onWheel(e: WheelEvent) {
|
||||
requestAnimationFrame(() => {
|
||||
if (!scrollEl.value) return
|
||||
const targetLane = timeAtCursor * newPps
|
||||
scrollEl.value.scrollLeft = Math.max(0, targetLane - (cursorInViewport - NAME_WIDTH))
|
||||
scrollEl.value.scrollLeft = Math.max(0, targetLane - (cursorInViewport - trackNameWidth.value))
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1194,7 +1390,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timeline">
|
||||
<div class="timeline" :style="timelineRootStyle">
|
||||
<div ref="scrollEl" class="tl-scroll" @wheel="onWheel">
|
||||
<div class="tl-content" :style="{ width: totalWidth + 'px' }">
|
||||
<div class="scrub-bar">
|
||||
@@ -1219,6 +1415,14 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!inClipEdit"
|
||||
class="snap-btn"
|
||||
:title="t('timeline.addGroupTooltip')"
|
||||
@click="addGroup"
|
||||
>
|
||||
<PhFolderPlus :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="scrub-ruler"
|
||||
@@ -1252,7 +1456,13 @@ onBeforeUnmount(() => {
|
||||
@pointercancel="onTracksPointerUp"
|
||||
>
|
||||
<div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div>
|
||||
<template v-for="(track, idx) in tracks" :key="track.type === 'layer' ? `l:${track.layerId}:${track.kind}` : `c:${track.clipTrackId}`">
|
||||
<template v-for="(track, idx) in tracks" :key="
|
||||
track.type === 'layer'
|
||||
? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}`
|
||||
: track.type === 'clip'
|
||||
? `c:${track.clipTrackId}:${track.inGroup ? 'g' : 't'}`
|
||||
: `gr:${track.groupId}`
|
||||
">
|
||||
<!-- Layer track -->
|
||||
<div
|
||||
v-if="track.type === 'layer'"
|
||||
@@ -1260,15 +1470,16 @@ onBeforeUnmount(() => {
|
||||
:class="{
|
||||
'sub-track': !track.isParent,
|
||||
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
|
||||
'drop-before': isDropBefore(track),
|
||||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||||
'drop-before': !track.inGroup && isDropBefore(track),
|
||||
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
|
||||
dragging: track.isParent && dragTrackId === track.layerId,
|
||||
colored: !!track.layer.color,
|
||||
locked: !!track.layer.locked,
|
||||
'in-group': track.inGroup,
|
||||
}"
|
||||
:style="trackColorStyle(track.layer.color)"
|
||||
@dragover="track.isParent ? onTrackDragOver($event, track.orderIndex) : null"
|
||||
@drop="track.isParent ? onTrackDrop($event) : null"
|
||||
@dragover="track.isParent && !track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
|
||||
@drop="track.isParent && !track.inGroup ? onTrackDrop($event) : null"
|
||||
@dragleave="onTrackDragLeave"
|
||||
>
|
||||
<span
|
||||
@@ -1342,18 +1553,19 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Clip track -->
|
||||
<div
|
||||
v-else
|
||||
v-else-if="track.type === 'clip'"
|
||||
class="track clip-track"
|
||||
:class="{
|
||||
'drop-before': isDropBefore(track),
|
||||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||||
'drop-before': !track.inGroup && isDropBefore(track),
|
||||
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
|
||||
dragging: dragTrackId === track.clipTrackId,
|
||||
colored: !!clipTrackById.get(track.clipTrackId)?.color,
|
||||
locked: !!clipTrackById.get(track.clipTrackId)?.locked,
|
||||
'in-group': track.inGroup,
|
||||
}"
|
||||
:style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)"
|
||||
@dragover="onTrackDragOver($event, track.orderIndex)"
|
||||
@drop="onTrackDrop($event)"
|
||||
@dragover="!track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
|
||||
@drop="!track.inGroup ? onTrackDrop($event) : null"
|
||||
@dragleave="onTrackDragLeave"
|
||||
>
|
||||
<span
|
||||
@@ -1426,6 +1638,56 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group header -->
|
||||
<div
|
||||
v-else
|
||||
class="track group-track"
|
||||
:class="{
|
||||
'drop-before': isDropBefore(track),
|
||||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||||
dragging: dragTrackId === track.groupId,
|
||||
colored: !!track.color,
|
||||
'drop-into': dropGroupId === track.groupId,
|
||||
collapsed: track.collapsed,
|
||||
}"
|
||||
:style="trackColorStyle(track.color)"
|
||||
@dragover="onTrackDragOver($event, track.orderIndex)"
|
||||
@drop="onTrackDrop($event)"
|
||||
@dragleave="onTrackDragLeave"
|
||||
>
|
||||
<span
|
||||
class="track-name group-track-name"
|
||||
draggable="true"
|
||||
@click="toggleGroupCollapsed(track.groupId)"
|
||||
@dblclick="renameGroupPrompt(track.groupId, track.name)"
|
||||
@contextmenu="onGroupContextMenu($event, track.groupId)"
|
||||
@dragstart="onTrackDragStart($event, track.groupId)"
|
||||
@dragend="onTrackDragEnd"
|
||||
>
|
||||
<PhDotsSixVertical :size="12" class="drag-handle" />
|
||||
<component
|
||||
:is="track.collapsed ? PhCaretRight : PhCaretDown"
|
||||
:size="11"
|
||||
class="group-caret"
|
||||
/>
|
||||
<PhFolder :size="12" class="name-badge" />
|
||||
{{ track.name }}
|
||||
<span class="group-count">{{ t('timeline.groupChildCount', { n: track.childCount }) }}</span>
|
||||
</span>
|
||||
<div
|
||||
class="track-lane group-lane"
|
||||
:class="{ 'drop-into': dropGroupId === track.groupId }"
|
||||
:style="{ width: laneWidth + 'px' }"
|
||||
@dragover="onGroupBodyDragOver($event, track.groupId)"
|
||||
@drop="onGroupBodyDrop($event, track.groupId)"
|
||||
@dragleave="onGroupBodyDragLeave"
|
||||
>
|
||||
<span v-if="track.collapsed && track.childCount === 0" class="group-empty-hint">
|
||||
{{ t('timeline.groupEmpty') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
|
||||
<div
|
||||
@@ -1443,6 +1705,16 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="name-col-resize"
|
||||
:class="{ resizing: nameResizing }"
|
||||
:style="{ left: trackNameWidth + 'px' }"
|
||||
:title="t('timeline.resizeTrackColumn')"
|
||||
@pointerdown="onNameResizeStart"
|
||||
@pointermove="onNameResizeMove"
|
||||
@pointerup="onNameResizeEnd"
|
||||
@pointercancel="onNameResizeEnd"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-if="menu"
|
||||
@@ -1525,32 +1797,48 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item" @click="toggleLocked">
|
||||
<span class="check">
|
||||
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
|
||||
</span>
|
||||
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" class="kf-menu-icon" />
|
||||
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
|
||||
</button>
|
||||
<template v-if="trackMenu.kind === 'layer'">
|
||||
<button
|
||||
class="kf-menu-item"
|
||||
:disabled="trackMenu.locked"
|
||||
@click="toggleSeparated"
|
||||
>
|
||||
<span class="check">
|
||||
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
|
||||
</span>
|
||||
<PhArrowsSplit :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.separate') }}
|
||||
<template v-if="trackMenu.kind === 'group'">
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item" @click="renameFromMenu">
|
||||
<span class="check"></span>
|
||||
<PhArrowsSplit :size="12" class="kf-menu-icon" style="visibility: hidden" />
|
||||
{{ t('timeline.renameGroup') }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item" @click="toggleLocked">
|
||||
<span class="check">
|
||||
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
|
||||
</span>
|
||||
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" class="kf-menu-icon" />
|
||||
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
|
||||
</button>
|
||||
<template v-if="trackMenu.kind === 'layer'">
|
||||
<button
|
||||
class="kf-menu-item"
|
||||
:disabled="trackMenu.locked"
|
||||
@click="toggleSeparated"
|
||||
>
|
||||
<span class="check">
|
||||
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
|
||||
</span>
|
||||
<PhArrowsSplit :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.separate') }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item danger" @click="deleteTrack">
|
||||
<span class="check"></span>
|
||||
<PhTrash :size="12" class="kf-menu-icon" />
|
||||
{{ trackMenu.kind === 'layer' ? t('timeline.deleteLayer') : t('timeline.deleteClipTrack') }}
|
||||
{{
|
||||
trackMenu.kind === 'layer'
|
||||
? t('timeline.deleteLayer')
|
||||
: trackMenu.kind === 'clip'
|
||||
? t('timeline.deleteClipTrack')
|
||||
: t('timeline.ungroup')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1564,6 +1852,37 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.name-col-resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
transform: translateX(-4px);
|
||||
cursor: ew-resize;
|
||||
z-index: 15;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.name-col-resize::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 4px;
|
||||
width: 1px;
|
||||
background: transparent;
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
.name-col-resize:hover::after,
|
||||
.name-col-resize.resizing::after {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.name-col-resize.resizing {
|
||||
background: rgba(110, 168, 255, 0.08);
|
||||
}
|
||||
|
||||
.tl-scroll {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -2273,6 +2592,71 @@ onBeforeUnmount(() => {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.track.group-track {
|
||||
height: 28px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(110, 168, 255, 0.05) 0%,
|
||||
rgba(110, 168, 255, 0.02) 100%
|
||||
);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.track.group-track .track-name {
|
||||
background: var(--panel-alt);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.track.group-track .track-name:hover {
|
||||
background: var(--panel-hover);
|
||||
}
|
||||
|
||||
.group-caret {
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.group-lane {
|
||||
background: transparent;
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track.group-track.drop-into > .track-name,
|
||||
.track.group-track .group-lane.drop-into {
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.group-empty-hint {
|
||||
position: sticky;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.track.in-group .track-name {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.track.in-group.sub-track .track-name {
|
||||
padding-left: 34px;
|
||||
}
|
||||
|
||||
.clip-track-dropzone {
|
||||
height: 26px;
|
||||
display: flex;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createHistory } from '@/composables/useHistory'
|
||||
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
|
||||
import { APP_NAME, APP_VERSION } from '@/version'
|
||||
import { drawSceneFrame } from '@/lib/render'
|
||||
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
||||
import {
|
||||
deleteStoredProject,
|
||||
getStoredProject,
|
||||
@@ -1221,6 +1222,200 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — track groups', () => {
|
||||
test('addTrackGroup creates an empty group at the top of trackOrder', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'G1', width: 10, height: 10, fill: '#111' })
|
||||
s.addLayer(a.id)
|
||||
const g = s.addTrackGroup('My group')
|
||||
expect(s.scene.groups).toHaveLength(1)
|
||||
expect(g.childIds).toHaveLength(0)
|
||||
expect(s.scene.trackOrder![0]).toBe(g.id)
|
||||
})
|
||||
|
||||
test('moveTrackIntoGroup moves track from top-level to group childIds', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'G2', width: 10, height: 10, fill: '#222' })
|
||||
const l = s.addLayer(a.id)
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(l.id, g.id)
|
||||
expect(g.childIds).toContain(l.id)
|
||||
expect((s.scene.trackOrder ?? []).includes(l.id)).toBe(false)
|
||||
})
|
||||
|
||||
test('moveTrackOutOfGroup promotes track back to top-level', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'G3', width: 10, height: 10, fill: '#333' })
|
||||
const l = s.addLayer(a.id)
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(l.id, g.id)
|
||||
s.moveTrackOutOfGroup(l.id)
|
||||
expect(g.childIds.includes(l.id)).toBe(false)
|
||||
expect(s.scene.trackOrder).toContain(l.id)
|
||||
})
|
||||
|
||||
test('removeTrackGroup promotes children back to top-level', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'G4', width: 10, height: 10, fill: '#444' })
|
||||
const l = s.addLayer(a.id)
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(l.id, g.id)
|
||||
s.removeTrackGroup(g.id)
|
||||
expect(s.scene.groups).toHaveLength(0)
|
||||
expect(s.scene.trackOrder).toContain(l.id)
|
||||
})
|
||||
|
||||
test('toggleGroupCollapsed flips the collapsed flag', () => {
|
||||
const s = makeFreshStore()
|
||||
const g = s.addTrackGroup('G')
|
||||
expect(!!g.collapsed).toBe(false)
|
||||
s.toggleGroupCollapsed(g.id)
|
||||
expect(g.collapsed).toBe(true)
|
||||
s.toggleGroupCollapsed(g.id)
|
||||
expect(g.collapsed).toBe(false)
|
||||
})
|
||||
|
||||
test('renameGroup updates the name', () => {
|
||||
const s = makeFreshStore()
|
||||
const g = s.addTrackGroup('Old')
|
||||
s.renameGroup(g.id, 'New')
|
||||
expect(g.name).toBe('New')
|
||||
})
|
||||
|
||||
test('a track can only be in one group at a time', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#555' })
|
||||
const l = s.addLayer(a.id)
|
||||
const g1 = s.addTrackGroup('A')
|
||||
const g2 = s.addTrackGroup('B')
|
||||
s.moveTrackIntoGroup(l.id, g1.id)
|
||||
s.moveTrackIntoGroup(l.id, g2.id)
|
||||
expect(g1.childIds.includes(l.id)).toBe(false)
|
||||
expect(g2.childIds).toContain(l.id)
|
||||
})
|
||||
|
||||
test('expandedTrackOrder expands group IDs to their childIds inline', () => {
|
||||
const s = makeFreshStore()
|
||||
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#abc' })
|
||||
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#def' })
|
||||
const l1 = s.addLayer(a1.id)
|
||||
const l2 = s.addLayer(a2.id)
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(l1.id, g.id)
|
||||
// scene.trackOrder: [group, l2] (l1 is inside group)
|
||||
// expanded: [l1, l2]
|
||||
const expanded = expandedTrackOrder(s.scene)
|
||||
expect(expanded).toEqual([l1.id, l2.id])
|
||||
})
|
||||
|
||||
test('moveTrackIntoGroup respects atIndex for insertion', () => {
|
||||
const s = makeFreshStore()
|
||||
const a1 = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#111' })
|
||||
const a2 = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#222' })
|
||||
const a3 = s.addAsset({ kind: 'rect', name: 'C', width: 10, height: 10, fill: '#333' })
|
||||
const l1 = s.addLayer(a1.id)
|
||||
const l2 = s.addLayer(a2.id)
|
||||
const l3 = s.addLayer(a3.id)
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(l1.id, g.id) // [l1]
|
||||
s.moveTrackIntoGroup(l2.id, g.id) // [l1, l2]
|
||||
s.moveTrackIntoGroup(l3.id, g.id, 1) // [l1, l3, l2]
|
||||
expect(g.childIds).toEqual([l1.id, l3.id, l2.id])
|
||||
})
|
||||
|
||||
test('removeTrackGroup inserts children at the former group position', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#aaa' })
|
||||
const top1 = s.addLayer(a.id)
|
||||
const top2 = s.addLayer(a.id)
|
||||
const childA = s.addLayer(a.id)
|
||||
const childB = s.addLayer(a.id)
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(childA.id, g.id)
|
||||
s.moveTrackIntoGroup(childB.id, g.id)
|
||||
// trackOrder currently: [childB? no, children are removed from top-level]
|
||||
// trackOrder contains: [g, top2, top1] (newest addLayer unshifts)
|
||||
// Wait: addLayer unshifts; the creation order is top2(later), then top1(earlier)
|
||||
// Then addLayer(childA), childB — all unshifted. Then moved into group.
|
||||
// Let's just position group explicitly to be sure:
|
||||
const order = s.scene.trackOrder ?? []
|
||||
const gIdx = order.indexOf(g.id)
|
||||
expect(gIdx).toBeGreaterThan(-1)
|
||||
s.removeTrackGroup(g.id)
|
||||
const newOrder = s.scene.trackOrder ?? []
|
||||
// children should now sit at the former group position
|
||||
expect(newOrder[gIdx]).toBe(childA.id)
|
||||
expect(newOrder[gIdx + 1]).toBe(childB.id)
|
||||
void top1
|
||||
void top2
|
||||
})
|
||||
|
||||
test('renameGroup ignores empty / whitespace names', () => {
|
||||
const s = makeFreshStore()
|
||||
const g = s.addTrackGroup('Original')
|
||||
s.renameGroup(g.id, '')
|
||||
expect(g.name).toBe('Original')
|
||||
s.renameGroup(g.id, ' ')
|
||||
expect(g.name).toBe('Original')
|
||||
s.renameGroup(g.id, ' Renamed ')
|
||||
expect(g.name).toBe('Renamed')
|
||||
})
|
||||
|
||||
test('removeTrackGroup on unknown id is a no-op', () => {
|
||||
const s = makeFreshStore()
|
||||
const g = s.addTrackGroup('G')
|
||||
const beforeCount = (s.scene.groups ?? []).length
|
||||
s.removeTrackGroup('does-not-exist')
|
||||
expect((s.scene.groups ?? []).length).toBe(beforeCount)
|
||||
// The real group still exists
|
||||
expect(s.scene.groups?.find((x) => x.id === g.id)).toBeTruthy()
|
||||
})
|
||||
|
||||
test('setTrackColor applies to groups too', () => {
|
||||
const s = makeFreshStore()
|
||||
const g = s.addTrackGroup('Col')
|
||||
s.setTrackColor(g.id, '#ff0000')
|
||||
expect(g.color).toBe('#ff0000')
|
||||
s.setTrackColor(g.id, null)
|
||||
expect(g.color).toBe(undefined)
|
||||
})
|
||||
|
||||
test('moveTrackIntoGroup with same group twice moves within group', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#555' })
|
||||
const l1 = s.addLayer(a.id)
|
||||
const l2 = s.addLayer(a.id)
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(l1.id, g.id)
|
||||
s.moveTrackIntoGroup(l2.id, g.id)
|
||||
// l1 currently at index 0. Move l1 to index 1.
|
||||
s.moveTrackIntoGroup(l1.id, g.id, 1)
|
||||
expect(g.childIds).toEqual([l2.id, l1.id])
|
||||
})
|
||||
|
||||
test('normalizeScene (via loadScene) removes orphan childIds and deduplicates', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'N', width: 10, height: 10, fill: '#666' })
|
||||
const l = s.addLayer(a.id)
|
||||
// Snapshot with a broken state
|
||||
const snap = JSON.parse(JSON.stringify(s.scene))
|
||||
snap.groups = [
|
||||
{ id: 'g1', name: 'G1', childIds: [l.id, 'ghost-layer', l.id] },
|
||||
{ id: 'g2', name: 'G2', childIds: [l.id] },
|
||||
]
|
||||
snap.trackOrder = [l.id, 'g1', 'g2']
|
||||
s.loadScene(snap)
|
||||
// Orphan 'ghost-layer' filtered out
|
||||
const g1 = s.scene.groups?.find((g) => g.id === 'g1')!
|
||||
const g2 = s.scene.groups?.find((g) => g.id === 'g2')!
|
||||
expect(g1.childIds.includes('ghost-layer')).toBe(false)
|
||||
// Dedupe: l.id appears only once across all groups
|
||||
const inG1 = g1.childIds.filter((id) => id === l.id).length
|
||||
const inG2 = g2.childIds.filter((id) => id === l.id).length
|
||||
expect(inG1 + inG2).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
suite('app smoke — live DOM', () => {
|
||||
test('app root, topbar, timeline, stage are mounted', () => {
|
||||
expect(document.querySelector('.app')).toBeTruthy()
|
||||
@@ -1254,6 +1449,11 @@ export function registerAllTests(): void {
|
||||
const dz = document.querySelector('.clip-track-dropzone')
|
||||
expect(dz).toBeTruthy()
|
||||
})
|
||||
|
||||
test('timeline has add-group button', () => {
|
||||
const btn = document.querySelector('button[title="Create a new track group"]')
|
||||
expect(btn).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
suite('version — app metadata', () => {
|
||||
|
||||
@@ -128,6 +128,17 @@ export const en = {
|
||||
addClipTrack: 'Add clip track',
|
||||
addClipTrackTooltip: 'Add another clip track',
|
||||
dropNewTrackHint: 'Drop a clip here to add a new track',
|
||||
addGroup: 'Add group',
|
||||
addGroupTooltip: 'Create a new track group',
|
||||
groupChildCount: '{n} tracks',
|
||||
groupEmpty: 'Drop tracks here',
|
||||
renameGroupDialog: {
|
||||
title: 'Rename group',
|
||||
confirm: 'Apply',
|
||||
},
|
||||
ungroup: 'Ungroup',
|
||||
renameGroup: 'Rename group',
|
||||
resizeTrackColumn: 'Drag to resize track name column',
|
||||
clipTrackName: 'Clip track',
|
||||
toggleTimeUnit: 'Toggle time unit',
|
||||
snapOn: 'Snapping on (playhead & keyframes snap together)',
|
||||
|
||||
+10
-1
@@ -230,7 +230,16 @@ const ENGINE_SOURCE = `(function(){
|
||||
var iE=instances[iInst];
|
||||
(instByTrack[iE.clipTrackId]=instByTrack[iE.clipTrackId]||[]).push(iE);
|
||||
}
|
||||
var order=scene.trackOrder||[];
|
||||
var rawOrder=scene.trackOrder||[];
|
||||
var groupById2={}; var groups2=scene.groups||[];
|
||||
for(var gi2=0;gi2<groups2.length;gi2++) groupById2[groups2[gi2].id]=groups2[gi2];
|
||||
var order=[];
|
||||
for(var roi=0;roi<rawOrder.length;roi++){
|
||||
var rid=rawOrder[roi];
|
||||
var grp=groupById2[rid];
|
||||
if(grp){ for(var gc=0;gc<grp.childIds.length;gc++) order.push(grp.childIds[gc]); }
|
||||
else order.push(rid);
|
||||
}
|
||||
if(!order.length){for(var lj=0;lj<scene.layers.length;lj++)order.push(scene.layers[lj].id);}
|
||||
for(var oi=order.length-1;oi>=0;oi--){
|
||||
var id=order[oi];
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
import type { AnimatableProps, Asset, Layer, Scene } from '@/types/scene'
|
||||
import { sampleLayer } from './interpolate'
|
||||
import { expandedTrackOrder } from './sceneOrder'
|
||||
|
||||
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
|
||||
const result: Record<string, HTMLImageElement> = {}
|
||||
@@ -174,7 +175,9 @@ export function drawSceneFrame(
|
||||
const clipById = new Map<string, NonNullable<typeof scene.clips>[number]>()
|
||||
for (const c of scene.clips ?? []) clipById.set(c.id, c)
|
||||
|
||||
const order = scene.trackOrder ?? scene.layers.map((l) => l.id)
|
||||
const order = expandedTrackOrder(scene).length
|
||||
? expandedTrackOrder(scene)
|
||||
: scene.layers.map((l) => l.id)
|
||||
for (let i = order.length - 1; i >= 0; i--) {
|
||||
const id = order[i]
|
||||
const layer = layerById.get(id)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Scene, TrackGroup } from '@/types/scene'
|
||||
|
||||
export function expandedTrackOrder(scene: Scene): string[] {
|
||||
const out: string[] = []
|
||||
const groupById = new Map<string, TrackGroup>()
|
||||
for (const g of scene.groups ?? []) groupById.set(g.id, g)
|
||||
for (const id of scene.trackOrder ?? []) {
|
||||
const group = groupById.get(id)
|
||||
if (group) {
|
||||
for (const cid of group.childIds) out.push(cid)
|
||||
} else {
|
||||
out.push(id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+103
-11
@@ -9,6 +9,7 @@ import type {
|
||||
Keyframe,
|
||||
Layer,
|
||||
Scene,
|
||||
TrackGroup,
|
||||
} from '@/types/scene'
|
||||
import { DEFAULT_BASE_PROPS } from '@/types/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
@@ -105,21 +106,50 @@ function normalizeScene(scene: Scene): Scene {
|
||||
scene.clips ||= []
|
||||
scene.clipTracks ||= []
|
||||
scene.clipInstances ||= []
|
||||
if (!scene.trackOrder) {
|
||||
scene.trackOrder = []
|
||||
for (const l of scene.layers) scene.trackOrder.push(l.id)
|
||||
for (const t of scene.clipTracks) scene.trackOrder.push(t.id)
|
||||
} else {
|
||||
const known = new Set<string>()
|
||||
for (const l of scene.layers) known.add(l.id)
|
||||
for (const t of scene.clipTracks) known.add(t.id)
|
||||
scene.trackOrder = scene.trackOrder.filter((id) => known.has(id))
|
||||
for (const l of scene.layers) if (!scene.trackOrder.includes(l.id)) scene.trackOrder.unshift(l.id)
|
||||
for (const t of scene.clipTracks) if (!scene.trackOrder.includes(t.id)) scene.trackOrder.push(t.id)
|
||||
scene.groups ||= []
|
||||
|
||||
const known = new Set<string>()
|
||||
for (const l of scene.layers) known.add(l.id)
|
||||
for (const t of scene.clipTracks) known.add(t.id)
|
||||
for (const g of scene.groups) known.add(g.id)
|
||||
|
||||
// Cleanup: group childIds should only reference existing layers/clipTracks
|
||||
const layerAndClipIds = new Set<string>()
|
||||
for (const l of scene.layers) layerAndClipIds.add(l.id)
|
||||
for (const t of scene.clipTracks) layerAndClipIds.add(t.id)
|
||||
for (const g of scene.groups) {
|
||||
g.childIds = g.childIds.filter((id) => layerAndClipIds.has(id))
|
||||
}
|
||||
// Dedupe: any given track can only be in one group
|
||||
const inGroup = new Set<string>()
|
||||
for (const g of scene.groups) {
|
||||
g.childIds = g.childIds.filter((id) => {
|
||||
if (inGroup.has(id)) return false
|
||||
inGroup.add(id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if (!scene.trackOrder) scene.trackOrder = []
|
||||
scene.trackOrder = scene.trackOrder.filter((id) => known.has(id))
|
||||
|
||||
for (const l of scene.layers) {
|
||||
if (!inGroup.has(l.id) && !scene.trackOrder.includes(l.id)) {
|
||||
scene.trackOrder.unshift(l.id)
|
||||
}
|
||||
}
|
||||
for (const t of scene.clipTracks) {
|
||||
if (!inGroup.has(t.id) && !scene.trackOrder.includes(t.id)) {
|
||||
scene.trackOrder.push(t.id)
|
||||
}
|
||||
}
|
||||
for (const g of scene.groups) {
|
||||
if (!scene.trackOrder.includes(g.id)) scene.trackOrder.push(g.id)
|
||||
}
|
||||
return scene
|
||||
}
|
||||
|
||||
|
||||
export const useSceneStore = defineStore('scene', {
|
||||
state: (): State => ({
|
||||
scene: normalizeScene({
|
||||
@@ -407,6 +437,12 @@ export const useSceneStore = defineStore('scene', {
|
||||
if (clipTrack) {
|
||||
if (color === null) delete clipTrack.color
|
||||
else clipTrack.color = color
|
||||
return
|
||||
}
|
||||
const group = this.scene.groups?.find((g) => g.id === trackId)
|
||||
if (group) {
|
||||
if (color === null) delete group.color
|
||||
else group.color = color
|
||||
}
|
||||
},
|
||||
shiftKeyframesPosition(
|
||||
@@ -615,6 +651,62 @@ export const useSceneStore = defineStore('scene', {
|
||||
if (!this.scene.clipInstances) return
|
||||
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.id !== id)
|
||||
},
|
||||
addTrackGroup(name?: string): TrackGroup {
|
||||
const group: TrackGroup = {
|
||||
id: uid(),
|
||||
name: name?.trim() || 'Group',
|
||||
childIds: [],
|
||||
}
|
||||
;(this.scene.groups ||= []).push(group)
|
||||
;(this.scene.trackOrder ||= []).unshift(group.id)
|
||||
return group
|
||||
},
|
||||
removeTrackGroup(id: string) {
|
||||
const group = this.scene.groups?.find((g) => g.id === id)
|
||||
if (!group) return
|
||||
const order = this.scene.trackOrder ?? []
|
||||
const idx = order.indexOf(id)
|
||||
if (idx >= 0) order.splice(idx, 1, ...group.childIds)
|
||||
this.scene.groups = (this.scene.groups ?? []).filter((g) => g.id !== id)
|
||||
},
|
||||
renameGroup(id: string, name: string) {
|
||||
const group = this.scene.groups?.find((g) => g.id === id)
|
||||
if (!group) return
|
||||
const n = name.trim()
|
||||
if (n) group.name = n
|
||||
},
|
||||
toggleGroupCollapsed(id: string) {
|
||||
const group = this.scene.groups?.find((g) => g.id === id)
|
||||
if (!group) return
|
||||
group.collapsed = !group.collapsed
|
||||
},
|
||||
moveTrackIntoGroup(trackId: string, groupId: string, atIndex?: number) {
|
||||
const target = this.scene.groups?.find((g) => g.id === groupId)
|
||||
if (!target) return
|
||||
if (trackId === groupId) return
|
||||
// Remove from top-level trackOrder
|
||||
const order = this.scene.trackOrder ?? []
|
||||
const ti = order.indexOf(trackId)
|
||||
if (ti >= 0) order.splice(ti, 1)
|
||||
// Remove from any other group's childIds
|
||||
for (const g of this.scene.groups ?? []) {
|
||||
const gi = g.childIds.indexOf(trackId)
|
||||
if (gi >= 0) g.childIds.splice(gi, 1)
|
||||
}
|
||||
const at = atIndex === undefined ? target.childIds.length : Math.max(0, Math.min(atIndex, target.childIds.length))
|
||||
target.childIds.splice(at, 0, trackId)
|
||||
},
|
||||
moveTrackOutOfGroup(trackId: string, toTopLevelIndex?: number) {
|
||||
for (const g of this.scene.groups ?? []) {
|
||||
const gi = g.childIds.indexOf(trackId)
|
||||
if (gi >= 0) g.childIds.splice(gi, 1)
|
||||
}
|
||||
const order = (this.scene.trackOrder ||= [])
|
||||
if (!order.includes(trackId)) {
|
||||
const at = toTopLevelIndex === undefined ? 0 : Math.max(0, Math.min(toTopLevelIndex, order.length))
|
||||
order.splice(at, 0, trackId)
|
||||
}
|
||||
},
|
||||
enterClip(id: string) {
|
||||
const clip = this.scene.clips?.find((c) => c.id === id)
|
||||
if (!clip) return
|
||||
|
||||
@@ -95,6 +95,14 @@ export interface ClipTrack {
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export interface TrackGroup {
|
||||
id: string
|
||||
name: string
|
||||
childIds: string[]
|
||||
collapsed?: boolean
|
||||
color?: string | null
|
||||
}
|
||||
|
||||
export interface ClipInstance {
|
||||
id: string
|
||||
clipId: string
|
||||
@@ -119,6 +127,7 @@ export interface Scene {
|
||||
clipTracks?: ClipTrack[]
|
||||
clipInstances?: ClipInstance[]
|
||||
trackOrder?: string[]
|
||||
groups?: TrackGroup[]
|
||||
}
|
||||
|
||||
export const DEFAULT_BASE_PROPS: AnimatableProps = {
|
||||
|
||||
@@ -62,4 +62,6 @@ ein element rein draggen muss das element (grafik) als draggable anzeigen, nicht
|
||||
|
||||
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