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) <noreply@anthropic.com>
This commit is contained in:
1 parent
63042b284d
commit
69de48ea8d
10 files changed
+522
-35
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user