bak
This commit is contained in:
1 parent
3ef5fb5a2d
commit
43933f93d1
11 files changed
+813
-76
No files matched your search
@@ -8,6 +8,7 @@ import { loadImage } from '@/lib/fileLoader'
|
||||
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
|
||||
import { t } from '@/i18n'
|
||||
import { useKfSelection } from '@/composables/useKfSelection'
|
||||
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
||||
|
||||
const store = useSceneStore()
|
||||
const { selectedKfs } = useKfSelection()
|
||||
@@ -182,7 +183,8 @@ const stackItems = computed<StackItem[]>(() => {
|
||||
instancesByTrack.set(ci.clipTrackId, arr)
|
||||
}
|
||||
|
||||
const order = store.scene.trackOrder ?? store.scene.layers.map((l) => l.id)
|
||||
const expanded = expandedTrackOrder(store.scene)
|
||||
const order = expanded.length ? expanded : store.scene.layers.map((l) => l.id)
|
||||
// iterate bottom → top: reverse the array
|
||||
for (let i = order.length - 1; i >= 0; i--) {
|
||||
const id = order[i]
|
||||
|
||||
+444
-60
@@ -2,9 +2,13 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
PhArrowsSplit,
|
||||
PhCaretDown,
|
||||
PhCaretRight,
|
||||
PhCheck,
|
||||
PhCopy,
|
||||
PhDotsSixVertical,
|
||||
PhFolder,
|
||||
PhFolderPlus,
|
||||
PhLock,
|
||||
PhLockOpen,
|
||||
PhMagnet,
|
||||
@@ -14,7 +18,7 @@ import {
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
import { confirmDialog } from '@/components/ui'
|
||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||
import { t } from '@/i18n'
|
||||
import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection'
|
||||
import type {
|
||||
@@ -24,6 +28,7 @@ import type {
|
||||
Easing,
|
||||
Keyframe,
|
||||
Layer,
|
||||
TrackGroup,
|
||||
} from '@/types/scene'
|
||||
|
||||
interface TimelineLayerTrack {
|
||||
@@ -35,6 +40,7 @@ interface TimelineLayerTrack {
|
||||
label: string
|
||||
isParent: boolean
|
||||
orderIndex: number
|
||||
inGroup: boolean
|
||||
}
|
||||
|
||||
interface TimelineClipTrack {
|
||||
@@ -42,9 +48,20 @@ interface TimelineClipTrack {
|
||||
clipTrackId: string
|
||||
name: string
|
||||
orderIndex: number
|
||||
inGroup: boolean
|
||||
}
|
||||
|
||||
type TimelineTrack = TimelineLayerTrack | TimelineClipTrack
|
||||
interface TimelineGroupTrack {
|
||||
type: 'group'
|
||||
groupId: string
|
||||
name: string
|
||||
collapsed: boolean
|
||||
color: string | null
|
||||
orderIndex: number
|
||||
childCount: number
|
||||
}
|
||||
|
||||
type TimelineTrack = TimelineLayerTrack | TimelineClipTrack | TimelineGroupTrack
|
||||
|
||||
const store = useSceneStore()
|
||||
|
||||
@@ -67,8 +84,74 @@ const clipTrackById = computed(() => {
|
||||
for (const t of store.scene.clipTracks ?? []) m.set(t.id, t)
|
||||
return m
|
||||
})
|
||||
const groupById = computed(() => {
|
||||
const m = new Map<string, TrackGroup>()
|
||||
for (const g of store.scene.groups ?? []) m.set(g.id, g)
|
||||
return m
|
||||
})
|
||||
|
||||
const NAME_WIDTH = 140
|
||||
const TRACK_NAME_WIDTH_KEY = 'animatr:trackNameWidth'
|
||||
const TRACK_NAME_WIDTH_DEFAULT = 140
|
||||
const TRACK_NAME_WIDTH_MIN = 80
|
||||
const TRACK_NAME_WIDTH_MAX = 400
|
||||
|
||||
function readStoredTrackNameWidth(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(TRACK_NAME_WIDTH_KEY)
|
||||
if (raw === null) return TRACK_NAME_WIDTH_DEFAULT
|
||||
const n = Number.parseInt(raw, 10)
|
||||
if (Number.isFinite(n) && n >= TRACK_NAME_WIDTH_MIN) return Math.min(n, TRACK_NAME_WIDTH_MAX)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return TRACK_NAME_WIDTH_DEFAULT
|
||||
}
|
||||
|
||||
const trackNameWidth = ref(readStoredTrackNameWidth())
|
||||
|
||||
const timelineRootStyle = computed(() => ({
|
||||
'--track-name-width': `${trackNameWidth.value}px`,
|
||||
}))
|
||||
|
||||
let nameResizeStartX = 0
|
||||
let nameResizeStartW = 0
|
||||
let nameResizePointerId = -1
|
||||
const nameResizing = ref(false)
|
||||
|
||||
function onNameResizeStart(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
const el = e.currentTarget as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
nameResizePointerId = e.pointerId
|
||||
nameResizeStartX = e.clientX
|
||||
nameResizeStartW = trackNameWidth.value
|
||||
nameResizing.value = true
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
|
||||
function onNameResizeMove(e: PointerEvent) {
|
||||
if (!nameResizing.value || e.pointerId !== nameResizePointerId) return
|
||||
const dx = e.clientX - nameResizeStartX
|
||||
trackNameWidth.value = Math.max(
|
||||
TRACK_NAME_WIDTH_MIN,
|
||||
Math.min(TRACK_NAME_WIDTH_MAX, nameResizeStartW + dx),
|
||||
)
|
||||
}
|
||||
|
||||
function onNameResizeEnd(e: PointerEvent) {
|
||||
if (!nameResizing.value || e.pointerId !== nameResizePointerId) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||||
nameResizePointerId = -1
|
||||
nameResizing.value = false
|
||||
document.body.style.userSelect = ''
|
||||
try {
|
||||
localStorage.setItem(TRACK_NAME_WIDTH_KEY, String(trackNameWidth.value))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const MIN_PX_PER_SEC = 2
|
||||
const MAX_PX_PER_SEC = 2000
|
||||
|
||||
@@ -82,7 +165,7 @@ function clamp(v: number, lo: number, hi: number) {
|
||||
}
|
||||
|
||||
const fitPxPerSec = computed(() => {
|
||||
const avail = Math.max(60, viewportWidth.value - NAME_WIDTH)
|
||||
const avail = Math.max(60, viewportWidth.value - trackNameWidth.value)
|
||||
const d = Math.max(0.1, duration.value)
|
||||
return avail / d
|
||||
})
|
||||
@@ -93,7 +176,7 @@ const effectivePxPerSec = computed(() => {
|
||||
})
|
||||
|
||||
const laneWidth = computed(() => duration.value * effectivePxPerSec.value)
|
||||
const totalWidth = computed(() => NAME_WIDTH + laneWidth.value)
|
||||
const totalWidth = computed(() => trackNameWidth.value + laneWidth.value)
|
||||
|
||||
type DisplayMode = 'seconds' | 'frames'
|
||||
const displayMode = ref<DisplayMode>('seconds')
|
||||
@@ -257,18 +340,21 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
|
||||
store.removeKeyframe(layerId, keyframeId)
|
||||
}
|
||||
|
||||
function emitLayerTrack(layer: Layer, orderIndex: number, inGroup: boolean, out: TimelineTrack[]) {
|
||||
if (!layer.separated) {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex, inGroup })
|
||||
} else {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex, inGroup })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex, inGroup })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex, inGroup })
|
||||
}
|
||||
}
|
||||
|
||||
const tracks = computed<TimelineTrack[]>(() => {
|
||||
const out: TimelineTrack[] = []
|
||||
if (store.activeClipId) {
|
||||
// Clip-edit scope: only show clip's layers (no clip tracks inside a clip)
|
||||
store.activeLayers.forEach((layer, orderIndex) => {
|
||||
if (!layer.separated) {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||
} else {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex })
|
||||
}
|
||||
emitLayerTrack(layer, orderIndex, false, out)
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -278,30 +364,52 @@ const tracks = computed<TimelineTrack[]>(() => {
|
||||
order.forEach((id, orderIndex) => {
|
||||
const layer = layerById.get(id)
|
||||
if (layer) {
|
||||
if (!layer.separated) {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||
} else {
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: t('timeline.position'), isParent: false, orderIndex })
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: t('timeline.rotation'), isParent: false, orderIndex })
|
||||
}
|
||||
emitLayerTrack(layer, orderIndex, false, out)
|
||||
return
|
||||
}
|
||||
const ct = clipTrackById.value.get(id)
|
||||
if (ct) {
|
||||
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? t('timeline.clipTrackName'), orderIndex })
|
||||
out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? t('timeline.clipTrackName'), orderIndex, inGroup: false })
|
||||
return
|
||||
}
|
||||
const grp = groupById.value.get(id)
|
||||
if (grp) {
|
||||
out.push({
|
||||
type: 'group',
|
||||
groupId: grp.id,
|
||||
name: grp.name,
|
||||
collapsed: !!grp.collapsed,
|
||||
color: grp.color ?? null,
|
||||
orderIndex,
|
||||
childCount: grp.childIds.length,
|
||||
})
|
||||
if (!grp.collapsed) {
|
||||
for (const cid of grp.childIds) {
|
||||
const cl = layerById.get(cid)
|
||||
if (cl) {
|
||||
emitLayerTrack(cl, -1, true, out)
|
||||
continue
|
||||
}
|
||||
const cct = clipTrackById.value.get(cid)
|
||||
if (cct) {
|
||||
out.push({ type: 'clip', clipTrackId: cct.id, name: cct.name ?? t('timeline.clipTrackName'), orderIndex: -1, inGroup: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
})
|
||||
|
||||
function trackDndId(track: TimelineTrack): string {
|
||||
return track.type === 'layer' ? track.layerId : track.clipTrackId
|
||||
if (track.type === 'layer') return track.layerId
|
||||
if (track.type === 'clip') return track.clipTrackId
|
||||
return track.groupId
|
||||
}
|
||||
|
||||
function isTrackHead(track: TimelineTrack): boolean {
|
||||
if (track.type === 'clip') return true
|
||||
return track.isParent
|
||||
if (track.type === 'layer') return track.isParent
|
||||
return true
|
||||
}
|
||||
|
||||
function isDropBefore(track: TimelineTrack): boolean {
|
||||
@@ -595,7 +703,7 @@ function duplicateFromMenu() {
|
||||
}
|
||||
|
||||
interface TrackMenuState {
|
||||
kind: 'layer' | 'clip'
|
||||
kind: 'layer' | 'clip' | 'group'
|
||||
trackId: string
|
||||
x: number
|
||||
y: number
|
||||
@@ -664,6 +772,59 @@ function onTrackDrop(e: DragEvent) {
|
||||
function onTrackDragEnd() {
|
||||
dragTrackId.value = null
|
||||
dropIndex.value = null
|
||||
dropGroupId.value = null
|
||||
}
|
||||
|
||||
const dropGroupId = ref<string | null>(null)
|
||||
|
||||
function onGroupBodyDragOver(e: DragEvent, groupId: string) {
|
||||
if (!dragTrackId.value) return
|
||||
// A group can't be moved into itself
|
||||
if (dragTrackId.value === groupId) return
|
||||
// A group itself can't be moved into another group (keep groups top-level)
|
||||
if (groupById.value.has(dragTrackId.value)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||||
dropGroupId.value = groupId
|
||||
dropIndex.value = null
|
||||
}
|
||||
|
||||
function onGroupBodyDragLeave() {
|
||||
// kept across frames; cleared on drop or dragend
|
||||
}
|
||||
|
||||
function onGroupBodyDrop(e: DragEvent, groupId: string) {
|
||||
e.preventDefault()
|
||||
const srcId = dragTrackId.value
|
||||
dragTrackId.value = null
|
||||
dropIndex.value = null
|
||||
dropGroupId.value = null
|
||||
if (!srcId) return
|
||||
if (srcId === groupId) return
|
||||
if (groupById.value.has(srcId)) return
|
||||
store.moveTrackIntoGroup(srcId, groupId)
|
||||
}
|
||||
|
||||
function toggleGroupCollapsed(groupId: string) {
|
||||
store.toggleGroupCollapsed(groupId)
|
||||
}
|
||||
|
||||
function addGroup() {
|
||||
store.addTrackGroup()
|
||||
}
|
||||
|
||||
async function renameGroupPrompt(groupId: string, current: string) {
|
||||
const next = await promptDialog({
|
||||
title: t('timeline.renameGroupDialog.title'),
|
||||
defaultValue: current,
|
||||
confirmLabel: t('timeline.renameGroupDialog.confirm'),
|
||||
})
|
||||
if (next === null) return
|
||||
store.renameGroup(groupId, next)
|
||||
}
|
||||
|
||||
async function ungroup(groupId: string) {
|
||||
store.removeTrackGroup(groupId)
|
||||
}
|
||||
|
||||
interface BoxSelectState {
|
||||
@@ -894,7 +1055,7 @@ function pixelTimeFromLane(e: DragEvent, el: HTMLElement): number {
|
||||
// Account for sticky track-name column offset: compute based on the full tl-content lane.
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0) return 0
|
||||
const NAME_COL = 140
|
||||
const NAME_COL = trackNameWidth.value
|
||||
const laneLeft = rect.left + NAME_COL
|
||||
const laneWidth = rect.width - NAME_COL
|
||||
if (laneWidth <= 0) return 0
|
||||
@@ -1031,6 +1192,22 @@ function onTrackContextMenu(e: MouseEvent, layerId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function onGroupContextMenu(e: MouseEvent, groupId: string) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const group = groupById.value.get(groupId)
|
||||
if (!group) return
|
||||
trackMenu.value = {
|
||||
kind: 'group',
|
||||
trackId: groupId,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
separated: false,
|
||||
currentColor: group.color ?? null,
|
||||
locked: false,
|
||||
}
|
||||
}
|
||||
|
||||
function onClipTrackContextMenu(e: MouseEvent, clipTrackId: string) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -1074,12 +1251,31 @@ function deleteTrack() {
|
||||
if (!trackMenu.value) return
|
||||
if (trackMenu.value.kind === 'layer') {
|
||||
store.removeLayer(trackMenu.value.trackId)
|
||||
} else {
|
||||
} else if (trackMenu.value.kind === 'clip') {
|
||||
store.removeClipTrack(trackMenu.value.trackId)
|
||||
} else {
|
||||
store.removeTrackGroup(trackMenu.value.trackId)
|
||||
}
|
||||
trackMenu.value = null
|
||||
}
|
||||
|
||||
async function renameFromMenu() {
|
||||
if (!trackMenu.value) return
|
||||
if (trackMenu.value.kind !== 'group') return
|
||||
const group = groupById.value.get(trackMenu.value.trackId)
|
||||
if (!group) return
|
||||
const id = trackMenu.value.trackId
|
||||
const current = group.name
|
||||
trackMenu.value = null
|
||||
const next = await promptDialog({
|
||||
title: t('timeline.renameGroupDialog.title'),
|
||||
defaultValue: current,
|
||||
confirmLabel: t('timeline.renameGroupDialog.confirm'),
|
||||
})
|
||||
if (next === null) return
|
||||
store.renameGroup(id, next)
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(e: PointerEvent) {
|
||||
if (menu.value && menuRef.value && e.target instanceof Node) {
|
||||
if (!menuRef.value.contains(e.target)) menu.value = null
|
||||
@@ -1152,7 +1348,7 @@ function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
const rect = scrollEl.value.getBoundingClientRect()
|
||||
const cursorInViewport = e.clientX - rect.left
|
||||
const laneCursor = cursorInViewport - NAME_WIDTH + scrollEl.value.scrollLeft
|
||||
const laneCursor = cursorInViewport - trackNameWidth.value + scrollEl.value.scrollLeft
|
||||
const currentPps = effectivePxPerSec.value
|
||||
const timeAtCursor = Math.max(0, laneCursor) / currentPps
|
||||
const factor = Math.pow(1.0015, -e.deltaY)
|
||||
@@ -1162,7 +1358,7 @@ function onWheel(e: WheelEvent) {
|
||||
requestAnimationFrame(() => {
|
||||
if (!scrollEl.value) return
|
||||
const targetLane = timeAtCursor * newPps
|
||||
scrollEl.value.scrollLeft = Math.max(0, targetLane - (cursorInViewport - NAME_WIDTH))
|
||||
scrollEl.value.scrollLeft = Math.max(0, targetLane - (cursorInViewport - trackNameWidth.value))
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1194,7 +1390,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timeline">
|
||||
<div class="timeline" :style="timelineRootStyle">
|
||||
<div ref="scrollEl" class="tl-scroll" @wheel="onWheel">
|
||||
<div class="tl-content" :style="{ width: totalWidth + 'px' }">
|
||||
<div class="scrub-bar">
|
||||
@@ -1219,6 +1415,14 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!inClipEdit"
|
||||
class="snap-btn"
|
||||
:title="t('timeline.addGroupTooltip')"
|
||||
@click="addGroup"
|
||||
>
|
||||
<PhFolderPlus :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="scrub-ruler"
|
||||
@@ -1252,7 +1456,13 @@ onBeforeUnmount(() => {
|
||||
@pointercancel="onTracksPointerUp"
|
||||
>
|
||||
<div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div>
|
||||
<template v-for="(track, idx) in tracks" :key="track.type === 'layer' ? `l:${track.layerId}:${track.kind}` : `c:${track.clipTrackId}`">
|
||||
<template v-for="(track, idx) in tracks" :key="
|
||||
track.type === 'layer'
|
||||
? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}`
|
||||
: track.type === 'clip'
|
||||
? `c:${track.clipTrackId}:${track.inGroup ? 'g' : 't'}`
|
||||
: `gr:${track.groupId}`
|
||||
">
|
||||
<!-- Layer track -->
|
||||
<div
|
||||
v-if="track.type === 'layer'"
|
||||
@@ -1260,15 +1470,16 @@ onBeforeUnmount(() => {
|
||||
:class="{
|
||||
'sub-track': !track.isParent,
|
||||
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
|
||||
'drop-before': isDropBefore(track),
|
||||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||||
'drop-before': !track.inGroup && isDropBefore(track),
|
||||
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
|
||||
dragging: track.isParent && dragTrackId === track.layerId,
|
||||
colored: !!track.layer.color,
|
||||
locked: !!track.layer.locked,
|
||||
'in-group': track.inGroup,
|
||||
}"
|
||||
:style="trackColorStyle(track.layer.color)"
|
||||
@dragover="track.isParent ? onTrackDragOver($event, track.orderIndex) : null"
|
||||
@drop="track.isParent ? onTrackDrop($event) : null"
|
||||
@dragover="track.isParent && !track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
|
||||
@drop="track.isParent && !track.inGroup ? onTrackDrop($event) : null"
|
||||
@dragleave="onTrackDragLeave"
|
||||
>
|
||||
<span
|
||||
@@ -1342,18 +1553,19 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Clip track -->
|
||||
<div
|
||||
v-else
|
||||
v-else-if="track.type === 'clip'"
|
||||
class="track clip-track"
|
||||
:class="{
|
||||
'drop-before': isDropBefore(track),
|
||||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||||
'drop-before': !track.inGroup && isDropBefore(track),
|
||||
'drop-after': !track.inGroup && isDropAfter(track, tracks[idx + 1]),
|
||||
dragging: dragTrackId === track.clipTrackId,
|
||||
colored: !!clipTrackById.get(track.clipTrackId)?.color,
|
||||
locked: !!clipTrackById.get(track.clipTrackId)?.locked,
|
||||
'in-group': track.inGroup,
|
||||
}"
|
||||
:style="trackColorStyle(clipTrackById.get(track.clipTrackId)?.color)"
|
||||
@dragover="onTrackDragOver($event, track.orderIndex)"
|
||||
@drop="onTrackDrop($event)"
|
||||
@dragover="!track.inGroup ? onTrackDragOver($event, track.orderIndex) : null"
|
||||
@drop="!track.inGroup ? onTrackDrop($event) : null"
|
||||
@dragleave="onTrackDragLeave"
|
||||
>
|
||||
<span
|
||||
@@ -1426,6 +1638,56 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group header -->
|
||||
<div
|
||||
v-else
|
||||
class="track group-track"
|
||||
:class="{
|
||||
'drop-before': isDropBefore(track),
|
||||
'drop-after': isDropAfter(track, tracks[idx + 1]),
|
||||
dragging: dragTrackId === track.groupId,
|
||||
colored: !!track.color,
|
||||
'drop-into': dropGroupId === track.groupId,
|
||||
collapsed: track.collapsed,
|
||||
}"
|
||||
:style="trackColorStyle(track.color)"
|
||||
@dragover="onTrackDragOver($event, track.orderIndex)"
|
||||
@drop="onTrackDrop($event)"
|
||||
@dragleave="onTrackDragLeave"
|
||||
>
|
||||
<span
|
||||
class="track-name group-track-name"
|
||||
draggable="true"
|
||||
@click="toggleGroupCollapsed(track.groupId)"
|
||||
@dblclick="renameGroupPrompt(track.groupId, track.name)"
|
||||
@contextmenu="onGroupContextMenu($event, track.groupId)"
|
||||
@dragstart="onTrackDragStart($event, track.groupId)"
|
||||
@dragend="onTrackDragEnd"
|
||||
>
|
||||
<PhDotsSixVertical :size="12" class="drag-handle" />
|
||||
<component
|
||||
:is="track.collapsed ? PhCaretRight : PhCaretDown"
|
||||
:size="11"
|
||||
class="group-caret"
|
||||
/>
|
||||
<PhFolder :size="12" class="name-badge" />
|
||||
{{ track.name }}
|
||||
<span class="group-count">{{ t('timeline.groupChildCount', { n: track.childCount }) }}</span>
|
||||
</span>
|
||||
<div
|
||||
class="track-lane group-lane"
|
||||
:class="{ 'drop-into': dropGroupId === track.groupId }"
|
||||
:style="{ width: laneWidth + 'px' }"
|
||||
@dragover="onGroupBodyDragOver($event, track.groupId)"
|
||||
@drop="onGroupBodyDrop($event, track.groupId)"
|
||||
@dragleave="onGroupBodyDragLeave"
|
||||
>
|
||||
<span v-if="track.collapsed && track.childCount === 0" class="group-empty-hint">
|
||||
{{ t('timeline.groupEmpty') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="!tracks.length" class="empty">{{ t('timeline.noTracks') }}</p>
|
||||
<div
|
||||
@@ -1443,6 +1705,16 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="name-col-resize"
|
||||
:class="{ resizing: nameResizing }"
|
||||
:style="{ left: trackNameWidth + 'px' }"
|
||||
:title="t('timeline.resizeTrackColumn')"
|
||||
@pointerdown="onNameResizeStart"
|
||||
@pointermove="onNameResizeMove"
|
||||
@pointerup="onNameResizeEnd"
|
||||
@pointercancel="onNameResizeEnd"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-if="menu"
|
||||
@@ -1525,32 +1797,48 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item" @click="toggleLocked">
|
||||
<span class="check">
|
||||
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
|
||||
</span>
|
||||
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" class="kf-menu-icon" />
|
||||
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
|
||||
</button>
|
||||
<template v-if="trackMenu.kind === 'layer'">
|
||||
<button
|
||||
class="kf-menu-item"
|
||||
:disabled="trackMenu.locked"
|
||||
@click="toggleSeparated"
|
||||
>
|
||||
<span class="check">
|
||||
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
|
||||
</span>
|
||||
<PhArrowsSplit :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.separate') }}
|
||||
<template v-if="trackMenu.kind === 'group'">
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item" @click="renameFromMenu">
|
||||
<span class="check"></span>
|
||||
<PhArrowsSplit :size="12" class="kf-menu-icon" style="visibility: hidden" />
|
||||
{{ t('timeline.renameGroup') }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item" @click="toggleLocked">
|
||||
<span class="check">
|
||||
<PhCheck v-if="trackMenu.locked" :size="12" weight="bold" />
|
||||
</span>
|
||||
<component :is="trackMenu.locked ? PhLockOpen : PhLock" :size="12" class="kf-menu-icon" />
|
||||
{{ trackMenu.locked ? t('timeline.unlock') : t('timeline.lock') }}
|
||||
</button>
|
||||
<template v-if="trackMenu.kind === 'layer'">
|
||||
<button
|
||||
class="kf-menu-item"
|
||||
:disabled="trackMenu.locked"
|
||||
@click="toggleSeparated"
|
||||
>
|
||||
<span class="check">
|
||||
<PhCheck v-if="trackMenu.separated" :size="12" weight="bold" />
|
||||
</span>
|
||||
<PhArrowsSplit :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.separate') }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item danger" @click="deleteTrack">
|
||||
<span class="check"></span>
|
||||
<PhTrash :size="12" class="kf-menu-icon" />
|
||||
{{ trackMenu.kind === 'layer' ? t('timeline.deleteLayer') : t('timeline.deleteClipTrack') }}
|
||||
{{
|
||||
trackMenu.kind === 'layer'
|
||||
? t('timeline.deleteLayer')
|
||||
: trackMenu.kind === 'clip'
|
||||
? t('timeline.deleteClipTrack')
|
||||
: t('timeline.ungroup')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1564,6 +1852,37 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.name-col-resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
transform: translateX(-4px);
|
||||
cursor: ew-resize;
|
||||
z-index: 15;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.name-col-resize::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 4px;
|
||||
width: 1px;
|
||||
background: transparent;
|
||||
transition: background 120ms var(--ease);
|
||||
}
|
||||
|
||||
.name-col-resize:hover::after,
|
||||
.name-col-resize.resizing::after {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.name-col-resize.resizing {
|
||||
background: rgba(110, 168, 255, 0.08);
|
||||
}
|
||||
|
||||
.tl-scroll {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -2273,6 +2592,71 @@ onBeforeUnmount(() => {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.track.group-track {
|
||||
height: 28px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(110, 168, 255, 0.05) 0%,
|
||||
rgba(110, 168, 255, 0.02) 100%
|
||||
);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.track.group-track .track-name {
|
||||
background: var(--panel-alt);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.track.group-track .track-name:hover {
|
||||
background: var(--panel-hover);
|
||||
}
|
||||
|
||||
.group-caret {
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.group-lane {
|
||||
background: transparent;
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track.group-track.drop-into > .track-name,
|
||||
.track.group-track .group-lane.drop-into {
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.group-empty-hint {
|
||||
position: sticky;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.track.in-group .track-name {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.track.in-group.sub-track .track-name {
|
||||
padding-left: 34px;
|
||||
}
|
||||
|
||||
.clip-track-dropzone {
|
||||
height: 26px;
|
||||
display: flex;
|
||||
|
||||
Reference in new issue
Block a user