0.10.1: audio clip push-cascade + track-swap ghost, Media drawer rename
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
04f867547d
commit
c5b1dd566a
8 files changed
+538
-43
No files matched your search
+285
-19
@@ -3889,6 +3889,35 @@ export function registerAllTests(): void {
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('empty state shows a Choose-Folder button that calls pickFolder', async () => {
|
||||
const { useClipartStore } = await import('@/stores/clipart')
|
||||
const s = useClipartStore()
|
||||
s.setOpen(true)
|
||||
s.supported = true
|
||||
// No handle → empty-state with action.
|
||||
s.handle = null
|
||||
s.folderName = null
|
||||
s.items = []
|
||||
// Spy on pickFolder so we don't actually open a folder picker.
|
||||
const calls: number[] = []
|
||||
const orig = s.pickFolder
|
||||
s.pickFolder = async () => {
|
||||
calls.push(1)
|
||||
}
|
||||
try {
|
||||
const handle = mountComponent(ClipartDrawer, {})
|
||||
await nextTick()
|
||||
const btn = handle.host.querySelector('.choose-folder-btn') as HTMLButtonElement | null
|
||||
expect(btn).toBeTruthy()
|
||||
btn!.click()
|
||||
expect(calls.length).toBe(1)
|
||||
handle.unmount()
|
||||
} finally {
|
||||
s.pickFolder = orig
|
||||
s.setOpen(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('audio clipart items render as .audio-tile with the music-note icon, not an img', async () => {
|
||||
const { useClipartStore } = await import('@/stores/clipart')
|
||||
const s = useClipartStore()
|
||||
@@ -4287,20 +4316,213 @@ export function registerAllTests(): void {
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('drag stops at the neighbour\'s edge when the move would overlap', async () => {
|
||||
// Two clips on the same track: A at t=0 (length 2), B at t=5.
|
||||
// Drag B leftward; it must not be pushed into A's 0..2 window.
|
||||
// The last valid position is startTime=2 (exactly abutting A).
|
||||
test('pointerup over a different audio track switches the clip to that track', async () => {
|
||||
// The live app (which our tests render alongside) ALSO has a
|
||||
// Timeline mounted. `document.elementFromPoint` would return
|
||||
// whatever element is topmost at a given screen coord — often
|
||||
// belonging to the live Timeline, not the one we just mounted.
|
||||
// Override the lookup for this test to pin the expected hit.
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||||
const t1 = s.addAudioTrack('one')
|
||||
const t2 = s.addAudioTrack('two')
|
||||
const clip = s.addAudioClip(asset.id, 1, t1.id)!
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const block = handle.host.querySelector(
|
||||
`.audio-track[data-audio-track-id="${t1.id}"] .audio-clip-block`,
|
||||
) as HTMLElement
|
||||
const targetLane = handle.host.querySelector(
|
||||
`.audio-track[data-audio-track-id="${t2.id}"]`,
|
||||
) as HTMLElement
|
||||
expect(block).toBeTruthy()
|
||||
expect(targetLane).toBeTruthy()
|
||||
|
||||
const origFromPoint = document.elementFromPoint.bind(document)
|
||||
;(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint = (
|
||||
_x: number,
|
||||
y: number,
|
||||
): Element | null => {
|
||||
// Only intercept the sentinel Y we use for the drop — let the
|
||||
// original handle every other call so the rest of the app
|
||||
// isn't disturbed.
|
||||
if (y === 42424242) return targetLane
|
||||
return origFromPoint(_x, y)
|
||||
}
|
||||
|
||||
const captured = new Set<number>()
|
||||
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
|
||||
captured.add(id)
|
||||
}
|
||||
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
|
||||
captured.delete(id)
|
||||
}
|
||||
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
|
||||
try {
|
||||
block.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 100, clientY: 10 }),
|
||||
)
|
||||
block.dispatchEvent(
|
||||
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: 110, clientY: 42424242 }),
|
||||
)
|
||||
block.dispatchEvent(
|
||||
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: 110, clientY: 42424242 }),
|
||||
)
|
||||
await nextTick()
|
||||
expect(clip.audioTrackId).toBe(t2.id)
|
||||
} finally {
|
||||
;(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint =
|
||||
origFromPoint
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('dragging an audio asset from the library shows a ghost preview on the hovered lane', async () => {
|
||||
const { setAudioDragSource, clearAudioDragSource } = await import(
|
||||
'@/composables/useAudioDragSource'
|
||||
)
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAudioAsset({ name: 'intro', src: 'data:audio/mp3;x', duration: 3 })
|
||||
const track = s.addAudioTrack()
|
||||
s.addAudioClip(asset.id, 0, track.id) // A
|
||||
const b = s.addAudioClip(asset.id, 5, track.id)! // B
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const lane = handle.host.querySelector('.audio-track .audio-lane') as HTMLElement
|
||||
const { timeToX } = stubAudioLane(lane)
|
||||
// Library dragstart would populate the shared source ref; simulate it.
|
||||
setAudioDragSource({ duration: asset.duration, name: asset.name })
|
||||
try {
|
||||
const dt = new DataTransfer()
|
||||
dt.setData('application/x-animatr-audio-asset', asset.id)
|
||||
const over = new DragEvent('dragover', {
|
||||
bubbles: true, cancelable: true, dataTransfer: dt,
|
||||
clientX: timeToX(5, s.scene.duration), clientY: 10,
|
||||
})
|
||||
lane.dispatchEvent(over)
|
||||
await nextTick()
|
||||
const ghost = handle.host.querySelector('.audio-clip-ghost') as HTMLElement
|
||||
expect(ghost).toBeTruthy()
|
||||
// The ghost width reflects the asset duration (3s * pps).
|
||||
const expectedWidthPx = parseFloat(ghost.style.width)
|
||||
expect(expectedWidthPx).toBeGreaterThan(0)
|
||||
// And dragleave wipes it.
|
||||
lane.dispatchEvent(new DragEvent('dragleave', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||
await nextTick()
|
||||
expect(handle.host.querySelector('.audio-clip-ghost')).toBeNull()
|
||||
void track
|
||||
} finally {
|
||||
clearAudioDragSource()
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('dragging over a different track renders an .audio-clip-ghost preview on that lane', async () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||||
const t1 = s.addAudioTrack('one')
|
||||
const t2 = s.addAudioTrack('two')
|
||||
s.addAudioClip(asset.id, 1, t1.id)
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const block = handle.host.querySelector(
|
||||
`.audio-track[data-audio-track-id="${t1.id}"] .audio-clip-block`,
|
||||
) as HTMLElement
|
||||
const targetLane = handle.host.querySelector(
|
||||
`.audio-track[data-audio-track-id="${t2.id}"]`,
|
||||
) as HTMLElement
|
||||
expect(block).toBeTruthy()
|
||||
expect(targetLane).toBeTruthy()
|
||||
|
||||
const origFromPoint = document.elementFromPoint.bind(document)
|
||||
;(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint = (
|
||||
_x,
|
||||
y: number,
|
||||
) => (y === 42424242 ? targetLane : origFromPoint(_x, y))
|
||||
|
||||
const captured = new Set<number>()
|
||||
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
|
||||
captured.add(id)
|
||||
}
|
||||
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
|
||||
captured.delete(id)
|
||||
}
|
||||
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
|
||||
try {
|
||||
block.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 100, clientY: 10 }),
|
||||
)
|
||||
block.dispatchEvent(
|
||||
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: 110, clientY: 42424242 }),
|
||||
)
|
||||
await nextTick()
|
||||
// The ghost materialises inside t2's lane, NOT t1's.
|
||||
const ghostInT2 = handle.host.querySelector(
|
||||
`.audio-track[data-audio-track-id="${t2.id}"] .audio-clip-ghost`,
|
||||
)
|
||||
const ghostInT1 = handle.host.querySelector(
|
||||
`.audio-track[data-audio-track-id="${t1.id}"] .audio-clip-ghost`,
|
||||
)
|
||||
expect(ghostInT2).toBeTruthy()
|
||||
expect(ghostInT1).toBeNull()
|
||||
// Release disposes the ghost either way.
|
||||
block.dispatchEvent(
|
||||
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: 110, clientY: 42424242 }),
|
||||
)
|
||||
await nextTick()
|
||||
expect(handle.host.querySelector('.audio-clip-ghost')).toBeNull()
|
||||
} finally {
|
||||
;(document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint =
|
||||
origFromPoint
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('with only one audio track, pointerup cannot switch the track', async () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||||
const only = s.addAudioTrack()
|
||||
const clip = s.addAudioClip(asset.id, 1, only.id)!
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const block = handle.host.querySelector('.audio-track .audio-clip-block') as HTMLElement
|
||||
const captured = new Set<number>()
|
||||
;(block as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
|
||||
captured.add(id)
|
||||
}
|
||||
;(block as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
|
||||
captured.delete(id)
|
||||
}
|
||||
;(block as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
|
||||
// Drag horizontally + release anywhere (even way off-lane). The
|
||||
// clip must stay on its only track — no neighbours to swap to.
|
||||
block.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 100, clientY: 10 }),
|
||||
)
|
||||
block.dispatchEvent(
|
||||
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: 140, clientY: 9999 }),
|
||||
)
|
||||
block.dispatchEvent(
|
||||
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: 140, clientY: 9999 }),
|
||||
)
|
||||
await nextTick()
|
||||
expect(clip.audioTrackId).toBe(only.id)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('dragging a clip into a neighbour pushes the neighbour forward (cascade)', async () => {
|
||||
// Two clips on the same track: A at t=5 (length 2, 5..7),
|
||||
// B at t=0 (length 2, 0..2). Drag B rightward past A — A must
|
||||
// shift forward so the two don't overlap.
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(30)
|
||||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||||
const track = s.addAudioTrack()
|
||||
const b = s.addAudioClip(asset.id, 0, track.id)! // B at 0..2
|
||||
const a = s.addAudioClip(asset.id, 5, track.id)! // A at 5..7
|
||||
const handle = mountComponent(Timeline, {})
|
||||
await nextTick()
|
||||
const blocks = handle.host.querySelectorAll('.audio-track .audio-clip-block') as NodeListOf<HTMLElement>
|
||||
expect(blocks.length).toBe(2)
|
||||
const blockB = blocks[1]
|
||||
const blockB = blocks[0] // rendered in insertion order; B was added first
|
||||
const captured = new Set<number>()
|
||||
;(blockB as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
|
||||
captured.add(id)
|
||||
@@ -4309,25 +4531,26 @@ export function registerAllTests(): void {
|
||||
captured.delete(id)
|
||||
}
|
||||
;(blockB as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(id)
|
||||
|
||||
// A (second block) sits at startTime=5, its style.left = pps*5.
|
||||
// Read pps back from that so the drag delta we synthesise is in
|
||||
// timeline-seconds, not pixels.
|
||||
const pps = parseFloat(blocks[1].style.left) / 5
|
||||
const startX = 500
|
||||
blockB.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: 500 }),
|
||||
new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: startX }),
|
||||
)
|
||||
// Big leftward drag — would end up at some negative position if
|
||||
// unconstrained, but overlap with A blocks further movement.
|
||||
// Drag right by 5s → B wants startTime=5, which overlaps A.
|
||||
// Cascade pushes A from 5 to 5+2=7.
|
||||
blockB.dispatchEvent(
|
||||
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: -5000 }),
|
||||
new PointerEvent('pointermove', { bubbles: true, cancelable: true, pointerId: 1, clientX: startX + 5 * pps }),
|
||||
)
|
||||
blockB.dispatchEvent(
|
||||
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: -5000 }),
|
||||
new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, clientX: startX + 5 * pps }),
|
||||
)
|
||||
await nextTick()
|
||||
// B never entered A's window; it stayed at its last valid pos
|
||||
// (≥ 2). Depending on the number of move events (just one here),
|
||||
// it'll actually have stopped at 5 since the first evaluated
|
||||
// newStart would already collide. Either way: no overlap.
|
||||
expect(b.startTime >= 2 - 1e-9).toBe(true)
|
||||
// And most importantly: A stayed put.
|
||||
expect(s.activeScene.audioClips.find((c) => c.id !== b.id)!.startTime).toBe(0)
|
||||
expect(Math.abs(b.startTime - 5) < 0.1).toBe(true)
|
||||
expect(Math.abs(a.startTime - 7) < 0.1).toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
@@ -5536,6 +5759,49 @@ export function registerAllTests(): void {
|
||||
expect(copy.audioClips[0].audioAssetId).toBe(asset.id)
|
||||
})
|
||||
|
||||
test('setAudioClipPosition cascades pushes on target track, leaves earlier clips alone', () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||||
const track = s.addAudioTrack()
|
||||
const early = s.addAudioClip(asset.id, 0, track.id)! // 0..2
|
||||
const mid = s.addAudioClip(asset.id, 5, track.id)! // 5..7
|
||||
const late = s.addAudioClip(asset.id, 8, track.id)! // 8..10
|
||||
// Move `mid` to startTime=4 — it overlaps nothing before, but
|
||||
// `late` (at 8) is still OK. Expect `mid` to land at 4 and no
|
||||
// cascade for `late`.
|
||||
expect(s.setAudioClipPosition(mid.id, 4)).toBe(true)
|
||||
expect(mid.startTime).toBe(4)
|
||||
expect(late.startTime).toBe(8)
|
||||
expect(early.startTime).toBe(0)
|
||||
// Now move `early` to startTime=4, which overlaps `mid` at 4..6.
|
||||
// Cascade: push `mid` to 6, which then overlaps `late` at 8 —
|
||||
// actually 6+2=8, so late stays at 8 (abutting).
|
||||
s.setAudioClipPosition(early.id, 4)
|
||||
expect(early.startTime).toBe(4)
|
||||
expect(mid.startTime).toBe(6)
|
||||
expect(late.startTime).toBe(8)
|
||||
})
|
||||
|
||||
test('setAudioClipPosition with audioTrackId swaps the clip to another track', () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
||||
const t1 = s.addAudioTrack('one')
|
||||
const t2 = s.addAudioTrack('two')
|
||||
const clip = s.addAudioClip(asset.id, 1, t1.id)!
|
||||
s.setAudioClipPosition(clip.id, 3, t2.id)
|
||||
expect(clip.audioTrackId).toBe(t2.id)
|
||||
expect(clip.startTime).toBe(3)
|
||||
})
|
||||
|
||||
test('setAudioClipPosition returns false for unknown clip or unknown track', () => {
|
||||
const s = makeFreshStore()
|
||||
expect(s.setAudioClipPosition('no-such-clip', 0)).toBe(false)
|
||||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 1 })
|
||||
const track = s.addAudioTrack()
|
||||
const clip = s.addAudioClip(asset.id, 0, track.id)!
|
||||
expect(s.setAudioClipPosition(clip.id, 0, 'ghost-track')).toBe(false)
|
||||
})
|
||||
|
||||
test('normalizeScenePage drops orphan audio clips pointing at a missing track', () => {
|
||||
const s = makeFreshStore()
|
||||
const asset = addAsset(s)
|
||||
|
||||
Reference in new issue
Block a user