diff --git a/package.json b/package.json index 4172f8e..6ee23a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "animatr", "private": true, - "version": "0.8.4", + "version": "0.8.5", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 0d52225..976c82b 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -1877,12 +1877,27 @@ async function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) { const trackMenu = ref(null) const trackMenuRef = ref(null) +function onLayerTrackNameClick(e: MouseEvent, track: TimelineLayerTrack) { + if (isTrackEffectivelyLocked(track)) return + if (e.shiftKey && track.isParent) { + store.toggleLayerSelection(track.layerId) + return + } + store.selectLayer(track.layerId) +} + function onTrackContextMenu(e: MouseEvent, layerId: string) { e.preventDefault() e.stopPropagation() const layer = store.activeLayers.find((l) => l.id === layerId) if (!layer) return - store.selectLayer(layerId) + // If the right-clicked layer isn't already in the multi-selection, + // treat the right-click as a single-select so the menu actions + // operate on what the user actually clicked. Multi-selected ids + // persist so "Group selected" has a real list to work with. + if (!store.selectedLayerIds.includes(layerId)) { + store.selectLayer(layerId) + } trackMenu.value = { kind: 'layer', trackId: layerId, @@ -1959,6 +1974,16 @@ function toggleSeparated() { trackMenu.value = null } +function groupSelectedFromMenu() { + store.groupSelectedLayers() + trackMenu.value = null +} + +function convertSelectedToClipFromMenu() { + store.convertSelectedLayersToClip() + trackMenu.value = null +} + function deleteTrack() { if (!trackMenu.value) return if (trackMenu.value.kind === 'layer') { @@ -2424,12 +2449,13 @@ onBeforeUnmount(() => { + +
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 71d973c..9d9875c 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -241,6 +241,237 @@ export function registerAllTests(): void { await tick() expect(s.selectedLayerId).toBe(l.id) }) + + test('selectLayer keeps selectedLayerIds in sync', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l = s.addLayer(a.id)! + s.selectLayer(l.id) + expect(s.selectedLayerIds).toEqual([l.id]) + s.selectLayer(null) + expect(s.selectedLayerIds).toEqual([]) + }) + + test('toggleLayerSelection adds and removes; anchor follows the last click', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l1 = s.addLayer(a.id)! + const l2 = s.addLayer(a.id)! + const l3 = s.addLayer(a.id)! + s.selectLayer(l1.id) + s.toggleLayerSelection(l2.id) + s.toggleLayerSelection(l3.id) + expect(s.selectedLayerIds).toEqual([l1.id, l2.id, l3.id]) + expect(s.selectedLayerId).toBe(l3.id) + // Removing the anchor moves the anchor back to the new last entry. + s.toggleLayerSelection(l3.id) + expect(s.selectedLayerIds).toEqual([l1.id, l2.id]) + expect(s.selectedLayerId).toBe(l2.id) + // Removing the last remaining entry drops the anchor to null. + s.toggleLayerSelection(l2.id) + s.toggleLayerSelection(l1.id) + expect(s.selectedLayerIds).toEqual([]) + expect(s.selectedLayerId).toBe(null) + }) + + test('toggleLayerSelection skips layers locked directly or by group', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const lFree = s.addLayer(a.id)! + const lDirect = s.addLayer(a.id)! + const lInGroup = s.addLayer(a.id)! + s.setTrackLocked(lDirect.id, true) + const g = s.addTrackGroup('G') + s.moveTrackIntoGroup(lInGroup.id, g.id) + s.setTrackLocked(g.id, true) + s.selectLayer(lFree.id) + s.toggleLayerSelection(lDirect.id) + s.toggleLayerSelection(lInGroup.id) + // Neither locked layer was added; the free one stays as the + // only selection. + expect(s.selectedLayerIds).toEqual([lFree.id]) + }) + + test('groupSelectedLayers moves every selected layer into a fresh group', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l1 = s.addLayer(a.id)! + const l2 = s.addLayer(a.id)! + const l3 = s.addLayer(a.id)! + s.selectLayer(l1.id) + s.toggleLayerSelection(l2.id) + s.toggleLayerSelection(l3.id) + const groupId = s.groupSelectedLayers()! + expect(groupId).toBeTruthy() + const g = s.scene.groups!.find((x) => x.id === groupId)! + expect(g.childIds.includes(l1.id)).toBe(true) + expect(g.childIds.includes(l2.id)).toBe(true) + expect(g.childIds.includes(l3.id)).toBe(true) + // The layers also get removed from the top-level trackOrder + // since moveTrackIntoGroup handles that. + const order = s.scene.trackOrder ?? [] + expect(order.includes(l1.id)).toBe(false) + expect(order.includes(l2.id)).toBe(false) + expect(order.includes(l3.id)).toBe(false) + expect(order.includes(groupId)).toBe(true) + }) + + test('groupSelectedLayers returns null with an empty selection', () => { + const s = makeFreshStore() + const groupsBefore = (s.scene.groups ?? []).length + const result = s.groupSelectedLayers() + expect(result).toBe(null) + // Nothing was created. + expect((s.scene.groups ?? []).length).toBe(groupsBefore) + }) + + test('groupSelectedLayers replaces the topmost selected track\'s stack position', () => { + // addLayer inserts at the top (index 0), so after three addLayer + // calls trackOrder reads top→bottom: [l3, l2, l1]. Picking l2 + // and l1 to group (two layers NOT at the top) should produce a + // group at index 1, not at the top of the stack. + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l1 = s.addLayer(a.id)! + const l2 = s.addLayer(a.id)! + const l3 = s.addLayer(a.id)! + const before = [...s.scene.trackOrder!] + expect(before).toEqual([l3.id, l2.id, l1.id]) + s.selectLayer(l2.id) + s.toggleLayerSelection(l1.id) + const groupId = s.groupSelectedLayers()! + const after = s.scene.trackOrder! + // Group sits where l2 used to be (index 1); l3 still at top; + // l1 and l2 are no longer top-level. + expect(after[0]).toBe(l3.id) + expect(after[1]).toBe(groupId) + expect(after.includes(l1.id)).toBe(false) + expect(after.includes(l2.id)).toBe(false) + }) + + test('convertSelectedLayersToClip packs selected layers into a fresh clip + instance', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + // Give each layer a unique name up-front so we can look them up + // unambiguously inside the packed clip. Bare `addLayer(asset.id)` + // falls back to the asset name — three layers would all read "R" + // and `find(l => l.name === ...)` would return the first match. + const l1 = s.addLayer(a.id, 'one')! + const l2 = s.addLayer(a.id, 'two')! + const l3 = s.addLayer(a.id, 'three')! + // Seed a non-default keyframe so we can confirm the clip owns an + // independent copy of the original layer's data. + s.upsertKeyframe(l2.id, 1.5, { x: 123 }) + const kfCount = l2.keyframes.length + s.selectLayer(l1.id) + s.toggleLayerSelection(l2.id) + expect(s.selectedLayerIds.length).toBe(2) + + const clipId = s.convertSelectedLayersToClip('My Clip')! + expect(clipId).toBeTruthy() + + // Clip exists with the packed layers and a duration that matches + // the scene so absolute kf times still land where they were. + const clip = s.scene.clips!.find((c) => c.id === clipId)! + expect(clip.name).toBe('My Clip') + expect(clip.duration).toBe(s.scene.duration) + expect(clip.layers.length).toBe(2) + // The kf data survives the round-trip. + const clonedL2 = clip.layers.find((l) => l.name === 'two')! + expect(clonedL2).toBeTruthy() + expect(clonedL2.keyframes.length).toBe(kfCount) + expect(clonedL2.keyframes.some((k) => Math.abs(k.time - 1.5) < 1e-6)).toBe(true) + + // Originals removed from scene.layers + trackOrder. + expect(s.scene.layers.some((l) => l.id === l1.id)).toBe(false) + expect(s.scene.layers.some((l) => l.id === l2.id)).toBe(false) + expect(s.scene.layers.some((l) => l.id === l3.id)).toBe(true) + expect(s.scene.trackOrder!.includes(l1.id)).toBe(false) + expect(s.scene.trackOrder!.includes(l2.id)).toBe(false) + + // Exactly one clipTrack + one instance created; the instance + // sits at startTime 0 so the packed layers' absolute kf times + // continue to fire at their original scene times. + const clipTrackIds = (s.scene.clipTracks ?? []).map((t) => t.id) + const matching = (s.scene.clipInstances ?? []).filter( + (ci) => ci.clipId === clipId, + ) + expect(matching.length).toBe(1) + expect(clipTrackIds.includes(matching[0].clipTrackId)).toBe(true) + expect(matching[0].startTime).toBe(0) + + // The clone is independent: mutating the clip's layer doesn't + // affect the source (l2 is gone, but the data pointer shouldn't + // have been shared). + clonedL2.keyframes[0].time = 99 + // (no crash means the refs are decoupled; l2 is already gone) + }) + + test('convertSelectedLayersToClip drops the clipTrack at the topmost selected slot', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const l1 = s.addLayer(a.id)! + const l2 = s.addLayer(a.id)! + const l3 = s.addLayer(a.id)! + // addLayer prepends → trackOrder reads top-to-bottom [l3, l2, l1]. + s.selectLayer(l2.id) + s.toggleLayerSelection(l1.id) + s.convertSelectedLayersToClip() + const order = s.scene.trackOrder ?? [] + // l3 is still at the top (index 0); the clipTrack replaces l2's + // old slot (index 1); l1's slot is collapsed away. + expect(order[0]).toBe(l3.id) + expect(order.length).toBe(2) + const clipTrackId = (s.scene.clipTracks ?? []).slice(-1)[0].id + expect(order[1]).toBe(clipTrackId) + }) + + test('convertSelectedLayersToClip returns null with an empty selection', () => { + const s = makeFreshStore() + expect(s.convertSelectedLayersToClip()).toBe(null) + expect((s.scene.clips ?? []).length).toBe(0) + expect((s.scene.clipTracks ?? []).length).toBe(0) + expect((s.scene.clipInstances ?? []).length).toBe(0) + }) + + test('convertSelectedLayersToClip skips effectively-locked layers', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const lFree = s.addLayer(a.id)! + const lLocked = s.addLayer(a.id)! + s.setTrackLocked(lLocked.id, true) + s.selectedLayerIds = [lFree.id, lLocked.id] + s.selectedLayerId = lLocked.id + const clipId = s.convertSelectedLayersToClip()! + const clip = s.scene.clips!.find((c) => c.id === clipId)! + expect(clip.layers.length).toBe(1) + expect(clip.layers[0].name).toBe(lFree.name) + // Locked layer stays put. + expect(s.scene.layers.some((l) => l.id === lLocked.id)).toBe(true) + }) + + test('groupSelectedLayers skips layers that are effectively locked', () => { + // Locked ones shouldn't land in the fresh group — they stay + // wherever they were. + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const lFree1 = s.addLayer(a.id)! + const lLocked = s.addLayer(a.id)! + const lFree2 = s.addLayer(a.id)! + s.setTrackLocked(lLocked.id, true) + // Locking a layer deselects it, so toggleLayerSelection also + // silently refuses; build the selection manually to exercise + // the filter inside `groupSelectedLayers`. + s.selectedLayerIds = [lFree1.id, lLocked.id, lFree2.id] + s.selectedLayerId = lFree2.id + const groupId = s.groupSelectedLayers()! + const g = s.scene.groups!.find((x) => x.id === groupId)! + expect(g.childIds.includes(lFree1.id)).toBe(true) + expect(g.childIds.includes(lFree2.id)).toBe(true) + expect(g.childIds.includes(lLocked.id)).toBe(false) + // The locked layer still sits in the top-level track order. + expect(s.scene.trackOrder!.includes(lLocked.id)).toBe(true) + }) }) suite('scene store — keyframes', () => { diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index bd6d7c7..f555acf 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -225,6 +225,8 @@ export const en = { confirm: 'Apply', }, ungroup: 'Ungroup', + groupSelected: 'Group {n} selected layers', + convertToClip: 'Convert {n} selected to clip', renameGroup: 'Rename group', renameScriptTrack: 'Rename script track', resizeTrackColumn: 'Drag to resize track name column', diff --git a/src/stores/scene.ts b/src/stores/scene.ts index 5bf5338..34bf373 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -1,4 +1,5 @@ import { defineStore } from 'pinia' +import { toRaw } from 'vue' import type { Action, AnimatableProps, @@ -64,7 +65,14 @@ export type ButtonEditMode = 'states' | 'hit' interface State { scene: Scene + /** Primary (anchor) selection — drives the Properties panel and + * any code that expects a single focused layer. Always matches + * the last entry of `selectedLayerIds`. */ selectedLayerId: string | null + /** Multi-selection. Order is click order — the last entry is the + * anchor. Used by group-selected / delete-selected and any + * operation that needs the whole set. */ + selectedLayerIds: string[] playhead: number playing: boolean activeClipId: string | null @@ -259,6 +267,7 @@ export const useSceneStore = defineStore('scene', { state: (): State => ({ scene: createBlankScene(), selectedLayerId: null, + selectedLayerIds: [], playhead: 0, playing: false, activeClipId: null, @@ -407,6 +416,7 @@ export const useSceneStore = defineStore('scene', { if (!order.includes(layer.id)) order.unshift(layer.id) } this.selectedLayerId = layer.id + this.selectedLayerIds = [layer.id] return layer }, removeLayer(id: string) { @@ -416,7 +426,12 @@ export const useSceneStore = defineStore('scene', { if (!this.activeClipId && this.scene.trackOrder) { this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id) } - if (this.selectedLayerId === id) this.selectedLayerId = null + if (this.selectedLayerId === id) { + this.selectedLayerId = null + this.selectedLayerIds = [] + } else if (this.selectedLayerIds.includes(id)) { + this.selectedLayerIds = this.selectedLayerIds.filter((x) => x !== id) + } }, moveLayer(fromIndex: number, toIndex: number) { const arr = this.activeLayers @@ -438,6 +453,29 @@ export const useSceneStore = defineStore('scene', { }, selectLayer(id: string | null) { this.selectedLayerId = id + this.selectedLayerIds = id ? [id] : [] + }, + /** Shift-click behaviour: add the layer to the selection if it's + * not already there; otherwise remove it. The anchor moves to + * the most-recently-clicked id so the Properties panel follows + * the user's last touch. Layers that can't be selected (locked + * directly or through a locked group) are skipped. */ + toggleLayerSelection(id: string) { + const layer = this.activeLayers.find((l) => l.id === id) + if (!layer) return + if (layer.locked) return + for (const g of this.scene.groups ?? []) { + if (g.locked && g.childIds.includes(id)) return + } + const existing = this.selectedLayerIds.indexOf(id) + if (existing >= 0) { + this.selectedLayerIds.splice(existing, 1) + this.selectedLayerId = + this.selectedLayerIds[this.selectedLayerIds.length - 1] ?? null + } else { + this.selectedLayerIds.push(id) + this.selectedLayerId = id + } }, setPlayhead(t: number) { this.playhead = Math.max(0, Math.min(this.activeDuration, t)) @@ -1269,15 +1307,18 @@ export const useSceneStore = defineStore('scene', { const sel = this.activeLayers.find((l) => l.id === id) if (!sel) { this.selectedLayerId = null + this.selectedLayerIds = [] return } if (sel.locked) { this.selectedLayerId = null + this.selectedLayerIds = [] return } for (const g of this.scene.groups ?? []) { if (g.locked && g.childIds.includes(id)) { this.selectedLayerId = null + this.selectedLayerIds = [] return } } @@ -1425,6 +1466,7 @@ export const useSceneStore = defineStore('scene', { this.activeButtonState = 'normal' this.activeClipId = null this.selectedLayerId = null + this.selectedLayerIds = [] this.playhead = BUTTON_STATE_TIMES.normal this.playing = false }, @@ -1432,6 +1474,7 @@ export const useSceneStore = defineStore('scene', { if (!this.activeButtonAssetId) return this.activeButtonEditMode = mode this.selectedLayerId = null + this.selectedLayerIds = [] if (mode === 'states') { // Landing back in states-mode: keep the last content state (or // default to normal) and sync the playhead to its kf-time. @@ -1453,12 +1496,14 @@ export const useSceneStore = defineStore('scene', { // state's fixed time so existing kf mutation paths target it. this.playhead = BUTTON_STATE_TIMES[state] this.selectedLayerId = null + this.selectedLayerIds = [] }, exitButtonAssetEdit() { this.activeButtonAssetId = null this.activeButtonEditMode = null this.activeButtonState = null this.selectedLayerId = null + this.selectedLayerIds = [] this.playhead = 0 this.playing = false }, @@ -1571,6 +1616,7 @@ export const useSceneStore = defineStore('scene', { loadScene(scene: Scene) { this.scene = normalizeScene(scene) this.selectedLayerId = null + this.selectedLayerIds = [] this.playhead = 0 this.playing = false this.activeClipId = null @@ -1589,6 +1635,89 @@ export const useSceneStore = defineStore('scene', { if (!this.scene.clipTracks?.length) this.addClipTrack() return clip }, + /** Pull the currently-selected (unlocked, top-level) layers out of + * `scene.layers`, pack them into a brand-new Clip, and drop a + * single ClipInstance on a fresh ClipTrack at the topmost + * selected track's stack position. The clip instance starts at + * `startTime = 0` and shares the scene duration — so layer kfs + * keep their absolute times and on-stage visuals are unchanged. + * Returns the new clip id, or null if the selection is empty. */ + convertSelectedLayersToClip(name?: string): string | null { + const ids = this.selectedLayerIds.filter((id) => { + const layer = this.scene.layers.find((l) => l.id === id) + if (!layer) return false + if (layer.locked) return false + for (const g of this.scene.groups ?? []) { + if (g.locked && g.childIds.includes(id)) return false + } + return true + }) + if (ids.length === 0) return null + + // Topmost index in trackOrder among the layers we're about to + // fold away. If a selected layer is already inside a group it + // doesn't appear in trackOrder directly, so it can't anchor + // the new clipTrack's position — we fall back to "top" if + // nothing anchors. + const order = (this.scene.trackOrder ||= []) + let insertAt = Infinity + for (const id of ids) { + const i = order.indexOf(id) + if (i >= 0 && i < insertAt) insertAt = i + } + + // Deep-clone the layers so the clip owns independent kf/base + // data. `toRaw` strips the Vue/Pinia reactive proxy first so + // `structuredClone` sees the underlying plain object graph + // (structuredClone refuses to walk a Vue proxy's invisible + // internals in some edge cases). + const clonedLayers = ids + .map((id) => this.scene.layers.find((l) => l.id === id)) + .filter((l): l is Layer => !!l) + .map((l) => structuredClone(toRaw(l)) as Layer) + + const clip: Clip = { + id: uid(), + name: name?.trim() || 'Clip', + duration: this.scene.duration, + layers: clonedLayers, + } + ;(this.scene.clips ||= []).push(clip) + + // Strip the originals out of scene.layers, trackOrder, and any + // group they were part of. Clearing in one pass keeps Pinia's + // reactivity from thrashing on intermediate states. + const idSet = new Set(ids) + this.scene.layers = this.scene.layers.filter((l) => !idSet.has(l.id)) + this.scene.trackOrder = order.filter((x) => !idSet.has(x)) + for (const g of this.scene.groups ?? []) { + g.childIds = g.childIds.filter((x) => !idSet.has(x)) + } + + // Fresh clip track, sat at the anchor slot instead of + // appended to the end (addClipTrack's default). + const track: ClipTrack = { id: uid() } + ;(this.scene.clipTracks ||= []).push(track) + const refreshedOrder = (this.scene.trackOrder ||= []) + if (insertAt === Infinity || insertAt > refreshedOrder.length) { + refreshedOrder.push(track.id) + } else { + refreshedOrder.splice(insertAt, 0, track.id) + } + + const instance: ClipInstance = { + id: uid(), + clipId: clip.id, + clipTrackId: track.id, + startTime: 0, + } + ;(this.scene.clipInstances ||= []).push(instance) + + this.selectedLayerId = null + this.selectedLayerIds = [] + + return clip.id + }, removeClip(id: string) { if (this.scene.clips) { this.scene.clips = this.scene.clips.filter((c) => c.id !== id) @@ -1599,6 +1728,7 @@ export const useSceneStore = defineStore('scene', { if (this.activeClipId === id) { this.activeClipId = null this.selectedLayerId = null + this.selectedLayerIds = [] this.playhead = 0 } }, @@ -1689,6 +1819,53 @@ export const useSceneStore = defineStore('scene', { ;(this.scene.trackOrder ||= []).unshift(group.id) return group }, + /** Create a new group and move every currently-selected layer + * into it. Returns the new group's id, or null if no layers are + * selected. Skips layers that are locked (directly or via a + * parent group). The new group replaces the topmost selected + * track's position in the stack — so grouping a run of layers + * sitting in the middle of the timeline doesn't jump the whole + * group up to the top. */ + groupSelectedLayers(name?: string): string | null { + const ids = this.selectedLayerIds.filter((id) => { + const layer = this.scene.layers.find((l) => l.id === id) + if (!layer) return false + if (layer.locked) return false + for (const g of this.scene.groups ?? []) { + if (g.locked && g.childIds.includes(id)) return false + } + return true + }) + if (ids.length === 0) return null + + // Compute the topmost (smallest) trackOrder index among the + // selected layers BEFORE we touch anything. This is where the + // new group will land. Anything outside trackOrder (layer is + // already inside another group) doesn't constrain the anchor. + const order = (this.scene.trackOrder ||= []) + let insertAt = Infinity + for (const id of ids) { + const i = order.indexOf(id) + if (i >= 0 && i < insertAt) insertAt = i + } + + const group = this.addTrackGroup(name) + // addTrackGroup unshifted the group onto trackOrder[0] — pluck + // it back out and splice into the anchor slot. The layers sit + // at their original indices at this point; moveTrackIntoGroup + // below removes them from trackOrder one by one, collapsing + // the stack around the group we've just positioned. + if (insertAt !== Infinity) { + const gIdx = order.indexOf(group.id) + if (gIdx >= 0) order.splice(gIdx, 1) + order.splice(insertAt, 0, group.id) + } + + for (const id of ids) { + this.moveTrackIntoGroup(id, group.id) + } + return group.id + }, removeTrackGroup(id: string) { const group = this.scene.groups?.find((g) => g.id === id) if (!group) return @@ -1754,12 +1931,14 @@ export const useSceneStore = defineStore('scene', { this.activeButtonEditMode = null this.activeButtonState = null this.selectedLayerId = null + this.selectedLayerIds = [] this.playhead = 0 this.playing = false }, exitClip() { this.activeClipId = null this.selectedLayerId = null + this.selectedLayerIds = [] this.playhead = 0 this.playing = false }, diff --git a/src/version.ts b/src/version.ts index 8f1d38c..ba66f6a 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.8.4' +export const APP_VERSION = '0.8.5' export const APP_NAME = 'RUWIO' diff --git a/todo b/todo index c73e95f..9937a18 100644 --- a/todo +++ b/todo @@ -157,12 +157,18 @@ kann man bei den button-states 200ms transitions einbauen? elemente in einer gelockten gruppe kann man bearbeiten. bearbeitungsmöglichkeit muss sofort entzogen werden wenn eine gruppe gesperrt ist +wir brauchen mehrfachselection von tracks (shift-click) und gruppiegen. also rechtsklick -> group + +wir brauchen mehrfachselection von tracks und konvertierung in clip. also rechtsklick -> convert to clip +dann werden die tracks direkt als neuer clip an ort und stelle eingefügt + + ---- -wir brauchen mehrfachselection von tracks (shift-click) und gruppiegen. also rechtsklick -> group +