+
@@ -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;
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 3667bff..7e2ea57 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -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', () => {
diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts
index 8d94a0d..047b9a7 100644
--- a/src/i18n/messages.ts
+++ b/src/i18n/messages.ts
@@ -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)',
diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts
index 4cca157..6137868 100644
--- a/src/lib/exportPlayer.ts
+++ b/src/lib/exportPlayer.ts
@@ -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
=0;oi--){
var id=order[oi];
diff --git a/src/lib/render.ts b/src/lib/render.ts
index 6cb01b9..b16458d 100644
--- a/src/lib/render.ts
+++ b/src/lib/render.ts
@@ -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> {
const result: Record = {}
@@ -174,7 +175,9 @@ export function drawSceneFrame(
const clipById = new Map[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)
diff --git a/src/lib/sceneOrder.ts b/src/lib/sceneOrder.ts
new file mode 100644
index 0000000..dae1b88
--- /dev/null
+++ b/src/lib/sceneOrder.ts
@@ -0,0 +1,16 @@
+import type { Scene, TrackGroup } from '@/types/scene'
+
+export function expandedTrackOrder(scene: Scene): string[] {
+ const out: string[] = []
+ const groupById = new Map()
+ 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
+}
diff --git a/src/stores/scene.ts b/src/stores/scene.ts
index 3b54ab0..179b3ed 100644
--- a/src/stores/scene.ts
+++ b/src/stores/scene.ts
@@ -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()
- 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()
+ 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()
+ 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()
+ 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
diff --git a/src/types/scene.ts b/src/types/scene.ts
index 202f21e..a92c52f 100644
--- a/src/types/scene.ts
+++ b/src/types/scene.ts
@@ -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 = {
diff --git a/todo b/todo
index 8465d04..c23e766 100644
--- a/todo
+++ b/todo
@@ -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
\ No newline at end of file
+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)
\ No newline at end of file