diff --git a/server/render.html b/server/render.html index 61013a3..fed9d3a 100644 --- a/server/render.html +++ b/server/render.html @@ -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=0;oi--){ var id=order[oi]; diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 4369926..59834d0 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -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(() => { 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] diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index bf6f6d5..b5d83eb 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -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() + 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('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(() => { 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(() => { 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(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(() => {