0.18.2: centre crosshair + locked-track selection + eye-on-lock
- Optional centre crosshair: dashed horizontal + vertical lines through the effective canvas centre, togglable from the HUD next to the crop / mask-preview / stage-preview buttons. Scales with zoom and stays visible inside clip-edit. - Locked clip tracks now behave as if they weren't on the stage — the ci-group is listening:false + draggable:false, so selection, drag, and transform all pass through. deselectIfLocked also clears a clip-instance selection that just got locked out (directly or via a parent group). - Eye toggle stays clickable on locked layer / clip-track rows — previous pointer-events:none rule only whitelisted the lock button. Hide-in-editor is orthogonal to lock. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5af0cdb3d4
commit
21cf6e9a12
7 files changed
+371
-19
No files matched your search
@@ -6142,6 +6142,38 @@ export function registerAllTests(): void {
|
||||
expect(s.selectedClipInstanceId).toBe(null)
|
||||
})
|
||||
|
||||
test('locking a clip track drops selection of an instance on it', () => {
|
||||
// The transformer latches onto the ci-group while selection is
|
||||
// active. Locking the track makes the ci-group non-listening,
|
||||
// so the selection has to go too — otherwise the handles stay
|
||||
// visible on a group the user can no longer interact with.
|
||||
const s = makeFreshStore()
|
||||
const clip = s.addClip('CInst')
|
||||
clip.duration = 2
|
||||
const track = s.scene.clipTracks![0]
|
||||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||||
s.selectClipInstance(ci.id)
|
||||
expect(s.selectedClipInstanceId).toBe(ci.id)
|
||||
s.setTrackLocked(track.id, true)
|
||||
expect(s.selectedClipInstanceId).toBe(null)
|
||||
})
|
||||
|
||||
test('locking a group containing a clip track drops the instance selection', () => {
|
||||
const s = makeFreshStore()
|
||||
const clip = s.addClip('CInst')
|
||||
clip.duration = 2
|
||||
const track = s.scene.clipTracks![0]
|
||||
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||||
const g = s.addTrackGroup('G', track.id)
|
||||
g.childIds.push(track.id)
|
||||
const order = s.scene.trackOrder!
|
||||
const ti = order.indexOf(track.id)
|
||||
if (ti >= 0) order.splice(ti, 1)
|
||||
s.selectClipInstance(ci.id)
|
||||
s.setTrackLocked(g.id, true)
|
||||
expect(s.selectedClipInstanceId).toBe(null)
|
||||
})
|
||||
|
||||
test('hiding a track leaves selection on a different track alone', () => {
|
||||
const s = makeFreshStore()
|
||||
const clip = s.addClip('CInst')
|
||||
@@ -6209,6 +6241,65 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — Timeline eye button stays clickable on locked tracks', () => {
|
||||
test('locked layer row keeps the eye-btn pointer-events auto', async () => {
|
||||
// Regression: `.track.locked .track-name { pointer-events: none }`
|
||||
// was muting every button in the row; only `.lock-btn` was
|
||||
// re-enabled. The eye toggle has to stay clickable so the user
|
||||
// can hide / reveal a locked layer without unlocking first.
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.setTrackLocked(l.id, true)
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const lockedRow = handle.host.querySelector('.track.locked') as HTMLElement
|
||||
expect(lockedRow).toBeTruthy()
|
||||
const eye = lockedRow.querySelector('.track-name .eye-btn') as HTMLButtonElement
|
||||
expect(eye).toBeTruthy()
|
||||
const pe = getComputedStyle(eye).pointerEvents
|
||||
expect(pe).toBe('auto')
|
||||
// Sanity — the lane IS inert.
|
||||
const lane = lockedRow.querySelector('.track-lane') as HTMLElement
|
||||
expect(getComputedStyle(lane).pointerEvents).toBe('none')
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('locked clip-track row keeps the eye-btn pointer-events auto', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.addClip('CInst')
|
||||
const track = s.scene.clipTracks![0]
|
||||
s.setTrackLocked(track.id, true)
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const lockedRow = handle.host.querySelector('.track.clip-track.locked') as HTMLElement
|
||||
expect(lockedRow).toBeTruthy()
|
||||
const eye = lockedRow.querySelector('.track-name .eye-btn') as HTMLButtonElement
|
||||
expect(eye).toBeTruthy()
|
||||
expect(getComputedStyle(eye).pointerEvents).toBe('auto')
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('toggling editorHidden on a locked layer still writes the flag', async () => {
|
||||
// End-to-end: lock the layer, click its eye button, verify
|
||||
// editorHidden flipped. Complements the CSS test — together
|
||||
// they cover both the pointer-events rule and the store path.
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.setTrackLocked(l.id, true)
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const eye = handle.host.querySelector(
|
||||
'.track.locked .track-name .eye-btn',
|
||||
) as HTMLButtonElement
|
||||
eye.click()
|
||||
await nextTick()
|
||||
expect(l.editorHidden).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — Timeline clip-track eye button', () => {
|
||||
test('clicking the eye toggles editorHidden on the clip track', async () => {
|
||||
const s = makeFreshStore()
|
||||
@@ -7327,6 +7418,137 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — CanvasStage centre crosshair', () => {
|
||||
test('HUD has a crosshair toggle that flips the active class', async () => {
|
||||
makeFreshStore()
|
||||
const handle = mountComponent(CanvasStage, {})
|
||||
await nextTick()
|
||||
const btn = handle.host.querySelector(
|
||||
'.zoom-controls .center-cross-btn',
|
||||
) as HTMLButtonElement
|
||||
expect(btn).toBeTruthy()
|
||||
expect(btn.classList.contains('active')).toBe(false)
|
||||
btn.click()
|
||||
await nextTick()
|
||||
expect(btn.classList.contains('active')).toBe(true)
|
||||
btn.click()
|
||||
await nextTick()
|
||||
expect(btn.classList.contains('active')).toBe(false)
|
||||
handle.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — CanvasStage centre crosshair (Konva)', () => {
|
||||
// Verify the lines actually render at the correct coordinates in
|
||||
// Konva when the toggle is on. Requires a proper VueKonva mount.
|
||||
async function mountCanvasWithKonva() {
|
||||
const VueKonva = (await import('vue-konva')).default
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-test-host', '')
|
||||
host.style.width = '800px'
|
||||
host.style.height = '600px'
|
||||
document.body.appendChild(host)
|
||||
const app = createApp({ render() { return h(CanvasStage, {}) } })
|
||||
app.use(VueKonva)
|
||||
app.directive('tooltip', vTooltip)
|
||||
app.mount(host)
|
||||
return {
|
||||
host, app,
|
||||
unmount() { app.unmount(); host.remove() },
|
||||
}
|
||||
}
|
||||
async function getKonvaStage() {
|
||||
const Konva = (await import('konva')).default
|
||||
return Konva.stages[Konva.stages.length - 1]
|
||||
}
|
||||
|
||||
test('lines span the scene canvas and are off by default', async () => {
|
||||
const s = makeFreshStore()
|
||||
// Pin a deterministic canvas so the assertions are exact.
|
||||
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(40)
|
||||
const stage = await getKonvaStage()
|
||||
// Off by default — no lines from this feature should be in
|
||||
// the stack. Match by unique dash pattern fed into our
|
||||
// computed (hard-coded lengths don't appear elsewhere).
|
||||
const before = stage.find('Line') as unknown as Array<{
|
||||
listening: () => boolean
|
||||
}>
|
||||
const crosshairBefore = before.filter((ln) => ln.listening() === false)
|
||||
// This is fuzzy — just make sure the suite mounts cleanly.
|
||||
expect(Array.isArray(crosshairBefore)).toBe(true)
|
||||
const btn = handle.host.querySelector(
|
||||
'.zoom-controls .center-cross-btn',
|
||||
) as HTMLButtonElement
|
||||
btn.click()
|
||||
await nextTick()
|
||||
await tick(20)
|
||||
const horiz = stage.findOne('Line') as unknown as {
|
||||
points: () => number[]
|
||||
} | null
|
||||
// At least one line exists after the toggle fires; grab all
|
||||
// lines and confirm the pair running through (w/2, h/2).
|
||||
const all = stage.find('Line') as unknown as Array<{
|
||||
points: () => number[]
|
||||
}>
|
||||
const hasHorizontal = all.some((ln) => {
|
||||
const p = ln.points()
|
||||
return p.length === 4 && p[1] === 300 && p[3] === 300 && p[0] === 0 && p[2] === 800
|
||||
})
|
||||
const hasVertical = all.some((ln) => {
|
||||
const p = ln.points()
|
||||
return p.length === 4 && p[0] === 400 && p[2] === 400 && p[1] === 0 && p[3] === 600
|
||||
})
|
||||
expect(hasHorizontal).toBe(true)
|
||||
expect(hasVertical).toBe(true)
|
||||
void horiz
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('lines follow the clip canvas when the user enters a clip', async () => {
|
||||
// The crosshair uses effectiveCanvas, which for a scene (even
|
||||
// inside enterClip) is the scene canvas. Verify the lines
|
||||
// still render while clip-edit mode is active so the user has
|
||||
// the same compose guide inside a clip.
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas = { width: 640, height: 480, background: '#ffffff' }
|
||||
const clip = s.addClip('CInst')
|
||||
s.enterClip(clip.id)
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(40)
|
||||
const btn = handle.host.querySelector(
|
||||
'.zoom-controls .center-cross-btn',
|
||||
) as HTMLButtonElement
|
||||
btn.click()
|
||||
await nextTick()
|
||||
await tick(20)
|
||||
const stage = await getKonvaStage()
|
||||
const lines = stage.find('Line') as unknown as Array<{
|
||||
points: () => number[]
|
||||
}>
|
||||
const hasHorizontal = lines.some((ln) => {
|
||||
const p = ln.points()
|
||||
return p.length === 4 && p[1] === 240 && p[3] === 240 && p[0] === 0 && p[2] === 640
|
||||
})
|
||||
const hasVertical = lines.some((ln) => {
|
||||
const p = ln.points()
|
||||
return p.length === 4 && p[0] === 320 && p[2] === 320 && p[1] === 0 && p[3] === 480
|
||||
})
|
||||
expect(hasHorizontal).toBe(true)
|
||||
expect(hasVertical).toBe(true)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — CanvasStage clip-instance transform', () => {
|
||||
// mountComponent skips VueKonva, which is fine for DOM-overlay
|
||||
// tests but not for clip-instance selection: we need real Konva
|
||||
@@ -7593,6 +7815,32 @@ export function registerAllTests(): void {
|
||||
}
|
||||
})
|
||||
|
||||
test('locked clip track → ci-group is non-listening + non-draggable', async () => {
|
||||
// Regression guard: before this, a locked clip track still
|
||||
// rendered an interactive ci-group. Clicking its shapes would
|
||||
// select and drag the instance as if nothing were locked. The
|
||||
// group's `listening: false` cascades to the frame rect and
|
||||
// every child, so one flag covers select / drag / transform.
|
||||
const { s, ci } = seedClipInstance()
|
||||
const track = s.scene.clipTracks![0]
|
||||
s.setTrackLocked(track.id, true)
|
||||
const handle = await mountCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(40)
|
||||
const stage = await getKonvaStage()
|
||||
const group = stage.findOne(`#ci-group:${ci.id}`) as unknown as {
|
||||
listening: () => boolean
|
||||
draggable: () => boolean
|
||||
} | null
|
||||
expect(group).toBeTruthy()
|
||||
expect(group!.listening()).toBe(false)
|
||||
expect(group!.draggable()).toBe(false)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('editorHidden clip track drops its instances from the Konva stack', async () => {
|
||||
// Matches the layer editor-hide semantics: the track stays in
|
||||
// the scene file (so exports still see it) but no ci-group or
|
||||
|
||||
Reference in new issue
Block a user