From 86ec7aa835a96166802ba641d6bf52f3a79d77fa Mon Sep 17 00:00:00 2001 From: th Date: Fri, 24 Apr 2026 08:00:02 +0200 Subject: [PATCH] =?UTF-8?q?0.13.0:=20unify=20all=20track=20kinds=20?= =?UTF-8?q?=E2=80=94=20same=20height,=20same=20drag,=20same=20grouping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scripts bumped from 26px to 30px so the five track kinds read as one visual list; clip / script / audio tracks lose their kind-specific gradient backgrounds now that they're no longer fixed-position chrome. - Audio tracks pick up the standard drag attributes + handlers; they now participate in the same trackOrder flow as layer / clip / script tracks and can be dropped into groups. - normalizeScenePage accepts scripts + audio as group children (used to silently strip them); the per-kind ordered appending is replaced with a single tail loop so no kind is hardcoded to the bottom. - moveTrackIntoGroup color cascade now covers all four groupable kinds (scripts + audio inherit group colour like layers + clip tracks). - Group expansion in Timeline emits scripts + audio as first-class sub-track children, with the shared in-group / sub-track classes and group-aware drag handlers. - Tests: move-into-group + color cascade for scripts + audio, legacy childIds roundtrip preserves both kinds, audio track is draggable, script track is 30px. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/components/Timeline.vue | 151 ++++++++++++++++++++++++++---------- src/devtools/tests.ts | 85 ++++++++++++++++++++ src/stores/scene.ts | 50 +++++++----- src/version.ts | 2 +- 5 files changed, 227 insertions(+), 63 deletions(-) diff --git a/package.json b/package.json index a85dcdf..e35fe45 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "0.12.0", + "version": "0.13.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index a2c6ce4..d007ca1 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -99,6 +99,9 @@ interface TimelineScriptTrack { orderIndex: number locked: boolean color: string | null + inGroup: boolean + parentGroupId?: string + groupChildIndex?: number } interface TimelineAudioTrack { @@ -109,6 +112,9 @@ interface TimelineAudioTrack { locked: boolean muted: boolean color: string | null + inGroup: boolean + parentGroupId?: string + groupChildIndex?: number } type TimelineTrack = @@ -774,6 +780,7 @@ const tracks = computed(() => { orderIndex, locked: !!st.locked, color: st.color ?? null, + inGroup: false, }) return } @@ -787,6 +794,7 @@ const tracks = computed(() => { locked: !!at.locked, muted: !!at.muted, color: at.color ?? null, + inGroup: false, }) return } @@ -820,6 +828,37 @@ const tracks = computed(() => { parentGroupId: grp.id, groupChildIndex: childIndex, }) + return + } + const cst = scriptTrackById.value.get(cid) + if (cst) { + out.push({ + type: 'script', + scriptTrackId: cst.id, + name: cst.name ?? t('timeline.scriptTrackName'), + orderIndex: -1, + locked: !!cst.locked, + color: cst.color ?? null, + inGroup: true, + parentGroupId: grp.id, + groupChildIndex: childIndex, + }) + return + } + const cat = audioTrackById.value.get(cid) + if (cat) { + out.push({ + type: 'audio', + audioTrackId: cat.id, + name: cat.name ?? t('timeline.audioTrackName'), + orderIndex: -1, + locked: !!cat.locked, + muted: !!cat.muted, + color: cat.color ?? null, + inGroup: true, + parentGroupId: grp.id, + groupChildIndex: childIndex, + }) } }) } @@ -846,10 +885,14 @@ const tracks = computed(() => { return true } if (tr.type === 'script') { - return !tr.locked + if (tr.locked) return false + if (lockedGroupChildIds.has(tr.scriptTrackId)) return false + return true } if (tr.type === 'audio') { - return !tr.locked + if (tr.locked) return false + if (lockedGroupChildIds.has(tr.audioTrackId)) return false + return true } // group: hide when locked return !lockedGroupIds.has(tr.groupId) @@ -870,19 +913,42 @@ function isTrackHead(track: TimelineTrack): boolean { } function trackChildIndex(track: TimelineTrack): number | null { - if (track.type === 'layer' || track.type === 'clip') { + if ( + track.type === 'layer' || + track.type === 'clip' || + track.type === 'script' || + track.type === 'audio' + ) { return track.groupChildIndex ?? null } return null } function trackParentGroupId(track: TimelineTrack): string | null { - if (track.type === 'layer' || track.type === 'clip') { + if ( + track.type === 'layer' || + track.type === 'clip' || + track.type === 'script' || + track.type === 'audio' + ) { return track.parentGroupId ?? null } return null } +function isGroupable(track: TimelineTrack): track is + | TimelineLayerTrack + | TimelineClipTrack + | TimelineScriptTrack + | TimelineAudioTrack { + return ( + track.type === 'layer' || + track.type === 'clip' || + track.type === 'script' || + track.type === 'audio' + ) +} + function isDropBefore(track: TimelineTrack): boolean { if (dropIndex.value === null) return false if (!isTrackHead(track)) return false @@ -891,7 +957,7 @@ function isDropBefore(track: TimelineTrack): boolean { if (trackParentGroupId(track) !== dropWithinGroupId.value) return false return dropIndex.value === trackChildIndex(track) } - if ((track.type === 'layer' || track.type === 'clip') && track.inGroup) return false + if (isGroupable(track) && track.inGroup) return false return dropIndex.value === track.orderIndex } @@ -906,7 +972,7 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined) if (nextTrack && trackChildIndex(nextTrack) === ci) return false return true } - if ((track.type === 'layer' || track.type === 'clip') && track.inGroup) return false + if (isGroupable(track) && track.inGroup) return false if (dropIndex.value !== track.orderIndex + 1) return false if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false return true @@ -3432,10 +3498,22 @@ onBeforeUnmount(() => { dragging: dragTrackId === track.scriptTrackId, colored: !!track.color, locked: track.locked, + 'in-group': track.inGroup, + 'sub-track': track.inGroup, }" :style="trackColorStyle(track.color)" - @dragover="!track.locked ? onTrackDragOver($event, track.orderIndex) : null" - @drop="!track.locked ? onTrackDrop($event) : null" + @dragover=" + !track.locked + ? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined + ? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex) + : onTrackDragOver($event, track.orderIndex)) + : null + " + @drop=" + !track.locked + ? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event)) + : null + " @dragleave="onTrackDragLeave" > { v-else-if="track.type === 'audio'" class="track audio-track" :class="{ + 'drop-before': isDropBefore(track), + 'drop-after': isDropAfter(track, tracks[idx + 1]), + dragging: dragTrackId === track.audioTrackId, colored: !!track.color, locked: track.locked, muted: track.muted, + 'in-group': track.inGroup, + 'sub-track': track.inGroup, }" :style="trackColorStyle(track.color)" :data-audio-track-id="track.audioTrackId" + @dragover=" + !track.locked + ? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined + ? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex) + : onTrackDragOver($event, track.orderIndex)) + : null + " + @drop=" + !track.locked + ? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event)) + : null + " + @dragleave="onTrackDragLeave" > @@ -5040,18 +5139,6 @@ onBeforeUnmount(() => { .track.clip-track { height: 34px; - background: linear-gradient( - to bottom, - transparent 0%, - rgba(110, 168, 255, 0.04) 50%, - transparent 100% - ); -} - -.clip-track .track-name { - background: var(--panel-alt); - font-size: 11px; - color: var(--text-dim); } .clip-lane { @@ -5061,18 +5148,7 @@ onBeforeUnmount(() => { /* ---- Script track ---- */ .track.script-track { - height: 26px; - background: linear-gradient( - to bottom, - rgba(245, 158, 11, 0.06) 0%, - rgba(245, 158, 11, 0.02) 100% - ); -} - -.script-track .track-name { - background: var(--panel-alt); - font-size: 11px; - color: var(--text-dim); + height: 30px; } .script-lane { @@ -5115,17 +5191,6 @@ onBeforeUnmount(() => { .track.audio-track { height: 30px; - background: linear-gradient( - to bottom, - color-mix(in srgb, var(--track-color, #0ea5e9) 8%, transparent) 0%, - color-mix(in srgb, var(--track-color, #0ea5e9) 2%, transparent) 100% - ); -} - -.audio-track .track-name { - background: var(--panel-alt); - font-size: 11px; - color: var(--text-dim); } .audio-track.muted .track-name .track-label { diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 2a720e8..aa1df5c 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -2950,6 +2950,63 @@ export function registerAllTests(): void { const inG2 = g2.childIds.filter((id) => id === l.id).length expect(inG1 + inG2).toBe(1) }) + + test('moveTrackIntoGroup accepts script tracks as children', () => { + const s = makeFreshStore() + const st = s.addScriptTrack('Logic') + const g = s.addTrackGroup('G') + s.moveTrackIntoGroup(st.id, g.id) + expect(g.childIds).toContain(st.id) + expect((s.scene.trackOrder ?? []).includes(st.id)).toBe(false) + }) + + test('moveTrackIntoGroup accepts audio tracks as children', () => { + const s = makeFreshStore() + const at = s.addAudioTrack('FX') + const g = s.addTrackGroup('G') + s.moveTrackIntoGroup(at.id, g.id) + expect(g.childIds).toContain(at.id) + expect((s.scene.trackOrder ?? []).includes(at.id)).toBe(false) + }) + + test('group color cascades to script + audio children alongside layers', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'Mix', width: 10, height: 10, fill: '#000' }) + const l = s.addLayer(a.id)! + const st = s.addScriptTrack('Script') + const at = s.addAudioTrack('Audio') + const g = s.addTrackGroup('G') + s.setTrackColor(g.id, '#ff8800') + s.moveTrackIntoGroup(l.id, g.id) + s.moveTrackIntoGroup(st.id, g.id) + s.moveTrackIntoGroup(at.id, g.id) + expect(l.color).toBe('#ff8800') + expect(st.color).toBe('#ff8800') + expect(at.color).toBe('#ff8800') + // None of them were explicitly recoloured — all inherit, none pin. + expect(l.colorOverridden).toBe(undefined) + expect(st.colorOverridden).toBe(undefined) + expect(at.colorOverridden).toBe(undefined) + }) + + test('normalizeScene preserves script + audio tracks as group children', () => { + // Legacy loads used to strip anything other than layers / clip + // tracks from childIds. All four non-group kinds must survive + // the roundtrip now. + const s = makeFreshStore() + const st = s.addScriptTrack('S') + const at = s.addAudioTrack('A') + const g = s.addTrackGroup('G') + const snap = JSON.parse(JSON.stringify(s.scene)) + snap.groups = [{ id: g.id, name: 'G', childIds: [st.id, at.id] }] + snap.trackOrder = [g.id] + s.loadScene(snap) + const loaded = s.scene.groups?.find((x) => x.id === g.id)! + expect(loaded.childIds).toContain(st.id) + expect(loaded.childIds).toContain(at.id) + expect((s.scene.trackOrder ?? []).includes(st.id)).toBe(false) + expect((s.scene.trackOrder ?? []).includes(at.id)).toBe(false) + }) }) suite('scene store — duplicateTrack', () => { @@ -5022,6 +5079,34 @@ export function registerAllTests(): void { handle.unmount() }) + test('audio track name carries the draggable attribute so it joins drag-reorder', async () => { + // Audio tracks used to render with no @dragstart, which left them + // outside the stack-reorder flow. They now share the same hooks + // as layer / clip / script tracks. + const s = makeFreshStore() + s.addAudioTrack('Draggable') + const handle = mountComponent(Timeline, {}) + await nextTick() + const name = handle.host.querySelector('.audio-track .track-name') as HTMLElement + expect(name).toBeTruthy() + expect(name.getAttribute('draggable')).toBe('true') + handle.unmount() + }) + + test('script track matches the default 30px track height', async () => { + // Regression: scripts used to be 26px, now unified with the other + // kinds so the timeline stack reads as a single visual list. + const s = makeFreshStore() + s.addScriptTrack('S') + const handle = mountComponent(Timeline, {}) + await nextTick() + const st = handle.host.querySelector('.script-track') as HTMLElement + expect(st).toBeTruthy() + const h = getComputedStyle(st).height + expect(h).toBe('30px') + handle.unmount() + }) + test('audio track co-exists with other track kinds in the tracks container', async () => { const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' }) diff --git a/src/stores/scene.ts b/src/stores/scene.ts index 6319a5a..4325c05 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -314,11 +314,16 @@ function normalizeScenePage(page: ScenePage): ScenePage { for (const st of page.scriptTracks) known.add(st.id) for (const at of page.audioTracks) known.add(at.id) - const layerAndClipIds = new Set() - for (const l of page.layers) layerAndClipIds.add(l.id) - for (const t of page.clipTracks) layerAndClipIds.add(t.id) + // Any non-group track may live inside a group — layers, clip tracks, + // script tracks and audio tracks are all first-class citizens. Groups + // themselves stay at the top level (no nested groups). + const groupableIds = new Set() + for (const l of page.layers) groupableIds.add(l.id) + for (const t of page.clipTracks) groupableIds.add(t.id) + for (const st of page.scriptTracks) groupableIds.add(st.id) + for (const at of page.audioTracks) groupableIds.add(at.id) for (const g of page.groups) { - g.childIds = g.childIds.filter((id) => layerAndClipIds.has(id)) + g.childIds = g.childIds.filter((id) => groupableIds.has(id)) } const inGroup = new Set() for (const g of page.groups) { @@ -330,25 +335,29 @@ function normalizeScenePage(page: ScenePage): ScenePage { } page.trackOrder = page.trackOrder.filter((id) => known.has(id)) + // Fallback for legacy / corrupted scenes where some ids are known but + // absent from trackOrder. Layers prepend (historically stage content + // sits on top); everything else appends in DOM order with no + // per-kind segregation — users can reorder freely afterwards. + const inOrder = new Set(page.trackOrder) for (const l of page.layers) { - if (!inGroup.has(l.id) && !page.trackOrder.includes(l.id)) { + if (!inGroup.has(l.id) && !inOrder.has(l.id)) { page.trackOrder.unshift(l.id) + inOrder.add(l.id) } } - for (const t of page.clipTracks) { - if (!inGroup.has(t.id) && !page.trackOrder.includes(t.id)) { - page.trackOrder.push(t.id) + const tail: Array<{ id: string }> = [ + ...page.clipTracks, + ...page.scriptTracks, + ...page.audioTracks, + ...page.groups, + ] + for (const item of tail) { + if (!inGroup.has(item.id) && !inOrder.has(item.id)) { + page.trackOrder.push(item.id) + inOrder.add(item.id) } } - for (const g of page.groups) { - if (!page.trackOrder.includes(g.id)) page.trackOrder.push(g.id) - } - for (const st of page.scriptTracks) { - if (!page.trackOrder.includes(st.id)) page.trackOrder.push(st.id) - } - for (const at of page.audioTracks) { - if (!page.trackOrder.includes(at.id)) page.trackOrder.push(at.id) - } // Orphan audio clips (pointing at a missing track or missing asset) would // never render anyway — drop them so serialised projects stay clean. const audioTrackIds = new Set(page.audioTracks.map((t) => t.id)) @@ -2746,12 +2755,17 @@ export const useSceneStore = defineStore('scene', { // Inherit the target group's color when the moved track hasn't // been explicitly recoloured by the user. Matches the cascade // behaviour of setTrackColor so a track joining a coloured group - // visually lines up with its siblings. + // visually lines up with its siblings — applies to all four + // groupable kinds, not just layers + clip tracks. if (target.color != null) { const l = this.scene.layers.find((x) => x.id === trackId) if (l && !l.colorOverridden) l.color = target.color const ct = this.scene.clipTracks?.find((x) => x.id === trackId) if (ct && !ct.colorOverridden) ct.color = target.color + const st = this.scene.scriptTracks?.find((x) => x.id === trackId) + if (st && !st.colorOverridden) st.color = target.color + const at = this.scene.audioTracks?.find((x) => x.id === trackId) + if (at && !at.colorOverridden) at.color = target.color } }, moveTrackOutOfGroup(trackId: string, toTopLevelIndex?: number) { diff --git a/src/version.ts b/src/version.ts index 81c3320..429044c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.12.0' +export const APP_VERSION = '0.13.0' export const APP_NAME = 'RUWIO'