This commit is contained in:
th committed 2026-04-20 01:05:27 +02:00
1 parent 43933f93d1
commit 03b1147a93
10 files changed
+804 -80

No files matched your search

+180 -20
View File
@@ -12,6 +12,7 @@ import {
PhLock,
PhLockOpen,
PhMagnet,
PhMaskHappy,
PhPauseCircle,
PhTrash,
} from '@phosphor-icons/vue'
@@ -41,6 +42,8 @@ interface TimelineLayerTrack {
isParent: boolean
orderIndex: number
inGroup: boolean
parentGroupId?: string
groupChildIndex?: number
}
interface TimelineClipTrack {
@@ -49,6 +52,8 @@ interface TimelineClipTrack {
name: string
orderIndex: number
inGroup: boolean
parentGroupId?: string
groupChildIndex?: number
}
interface TimelineGroupTrack {
@@ -240,6 +245,30 @@ function pxFromTime(t: number) {
return t * effectivePxPerSec.value
}
function groupKeyframeTimes(groupId: string): number[] {
const group = groupById.value.get(groupId)
if (!group) return []
const times: number[] = []
const layerById = new Map<string, Layer>()
for (const l of store.scene.layers) layerById.set(l.id, l)
for (const childId of group.childIds) {
const layer = layerById.get(childId)
if (layer) {
for (const k of layer.keyframes) times.push(k.time)
for (const k of layer.positionKeyframes ?? []) times.push(k.time)
for (const k of layer.rotationKeyframes ?? []) times.push(k.time)
continue
}
const ct = clipTrackById.value.get(childId)
if (ct) {
for (const ci of clipInstancesByTrack.value[ct.id] ?? []) {
times.push(ci.startTime)
}
}
}
return times
}
function formatTick(t: number) {
if (displayMode.value === 'frames') {
return `${Math.round(t * fps.value)}`
@@ -340,13 +369,21 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
store.removeKeyframe(layerId, keyframeId)
}
function emitLayerTrack(layer: Layer, orderIndex: number, inGroup: boolean, out: TimelineTrack[]) {
function emitLayerTrack(
layer: Layer,
orderIndex: number,
inGroup: boolean,
out: TimelineTrack[],
parentGroupId?: string,
groupChildIndex?: number,
) {
const base = { type: 'layer' as const, layerId: layer.id, layer, orderIndex, inGroup, parentGroupId, groupChildIndex }
if (!layer.separated) {
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex, inGroup })
out.push({ ...base, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true })
} 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 })
out.push({ ...base, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true })
out.push({ ...base, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false })
out.push({ ...base, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false })
}
}
@@ -384,17 +421,25 @@ const tracks = computed<TimelineTrack[]>(() => {
childCount: grp.childIds.length,
})
if (!grp.collapsed) {
for (const cid of grp.childIds) {
grp.childIds.forEach((cid, childIndex) => {
const cl = layerById.get(cid)
if (cl) {
emitLayerTrack(cl, -1, true, out)
continue
emitLayerTrack(cl, -1, true, out, grp.id, childIndex)
return
}
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 })
out.push({
type: 'clip',
clipTrackId: cct.id,
name: cct.name ?? t('timeline.clipTrackName'),
orderIndex: -1,
inGroup: true,
parentGroupId: grp.id,
groupChildIndex: childIndex,
})
}
}
})
}
}
})
@@ -412,16 +457,44 @@ function isTrackHead(track: TimelineTrack): boolean {
return true
}
function trackChildIndex(track: TimelineTrack): number | null {
if (track.type === 'layer' || track.type === 'clip') {
return track.groupChildIndex ?? null
}
return null
}
function trackParentGroupId(track: TimelineTrack): string | null {
if (track.type === 'layer' || track.type === 'clip') {
return track.parentGroupId ?? null
}
return null
}
function isDropBefore(track: TimelineTrack): boolean {
if (dropIndex.value === null) return false
if (!isTrackHead(track)) return false
if (dragTrackId.value === trackDndId(track)) return false
if (dropWithinGroupId.value) {
if (trackParentGroupId(track) !== dropWithinGroupId.value) return false
return dropIndex.value === trackChildIndex(track)
}
if (track.type !== 'group' && track.inGroup) return false
return dropIndex.value === track.orderIndex
}
function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined): boolean {
if (dropIndex.value === null) return false
if (dragTrackId.value === trackDndId(track)) return false
if (dropWithinGroupId.value) {
if (trackParentGroupId(track) !== dropWithinGroupId.value) return false
const ci = trackChildIndex(track)
if (ci === null) return false
if (dropIndex.value !== ci + 1) return false
if (nextTrack && trackChildIndex(nextTrack) === ci) return false
return true
}
if (track.type !== 'group' && track.inGroup) return false
if (dropIndex.value !== track.orderIndex + 1) return false
if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false
return true
@@ -727,6 +800,7 @@ const TRACK_COLORS: Array<{ value: string | null; label: string }> = [
const dragTrackId = ref<string | null>(null)
const dropIndex = ref<number | null>(null)
const dropWithinGroupId = ref<string | null>(null)
function onTrackDragStart(e: DragEvent, trackId: string) {
if (!e.dataTransfer) return
@@ -748,6 +822,45 @@ function onTrackDragOver(e: DragEvent, orderIndex: number) {
const rect = el.getBoundingClientRect()
const midY = rect.top + rect.height / 2
dropIndex.value = e.clientY < midY ? orderIndex : orderIndex + 1
dropWithinGroupId.value = null
}
function onGroupChildDragOver(e: DragEvent, groupId: string, childIndex: number) {
if (!dragTrackId.value) return
if (dragTrackId.value === groupId) return
// Groups themselves cannot be nested inside another group
if (groupById.value.has(dragTrackId.value)) return
if (!e.dataTransfer) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const midY = rect.top + rect.height / 2
dropIndex.value = e.clientY < midY ? childIndex : childIndex + 1
dropWithinGroupId.value = groupId
dropGroupId.value = null
}
function onGroupChildDrop(e: DragEvent) {
e.preventDefault()
const srcId = dragTrackId.value
const target = dropIndex.value
const groupId = dropWithinGroupId.value
dragTrackId.value = null
dropIndex.value = null
dropWithinGroupId.value = null
dropGroupId.value = null
if (!srcId || target === null || !groupId) return
if (srcId === groupId) return
if (groupById.value.has(srcId)) return
const group = groupById.value.get(groupId)
if (!group) return
const from = group.childIds.indexOf(srcId)
let to = target
// If source is already inside this group and before the target index,
// removal will shift subsequent positions down by one.
if (from >= 0 && from < to) to -= 1
store.moveTrackIntoGroup(srcId, groupId, to)
}
function onTrackDragLeave() {
@@ -773,6 +886,7 @@ function onTrackDragEnd() {
dragTrackId.value = null
dropIndex.value = null
dropGroupId.value = null
dropWithinGroupId.value = null
}
const dropGroupId = ref<string | null>(null)
@@ -1470,16 +1584,26 @@ onBeforeUnmount(() => {
:class="{
'sub-track': !track.isParent,
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
'drop-before': !track.inGroup && isDropBefore(track),
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
'drop-before': isDropBefore(track),
'drop-after': 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 && !track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
@drop="track.isParent && !track.inGroup ? onTrackDrop($event) : null"
@dragover="
track.isParent
? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
: onTrackDragOver($event, track.orderIndex))
: null
"
@drop="
track.isParent
? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
: null
"
@dragleave="onTrackDragLeave"
>
<span
@@ -1506,6 +1630,12 @@ onBeforeUnmount(() => {
:size="11"
class="name-badge"
/>
<PhMaskHappy
v-if="track.isParent && track.layer.isMask"
:size="11"
class="name-badge"
:class="{ 'mask-negative': track.layer.maskMode === 'negative' }"
/>
<span v-if="!track.isParent" class="sub-indent">↳</span>
{{ track.label }}
</span>
@@ -1556,16 +1686,20 @@ onBeforeUnmount(() => {
v-else-if="track.type === 'clip'"
class="track clip-track"
:class="{
'drop-before': !track.inGroup && isDropBefore(track),
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
'drop-before': isDropBefore(track),
'drop-after': 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="!track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
@drop="!track.inGroup ? onTrackDrop($event) : null"
@dragover="
track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
: onTrackDragOver($event, track.orderIndex)
"
@drop="track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event)"
@dragleave="onTrackDragLeave"
>
<span
@@ -1683,9 +1817,18 @@ onBeforeUnmount(() => {
@drop="onGroupBodyDrop($event, track.groupId)"
@dragleave="onGroupBodyDragLeave"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span v-if="track.collapsed && track.childCount === 0" class="group-empty-hint">
{{ t('timeline.groupEmpty') }}
</span>
<template v-if="track.collapsed">
<span
v-for="(time, kfIdx) in groupKeyframeTimes(track.groupId)"
:key="`gkf-${kfIdx}`"
class="group-kf-dot"
:style="{ left: pxFromTime(time) + 'px' }"
/>
</template>
</div>
</div>
</template>
@@ -2289,6 +2432,10 @@ onBeforeUnmount(() => {
flex-shrink: 0;
}
.name-badge.mask-negative {
color: #ff6b6b;
}
.drag-handle {
color: var(--text-dim);
margin-right: 4px;
@@ -2649,12 +2796,25 @@ onBeforeUnmount(() => {
font-style: italic;
}
.group-kf-dot {
position: absolute;
top: 50%;
width: 5px;
height: 5px;
margin-left: -2.5px;
margin-top: -2.5px;
border-radius: 50%;
background: var(--track-color, var(--accent));
opacity: 0.75;
pointer-events: none;
}
.track.in-group .track-name {
padding-left: 22px;
padding-left: 38px;
}
.track.in-group.sub-track .track-name {
padding-left: 34px;
padding-left: 50px;
}
.clip-track-dropzone {