From 69de48ea8d1a71a685d6e2b436b8d13462a59beb Mon Sep 17 00:00:00 2001 From: th Date: Mon, 20 Apr 2026 16:42:39 +0200 Subject: [PATCH] 0.5.6: show/hide locked tracks + group lock + crop/mask-preview split - Timeline: new show/hide toggle next to snap; locked tracks (including layer, clip, and the new group lock) are filtered from the timeline when the toggle is off. Preference lives in localStorage and is shared across components via a module-level ref. - Groups become lockable via the same inline lock toggle. A locked group propagates to its children: they behave as effectively locked in the timeline and on the stage. Collapse/expand still works on a locked group. - Canvas: layers in locked+hidden tracks render as a 1px outline in the layer (or fill) color instead of their full fill, and are draggable:false / listening:false. Mask layers keep their dashed ghost styling; masks still apply in the preview overlay regardless of lock or show state. - drawSceneFrame gains an optional `{ crop }` option; the mask-preview overlay passes the editor's crop toggle through, so the two controls are fully independent. Crop changes now also trigger a preview redraw via a watcher dep. - Tests: new suites for show-locked persistence, group lock store action, crop option on drawSceneFrame (including mask+crop combos). Properties transparent-toggle tests now reset the section prefs localStorage so the Appearance section is guaranteed open when the panel mounts. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/components/CanvasStage.vue | 121 ++++++++++++++- src/components/Timeline.vue | 143 +++++++++++++++--- src/composables/useShowLocked.ts | 24 +++ src/devtools/tests.ts | 246 +++++++++++++++++++++++++++++++ src/i18n/messages.ts | 2 + src/lib/render.ts | 10 +- src/stores/scene.ts | 6 + src/types/scene.ts | 1 + src/version.ts | 2 +- 10 files changed, 522 insertions(+), 35 deletions(-) create mode 100644 src/composables/useShowLocked.ts diff --git a/package.json b/package.json index f9bd2f2..84ade20 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "animatr", "private": true, - "version": "0.5.5", + "version": "0.5.6", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 6490841..3023fce 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -10,6 +10,7 @@ import { drawSceneFrame, loadSceneImages } from '@/lib/render' import type { AnimatableProps, Asset, Layer, Point } from '@/types/scene' import { t } from '@/i18n' import { useKfSelection } from '@/composables/useKfSelection' +import { showLocked } from '@/composables/useShowLocked' import { expandedTrackOrder } from '@/lib/sceneOrder' const store = useSceneStore() @@ -257,6 +258,24 @@ function shadowCfg(props: AnimatableProps): Record { } } +function isLayerEffectivelyLocked(layer: Layer): boolean { + if (layer.locked) return true + for (const g of store.scene.groups ?? []) { + if (g.locked && g.childIds.includes(layer.id)) return true + } + return false +} + +function isLayerGhostLocked(layer: Layer): boolean { + return isLayerEffectivelyLocked(layer) && !showLocked.value +} + +function ghostLockColor(layer: Layer, fallback?: string | null): string { + if (layer.color) return layer.color + if (fallback && fallback !== 'transparent') return fallback + return '#6ea8ff' +} + function baseNodeConfig(layer: Layer, props: AnimatableProps) { const maskGhost = layer.isMask ? { @@ -267,6 +286,10 @@ function baseNodeConfig(layer: Layer, props: AnimatableProps) { strokeScaleEnabled: false, } : {} + // Any locked layer (direct or via a locked group) is inert on the stage. + // When the timeline also hides locked tracks the per-shape config switches + // to a thin outline — handled by isLayerGhostLocked() checks per shape. + const effectivelyLocked = isLayerEffectivelyLocked(layer) return { id: layer.id, x: props.x, @@ -276,7 +299,8 @@ function baseNodeConfig(layer: Layer, props: AnimatableProps) { rotation: props.rotation, opacity: props.opacity, visible: layer.visible, - draggable: true, + draggable: !effectivelyLocked, + listening: !effectivelyLocked, ...shadowCfg(props), ...maskGhost, } @@ -343,8 +367,22 @@ function strokeCfg( function rectConfig(layer: Layer, asset: Extract, props: AnimatableProps) { const fill = props.fill ?? asset.fill const blur = props.blur ?? asset.blur ?? 0 + const base = baseNodeConfig(layer, props) + if (isLayerGhostLocked(layer)) { + return { + ...base, + width: asset.width, + height: asset.height, + offsetX: layer.offsetX ?? asset.width / 2, + offsetY: layer.offsetY ?? asset.height / 2, + fill: undefined, + stroke: ghostLockColor(layer, fill), + strokeWidth: 1, + strokeScaleEnabled: false, + } + } const cfg: Record = { - ...baseNodeConfig(layer, props), + ...base, width: asset.width, height: asset.height, offsetX: layer.offsetX ?? asset.width / 2, @@ -363,8 +401,21 @@ function circleConfig( ) { const fill = props.fill ?? asset.fill const blur = props.blur ?? asset.blur ?? 0 + const base = baseNodeConfig(layer, props) + if (isLayerGhostLocked(layer)) { + return { + ...base, + radius: asset.radius, + offsetX: layer.offsetX ?? 0, + offsetY: layer.offsetY ?? 0, + fill: undefined, + stroke: ghostLockColor(layer, fill), + strokeWidth: 1, + strokeScaleEnabled: false, + } + } const cfg: Record = { - ...baseNodeConfig(layer, props), + ...base, radius: asset.radius, offsetX: layer.offsetX ?? 0, offsetY: layer.offsetY ?? 0, @@ -377,8 +428,22 @@ function circleConfig( function textConfig(layer: Layer, asset: Extract, props: AnimatableProps) { const fill = props.fill ?? asset.fill + const base = baseNodeConfig(layer, props) + if (isLayerGhostLocked(layer)) { + return { + ...base, + text: asset.text, + fontSize: asset.fontSize, + offsetX: layer.offsetX ?? 0, + offsetY: layer.offsetY ?? 0, + fill: undefined, + stroke: ghostLockColor(layer, fill), + strokeWidth: 1, + strokeScaleEnabled: false, + } + } const cfg: Record = { - ...baseNodeConfig(layer, props), + ...base, text: asset.text, fontSize: asset.fontSize, offsetX: layer.offsetX ?? 0, @@ -451,8 +516,29 @@ function polygonConfig( flat.push(p.x - offX, p.y - offY) } const hasCurves = asset.points.some((p) => p.type && p.type !== 'corner') + const base = baseNodeConfig(layer, props) + if (isLayerGhostLocked(layer)) { + const ghostCfg: Record = { + ...base, + points: flat, + closed, + lineJoin: 'round', + lineCap: 'round', + fill: undefined, + stroke: ghostLockColor(layer, fill), + strokeWidth: 1, + strokeScaleEnabled: false, + } + if (hasCurves) { + ghostCfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => { + traceKonvaPolyPath(ctx, pts, asset.points, offX, offY, closed) + ctx.fillStrokeShape(shape) + } + } + return ghostCfg + } const cfg: Record = { - ...baseNodeConfig(layer, props), + ...base, points: flat, closed, lineJoin: 'round', @@ -487,6 +573,26 @@ function imageConfig( ) { const img = imageCache.value[asset.id] if (!img) return null + if (isLayerGhostLocked(layer)) { + // Show a 1px outline of the image's bounding box instead of the pixels. + return { + ...baseNodeConfig(layer, props), + width: asset.width, + height: asset.height, + offsetX: layer.offsetX ?? asset.width / 2, + offsetY: layer.offsetY ?? asset.height / 2, + fill: undefined, + stroke: ghostLockColor(layer, null), + strokeWidth: 1, + strokeScaleEnabled: false, + sceneFunc: (ctx: KonvaCtx, shape: KonvaShape) => { + ctx.beginPath() + ctx.rect(0, 0, asset.width, asset.height) + ctx.closePath() + ctx.fillStrokeShape(shape) + }, + } + } return { ...baseNodeConfig(layer, props), image: img, @@ -1891,7 +1997,9 @@ function drawPreview() { ctx.save() ctx.translate(viewX.value, viewY.value) ctx.scale(zoom.value, zoom.value) - drawSceneFrame(ctx, store.scene, store.playhead, previewImages.value) + drawSceneFrame(ctx, store.scene, store.playhead, previewImages.value, { + crop: cropObjects.value, + }) ctx.restore() } @@ -1903,6 +2011,7 @@ watch( zoom, viewX, viewY, + cropObjects, ], async () => { if (!maskPreview.value) return diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 99a5bb2..145ff08 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -11,6 +11,8 @@ import { PhFolderPlus, PhLock, PhLockOpen, + PhEye, + PhEyeSlash, PhMagnet, PhMaskHappy, PhPauseCircle, @@ -22,6 +24,7 @@ import { snapToCandidates } from '@/lib/timelineSnap' import { confirmDialog, promptDialog } from '@/components/ui' import { t } from '@/i18n' import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composables/useKfSelection' +import { showLocked } from '@/composables/useShowLocked' import type { AnimatableProps, Clip, @@ -62,6 +65,7 @@ interface TimelineGroupTrack { name: string collapsed: boolean color: string | null + locked: boolean orderIndex: number childCount: number } @@ -187,6 +191,7 @@ type DisplayMode = 'seconds' | 'frames' const displayMode = ref('seconds') const snapEnabled = ref(true) + const SNAP_PX = 8 function allKeyframeTimes(ignoreKeyframeId?: string): number[] { @@ -387,6 +392,27 @@ function emitLayerTrack( } } +const lockedGroupChildIds = computed(() => { + const s = new Set() + for (const g of store.scene.groups ?? []) { + if (g.locked) for (const cid of g.childIds) s.add(cid) + } + return s +}) + +function isTrackEffectivelyLocked(track: TimelineTrack): boolean { + if (track.type === 'layer') { + return !!track.layer.locked || lockedGroupChildIds.value.has(track.layerId) + } + if (track.type === 'clip') { + return ( + !!clipTrackById.value.get(track.clipTrackId)?.locked || + lockedGroupChildIds.value.has(track.clipTrackId) + ) + } + return track.locked +} + const tracks = computed(() => { const out: TimelineTrack[] = [] if (store.activeClipId) { @@ -417,6 +443,7 @@ const tracks = computed(() => { name: grp.name, collapsed: !!grp.collapsed, color: grp.color ?? null, + locked: !!grp.locked, orderIndex, childCount: grp.childIds.length, }) @@ -443,7 +470,29 @@ const tracks = computed(() => { } } }) - return out + if (showLocked.value) return out + const lockedGroupChildIds = new Set() + const lockedGroupIds = new Set() + for (const g of store.scene.groups ?? []) { + if (g.locked) { + lockedGroupIds.add(g.id) + for (const cid of g.childIds) lockedGroupChildIds.add(cid) + } + } + return out.filter((tr) => { + if (tr.type === 'layer') { + if (tr.layer.locked) return false + if (lockedGroupChildIds.has(tr.layerId)) return false + return true + } + if (tr.type === 'clip') { + if (clipTrackById.value.get(tr.clipTrackId)?.locked) return false + if (lockedGroupChildIds.has(tr.clipTrackId)) return false + return true + } + // group: hide when locked + return !lockedGroupIds.has(tr.groupId) + }) }) function trackDndId(track: TimelineTrack): string { @@ -1523,6 +1572,17 @@ onBeforeUnmount(() => { > +
{ .track.locked { height: 15px; + background: color-mix(in srgb, #ff6b6b 8%, transparent); } .track.clip-track.locked { @@ -2322,6 +2410,7 @@ onBeforeUnmount(() => { .track.locked .track-name { font-size: 10px; + background: color-mix(in srgb, #ff6b6b 8%, var(--panel)); } .track.locked .track-lane { @@ -2361,6 +2450,12 @@ onBeforeUnmount(() => { pointer-events: auto; } +/* Locked groups stay collapsible — re-enable pointer events on the name */ +.track.group-track.locked .track-name { + pointer-events: auto; + cursor: pointer; +} + .lock-btn { margin-left: auto; display: inline-flex; diff --git a/src/composables/useShowLocked.ts b/src/composables/useShowLocked.ts new file mode 100644 index 0000000..b714aa4 --- /dev/null +++ b/src/composables/useShowLocked.ts @@ -0,0 +1,24 @@ +import { ref, watch } from 'vue' + +const KEY = 'animatr:showLockedTracks' + +function read(): boolean { + try { + const v = localStorage.getItem(KEY) + return v === null ? true : v === '1' + } catch { + return true + } +} + +// Module-level shared ref so Timeline, CanvasStage and anything else can +// observe the same "show locked tracks" preference. +export const showLocked = ref(read()) + +watch(showLocked, (v) => { + try { + localStorage.setItem(KEY, v ? '1' : '0') + } catch { + /* ignore */ + } +}) diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 7201607..d6a196a 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -2403,7 +2403,18 @@ export function registerAllTests(): void { ) } + function resetSectionPrefs() { + // Ensure the Appearance section is open (default) regardless of any + // previous session's state persisted in localStorage. + try { + localStorage.removeItem('animatr:propSections') + } catch { + /* noop */ + } + } + test('fill: toggle transparent on writes "transparent" to keyframe', async () => { + resetSectionPrefs() const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', @@ -2428,6 +2439,7 @@ export function registerAllTests(): void { }) test('fill: toggle off restores the previous non-transparent color', async () => { + resetSectionPrefs() const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', @@ -2459,6 +2471,7 @@ export function registerAllTests(): void { }) test('stroke: toggle on then off restores to a visible color (auto-black fallback)', async () => { + resetSectionPrefs() const s = makeFreshStore() // Rect without an explicit stroke — the restore should fall back to black const a = s.addAsset({ @@ -2488,6 +2501,7 @@ export function registerAllTests(): void { }) test('fill: toggle off respects a user-picked color before transparency', async () => { + resetSectionPrefs() const s = makeFreshStore() const a = s.addAsset({ kind: 'rect', @@ -2515,4 +2529,236 @@ export function registerAllTests(): void { handle.unmount() }) }) + + suite('timeline — show locked tracks', () => { + test('toggling showLocked persists to localStorage', async () => { + const { showLocked } = await import('@/composables/useShowLocked') + const KEY = 'animatr:showLockedTracks' + showLocked.value = true + await tick() + expect(localStorage.getItem(KEY)).toBe('1') + showLocked.value = false + await tick() + expect(localStorage.getItem(KEY)).toBe('0') + showLocked.value = true + }) + + test('showLocked ref is shared across importers', async () => { + const a = await import('@/composables/useShowLocked') + const b = await import('@/composables/useShowLocked') + // Same module → same ref instance + expect(a.showLocked).toBe(b.showLocked) + a.showLocked.value = false + await tick() + expect(b.showLocked.value).toBe(false) + a.showLocked.value = true + }) + }) + + suite('scene store — group lock', () => { + test('setTrackLocked on a group id sets group.locked', () => { + const s = makeFreshStore() + const g = s.addTrackGroup('G') + s.setTrackLocked(g.id, true) + const grp = s.scene.groups!.find((x) => x.id === g.id)! + expect(grp.locked).toBe(true) + }) + + test('setTrackLocked(false) clears group.locked', () => { + const s = makeFreshStore() + const g = s.addTrackGroup('G') + s.setTrackLocked(g.id, true) + s.setTrackLocked(g.id, false) + const grp = s.scene.groups!.find((x) => x.id === g.id)! + expect(grp.locked).toBe(undefined) + }) + + test('setTrackLocked dispatches to layer, clip or group based on id', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' }) + const l = s.addLayer(a.id) + const ct = s.addClipTrack() + const g = s.addTrackGroup('G') + s.setTrackLocked(l.id, true) + s.setTrackLocked(ct.id, true) + s.setTrackLocked(g.id, true) + expect(l.locked).toBe(true) + const stored = s.scene.clipTracks!.find((x) => x.id === ct.id)! + expect(stored.locked).toBe(true) + const grp = s.scene.groups!.find((x) => x.id === g.id)! + expect(grp.locked).toBe(true) + }) + + test('locking a group does not mutate child layer.locked', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' }) + const l = s.addLayer(a.id) + const g = s.addTrackGroup('G') + s.moveTrackIntoGroup(l.id, g.id) + s.setTrackLocked(g.id, true) + // The layer itself stays unlocked; effective lock is derived in UI code. + expect(l.locked).toBe(undefined) + }) + }) + + suite('render — crop option', () => { + function setupStraddlingScene() { + const s = makeFreshStore() + s.scene.canvas.width = 100 + s.scene.canvas.height = 100 + s.scene.canvas.background = 'transparent' + const a = s.addAsset({ + kind: 'rect', + name: 'Wide', + width: 60, + height: 20, + fill: '#00ff00', + }) + const layer = s.addLayer(a.id) + // Centered just off the right edge — 40px of the rect spills past x=100. + s.upsertKeyframe(layer.id, 0, { x: 110, y: 50 }) + return s + } + + test('default crop clips content outside stage bounds', () => { + const s = setupStraddlingScene() + const canvas = document.createElement('canvas') + canvas.width = 200 + canvas.height = 100 + const ctx = canvas.getContext('2d')! + drawSceneFrame(ctx, s.scene, 0, {}) + // Sample a pixel ~20px past the right edge — still inside the rect's + // visual box but outside the stage. Should be cleared by the clip. + const outside = ctx.getImageData(120, 50, 1, 1).data + expect(outside[3]).toBe(0) + }) + + test('crop:false lets content render past the stage edge', () => { + const s = setupStraddlingScene() + const canvas = document.createElement('canvas') + canvas.width = 200 + canvas.height = 100 + const ctx = canvas.getContext('2d')! + drawSceneFrame(ctx, s.scene, 0, {}, { crop: false }) + const outside = ctx.getImageData(120, 50, 1, 1).data + // Green channel visible in the part of the rect that extends past the stage + expect(outside[1] > 200).toBe(true) + expect(outside[3] > 200).toBe(true) + }) + + test('crop:false still paints stage background inside stage bounds', () => { + const s = makeFreshStore() + s.scene.canvas.width = 100 + s.scene.canvas.height = 100 + s.scene.canvas.background = '#ff0000' + const canvas = document.createElement('canvas') + canvas.width = 100 + canvas.height = 100 + const ctx = canvas.getContext('2d')! + drawSceneFrame(ctx, s.scene, 0, {}, { crop: false }) + const inside = ctx.getImageData(50, 50, 1, 1).data + expect(inside[0] > 200).toBe(true) + expect(inside[3] > 200).toBe(true) + }) + + test('mask still applies when crop is disabled', () => { + // Same setup as the positive-mask render test but with crop:false — + // the mask should still clear content outside the mask shape, and the + // crop flag should only affect the stage clip, not the masked group. + const s = makeFreshStore() + s.scene.canvas.width = 100 + s.scene.canvas.height = 100 + s.scene.canvas.background = 'transparent' + const content = s.addAsset({ + kind: 'rect', + name: 'BG', + width: 100, + height: 100, + fill: '#ff0000', + }) + const contentLayer = s.addLayer(content.id) + s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 }) + const mask = s.addAsset({ + kind: 'circle', + name: 'M', + radius: 20, + fill: '#ffffff', + }) + const maskLayer = s.addLayer(mask.id) + s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 }) + s.setLayerMask(maskLayer.id, true, 'positive') + const group = s.addTrackGroup('G') + s.moveTrackIntoGroup(maskLayer.id, group.id) + s.moveTrackIntoGroup(contentLayer.id, group.id) + const canvas = document.createElement('canvas') + canvas.width = 100 + canvas.height = 100 + const ctx = canvas.getContext('2d')! + drawSceneFrame(ctx, s.scene, 0, {}, { crop: false }) + const inside = ctx.getImageData(50, 50, 1, 1).data + const outside = ctx.getImageData(5, 5, 1, 1).data + // Positive mask keeps content inside + expect(inside[0] > 200).toBe(true) + expect(inside[3] > 200).toBe(true) + // Outside mask cleared by the mask (even without stage clipping) + expect(outside[3]).toBe(0) + }) + + test('content outside the stage renders past the edge when crop:false and mask-like groups exist elsewhere', () => { + // A straddling rect + a totally separate positive-mask group. The + // straddling rect must still bleed past the stage edge and the mask + // must still work inside its own group — they don't interfere. + const s = makeFreshStore() + s.scene.canvas.width = 100 + s.scene.canvas.height = 100 + s.scene.canvas.background = 'transparent' + // Straddling rect — spills past right edge + const strad = s.addAsset({ + kind: 'rect', + name: 'Wide', + width: 60, + height: 20, + fill: '#00ff00', + }) + const sLayer = s.addLayer(strad.id) + s.upsertKeyframe(sLayer.id, 0, { x: 110, y: 50 }) + // Masked group elsewhere + const content = s.addAsset({ + kind: 'rect', + name: 'Blue', + width: 40, + height: 40, + fill: '#0000ff', + }) + const cLayer = s.addLayer(content.id) + s.upsertKeyframe(cLayer.id, 0, { x: 50, y: 50 }) + const mask = s.addAsset({ + kind: 'circle', + name: 'M', + radius: 10, + fill: '#fff', + }) + const mLayer = s.addLayer(mask.id) + s.upsertKeyframe(mLayer.id, 0, { x: 50, y: 50 }) + s.setLayerMask(mLayer.id, true, 'positive') + const group = s.addTrackGroup('G') + s.moveTrackIntoGroup(mLayer.id, group.id) + s.moveTrackIntoGroup(cLayer.id, group.id) + const canvas = document.createElement('canvas') + canvas.width = 200 + canvas.height = 100 + const ctx = canvas.getContext('2d')! + drawSceneFrame(ctx, s.scene, 0, {}, { crop: false }) + // Straddling rect's spill past x=100 is visible + const spill = ctx.getImageData(120, 50, 1, 1).data + expect(spill[1] > 200).toBe(true) + expect(spill[3] > 200).toBe(true) + // Mask still trims the blue rect outside its circle. + // (32, 50) is inside the blue rect (30–70) but outside the 10-radius + // mask circle (distance ~18), and well away from the green rect + // (80–140), so it must be cleared by the mask. + const maskOutside = ctx.getImageData(32, 50, 1, 1).data + expect(maskOutside[3]).toBe(0) + }) + }) } diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 8f82a62..cfb3dcf 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -173,6 +173,8 @@ export const en = { toggleTimeUnit: 'Toggle time unit', snapOn: 'Snapping on (playhead & keyframes snap together)', snapOff: 'Snapping off', + hideLocked: 'Hide locked tracks', + showLocked: 'Show locked tracks', stillFrameTooltip: 'Still frame {time} — holds until the next keyframe', keyframeTooltip: 'Keyframe {time} — drag to move, right-click for options', trimTooltip: '{name} · {duration}s{trims} · Drag edges to trim, double-click to open', diff --git a/src/lib/render.ts b/src/lib/render.ts index d2e7b18..fb8d058 100644 --- a/src/lib/render.ts +++ b/src/lib/render.ts @@ -333,14 +333,18 @@ export function drawSceneFrame( scene: Scene, time: number, images: Record, + options?: { crop?: boolean }, ) { const lw = scene.canvas.width const lh = scene.canvas.height + const crop = options?.crop !== false ctx.save() - ctx.beginPath() - ctx.rect(0, 0, lw, lh) - ctx.clip() + if (crop) { + ctx.beginPath() + ctx.rect(0, 0, lw, lh) + ctx.clip() + } ctx.fillStyle = scene.canvas.background ctx.fillRect(0, 0, lw, lh) diff --git a/src/stores/scene.ts b/src/stores/scene.ts index 6daf5ee..adb5145 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -957,6 +957,12 @@ export const useSceneStore = defineStore('scene', { if (clipTrack) { if (locked) clipTrack.locked = true else delete clipTrack.locked + return + } + const group = this.scene.groups?.find((g) => g.id === trackId) + if (group) { + if (locked) group.locked = true + else delete group.locked } }, setLayerSeparated(layerId: string, separated: boolean) { diff --git a/src/types/scene.ts b/src/types/scene.ts index 85740aa..773e3f0 100644 --- a/src/types/scene.ts +++ b/src/types/scene.ts @@ -142,6 +142,7 @@ export interface TrackGroup { childIds: string[] collapsed?: boolean color?: string | null + locked?: boolean } export interface ClipInstance { diff --git a/src/version.ts b/src/version.ts index 619f9c6..2ab41fc 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.5.5' +export const APP_VERSION = '0.5.6' export const APP_NAME = 'RUWIO'