diff --git a/package.json b/package.json index 0a16b3f..a21336c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "animatr", "private": true, - "version": "0.10.0", + "version": "0.10.1", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/AssetSidebar.vue b/src/components/AssetSidebar.vue index e3800ff..b2f44e5 100644 --- a/src/components/AssetSidebar.vue +++ b/src/components/AssetSidebar.vue @@ -20,6 +20,7 @@ import { import { useSceneStore } from '@/stores/scene' import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref' import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader' +import { clearAudioDragSource, setAudioDragSource } from '@/composables/useAudioDragSource' import type { Asset } from '@/types/scene' import { confirmDialog, promptDialog } from '@/components/ui' import { loadSceneImages, renderButtonThumbnail } from '@/lib/render' @@ -326,6 +327,11 @@ function onAudioAssetDragStart(e: DragEvent, asset: Extract import { onBeforeUnmount, onMounted } from 'vue' -import { PhArrowClockwise, PhCaretLeft, PhCaretRight, PhImagesSquare, PhMusicNote } from '@phosphor-icons/vue' +import { PhArrowClockwise, PhCaretLeft, PhCaretRight, PhFolderOpen, PhImagesSquare, PhMusicNote } from '@phosphor-icons/vue' import { useClipartStore } from '@/stores/clipart' +import { readAudioDuration, readAsDataUrl } from '@/lib/fileLoader' +import { clearAudioDragSource, setAudioDragSource } from '@/composables/useAudioDragSource' import { t } from '@/i18n' const clipart = useClipartStore() @@ -22,6 +24,22 @@ function onItemDragStart(e: DragEvent, index: number) { e.dataTransfer.setData('application/x-animatr-clipart', String(index)) e.dataTransfer.setData('text/plain', item.name) if (item.kind === 'audio') { + // Populate the shared drag-source so Timeline can render a ghost + // preview on hover. We don't know the audio duration until the + // file decodes, so seed with 0 (ghost renders at minimum width) + // and upgrade asynchronously. dragend clears both. + setAudioDragSource({ duration: 0, name: item.name }) + const el = e.currentTarget as HTMLElement + el.addEventListener('dragend', clearAudioDragSource, { once: true }) + void (async () => { + try { + const dataUrl = await readAsDataUrl(item.file) + const d = await readAudioDuration(dataUrl) + setAudioDragSource({ duration: d, name: item.name }) + } catch { + /* leave the placeholder width — still a visible hint */ + } + })() // No image ghost for audio — browsers render the row itself as the // default drag preview, which is what we want. return @@ -75,9 +93,13 @@ function onItemDragStart(e: DragEvent, index: number) {

{{ t('clipart.unsupported') }}

-

- {{ t('clipart.empty') }} -

+
+

{{ t('clipart.empty') }}

+ +

{{ clipart.error }}

{{ t('clipart.loading') }}

@@ -263,6 +285,35 @@ function onItemDragStart(e: DragEvent, index: number) { font-style: italic; } +.empty-state-action { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; +} + +.empty-state-action p { + margin: 0; +} + +.choose-folder-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--text); + border-radius: var(--radius-sm); + font-size: 12px; + cursor: pointer; + font-style: normal; +} + +.choose-folder-btn:hover { + background: color-mix(in srgb, var(--accent) 22%, transparent); +} + .error-state { margin: 0; padding: 16px 14px; diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 363fc42..c6c0f6a 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -32,6 +32,7 @@ import { import { useSceneStore } from '@/stores/scene' import { useClipartStore } from '@/stores/clipart' import { importAudioFile } from '@/lib/fileLoader' +import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDragSource' import { sampleLayer } from '@/lib/interpolate' import { snapToCandidates } from '@/lib/timelineSnap' import { confirmDialog, promptDialog } from '@/components/ui' @@ -1807,14 +1808,34 @@ function onAudioLaneDragOver(e: DragEvent, audioTrackId: string) { if (!e.dataTransfer) return const el = e.currentTarget as HTMLElement const time = pixelTimeFromEvent(e, el) - // Forbid drops on top of existing clips — the browser renders a - // "not-allowed" cursor when dropEffect is 'none'. Width-based overlap - // (our drop's tail landing inside the next clip) is caught at drop time. - e.dataTransfer.dropEffect = audioPointInAnyClip(audioTrackId, time) ? 'none' : 'copy' + const collides = audioPointInAnyClip(audioTrackId, time) + e.dataTransfer.dropEffect = collides ? 'none' : 'copy' + // Show a ghost preview on the hovered lane. The source info (name + + // duration) comes from whichever library row the user started + // dragging from — see useAudioDragSource. 0-length drops (clipart + // pre-decode) render at the minimum 6px width so the ghost stays + // visible as a drop target. + const src = audioDragSource.value + if (!collides && src) { + audioClipGhost.value = { + clipId: '__external__', + targetTrackId: audioTrackId, + startTime: time, + length: Math.max(0, src.duration), + } + } else { + audioClipGhost.value = null + } +} + +function onAudioLaneDragLeave() { + audioClipGhost.value = null } async function onAudioLaneDrop(e: DragEvent, audioTrackId: string) { e.preventDefault() + audioClipGhost.value = null + clearAudioDragSource() const track = audioTrackById.value.get(audioTrackId) if (!track || track.locked) return const el = e.currentTarget as HTMLElement @@ -1873,11 +1894,26 @@ interface AudioClipMoveState { pointerId: number startX: number initialStartTime: number + initialTrackId: string moved: boolean } const audioClipMove = ref(null) +interface AudioClipGhost { + clipId: string + targetTrackId: string + startTime: number + length: number +} + +/** While dragging a clip over a DIFFERENT audio track, this holds a + * preview "where it will land" on that target lane. Rendered as a + * dashed block in the template so the user sees the drop target + * before committing via pointerup. Cleared whenever the cursor + * returns to the clip's original lane or leaves the audio area. */ +const audioClipGhost = ref(null) + function onAudioClipPointerDown(e: PointerEvent, clip: AudioClip) { if (e.button !== 0) return const track = audioTrackById.value.get(clip.audioTrackId) @@ -1891,10 +1927,27 @@ function onAudioClipPointerDown(e: PointerEvent, clip: AudioClip) { pointerId: e.pointerId, startX: e.clientX, initialStartTime: clip.startTime, + initialTrackId: clip.audioTrackId, moved: false, } } +/** Walk the DOM under the pointer to discover which audio track (if + * any) the cursor is currently over. Returns the track id, or null + * when the cursor has drifted off the audio lanes entirely. Used on + * pointerup to decide whether a drag should swap tracks. */ +function audioTrackIdFromPointer(clientX: number, clientY: number): string | null { + const el = document.elementFromPoint(clientX, clientY) + if (!el) return null + const host = (el as Element).closest('[data-audio-track-id]') as HTMLElement | null + if (!host) return null + const id = host.dataset.audioTrackId + if (!id) return null + // Only treat it as a valid target if the track exists in the store + // (defensive against stale DOM in the middle of re-renders). + return audioTrackById.value.get(id) ? id : null +} + function onAudioClipPointerMove(e: PointerEvent) { const m = audioClipMove.value if (!m || m.pointerId !== e.pointerId) return @@ -1907,13 +1960,34 @@ function onAudioClipPointerMove(e: PointerEvent) { const newStart = Math.max(0, m.initialStartTime + deltaSec) const clip = store.scene.audioClips?.find((c) => c.id === m.clipId) if (!clip) return - const asset = audioAssetById.value.get(clip.audioAssetId) - const length = asset ? Math.max(0, asset.duration - (clip.trimStart ?? 0) - (clip.trimEnd ?? 0)) : 0 - // Refuse overlapping positions — the clip stays at its last valid - // startTime. Cursor keeps tracking the pointer; block stops moving - // once it meets a neighbour's edge. - if (audioWindowCollides(clip.audioTrackId, newStart, length, clip.id)) return - store.moveAudioClip(m.clipId, newStart) + // Horizontal drag always moves the clip on its CURRENT track with + // cascade push. Track-switch is deferred to pointerup (to keep the + // pointer capture anchored on a stable DOM element). + store.setAudioClipPosition(m.clipId, newStart, clip.audioTrackId) + + // If the cursor is hovering over a different audio track, preview + // the drop on that lane as a dashed ghost. Users see where release + // will land BEFORE they commit. + const hovered = audioTrackIdFromPointer(e.clientX, e.clientY) + const canSwap = + hovered && + hovered !== m.initialTrackId && + store.scene.audioTracks && + store.scene.audioTracks.length > 1 + if (canSwap) { + const asset = audioAssetById.value.get(clip.audioAssetId) + const length = asset + ? Math.max(0, asset.duration - (clip.trimStart ?? 0) - (clip.trimEnd ?? 0)) + : 0 + audioClipGhost.value = { + clipId: clip.id, + targetTrackId: hovered!, + startTime: newStart, + length, + } + } else { + audioClipGhost.value = null + } } function onAudioClipPointerUp(e: PointerEvent) { @@ -1922,8 +1996,13 @@ function onAudioClipPointerUp(e: PointerEvent) { const el = e.currentTarget as HTMLElement if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId) const wasMoved = m.moved + const ghost = audioClipGhost.value + if (wasMoved && ghost && ghost.clipId === m.clipId) { + // Commit to the previewed target lane with cascade push. + store.setAudioClipPosition(m.clipId, ghost.startTime, ghost.targetTrackId) + } audioClipMove.value = null - // Block the contextmenu-after-drag that some browsers synthesise. + audioClipGhost.value = null if (wasMoved) e.preventDefault() } @@ -3092,6 +3171,7 @@ onBeforeUnmount(() => { muted: track.muted, }" :style="trackColorStyle(track.color)" + :data-audio-track-id="track.audioTrackId" > { class="track-lane audio-lane" :style="{ width: laneWidth + 'px' }" @dragover.stop="!track.locked ? onAudioLaneDragOver($event, track.audioTrackId) : null" + @dragleave.stop="onAudioLaneDragLeave" @drop.stop="!track.locked ? onAudioLaneDrop($event, track.audioTrackId) : null" > @@ -3134,6 +3215,14 @@ onBeforeUnmount(() => { {{ audioAssetById.get(clip.audioAssetId)?.name ?? '—' }} + @@ -4653,6 +4742,17 @@ onBeforeUnmount(() => { cursor: default; } +.audio-clip-ghost { + position: absolute; + top: 4px; + bottom: 4px; + border: 1.5px dashed var(--accent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 15%, transparent); + pointer-events: none; + box-sizing: border-box; +} + .audio-track.muted .audio-clip-block { opacity: 0.45; } diff --git a/src/composables/useAudioDragSource.ts b/src/composables/useAudioDragSource.ts new file mode 100644 index 0000000..b135b65 --- /dev/null +++ b/src/composables/useAudioDragSource.ts @@ -0,0 +1,28 @@ +import { ref } from 'vue' + +/** + * Shared reactive state describing what the user is currently dragging + * from the asset sidebar or the media drawer. Read by Timeline's audio + * lane to render a ghost preview at the hover position. + * + * The browser hides `dataTransfer.getData()` during `dragover` for + * security ("protected mode"), so we can't look up the source asset + * through the DragEvent itself. Dragstart handlers on the library + * rows write here instead; Timeline reads it; dragend/drop clears it. + */ +export interface AudioDragSource { + /** Seconds. 0 when unknown (clipart files before decode). */ + duration: number + /** Display label shown inside the ghost. */ + name: string +} + +export const audioDragSource = ref(null) + +export function setAudioDragSource(src: AudioDragSource): void { + audioDragSource.value = src +} + +export function clearAudioDragSource(): void { + audioDragSource.value = null +} diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 95050e4..b282084 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -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() + ;(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() + ;(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() + ;(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 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() ;(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) diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 85193d9..79c20c1 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -326,7 +326,7 @@ export const en = { title: 'Settings', tab: { document: 'Document', - clipart: 'Clipart', + clipart: 'Media', interface: 'Interface', theme: 'UI Theme', }, @@ -413,12 +413,12 @@ export const en = { }, clipart: { - title: 'Clipart library', - open: 'Open clipart library', - close: 'Close clipart library', + title: 'Media', + open: 'Open media', + close: 'Close media', refresh: 'Reload folder', - empty: 'Choose a folder in settings to populate the library.', - noFiles: 'No image files in this folder.', + empty: 'Choose a folder with images and audio to populate the library.', + noFiles: 'No images or audio files in this folder.', loading: 'Loading…', unsupported: 'Your browser does not support folder access. Try Chrome or Edge.', chooseFolder: 'Choose folder…', @@ -426,7 +426,7 @@ export const en = { folderLabel: 'Folder', noFolder: 'No folder selected', settingsHint: - 'Point to a local folder with your SVG/PNG/JPG clipart. The app remembers the folder; permissions may be re-asked after browser restart.', + 'Point to a local folder with your images and audio files. The app remembers the folder; permissions may be re-asked after browser restart.', }, scenes: { diff --git a/src/stores/scene.ts b/src/stores/scene.ts index d9e5a33..08058cf 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -1666,6 +1666,50 @@ export const useSceneStore = defineStore('scene', { } }, + /** Move a clip to `(startTime, audioTrackId)` on the active page and + * cascade-push any existing clips on the target track out of the way + * (forward in time) so no overlap survives. Clips that end BEFORE + * the moved clip's new startTime are left alone. Returns `false` + * when the clip or target track is unknown. */ + setAudioClipPosition(id: string, startTime: number, audioTrackId?: string): boolean { + const page = this.activeScene + const clip = page.audioClips.find((c) => c.id === id) + if (!clip) return false + const targetTrackId = audioTrackId ?? clip.audioTrackId + const targetTrack = page.audioTracks.find((t) => t.id === targetTrackId) + if (!targetTrack) return false + + const lenOf = (c: AudioClip): number => { + const a = this.project.assets.find((x) => x.id === c.audioAssetId) + if (!a || a.kind !== 'audio') return 0 + return Math.max(0, a.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0)) + } + + const newStart = Math.max(0, startTime) + const clipLen = lenOf(clip) + + // Anything already on the target track, minus the one we're moving. + const others = page.audioClips + .filter((c) => c.audioTrackId === targetTrackId && c.id !== id) + .slice() + .sort((a, b) => a.startTime - b.startTime) + + let floor = newStart + clipLen + for (const other of others) { + const len = lenOf(other) + // Clip entirely before the moved block — no cascade needed. + if (other.startTime + len <= newStart) continue + // Overlapping or sitting past the moved block but below the + // rolling floor → push it forward to the floor. + if (other.startTime < floor) other.startTime = floor + floor = other.startTime + len + } + + clip.audioTrackId = targetTrackId + clip.startTime = newStart + return true + }, + /** Trim from the head and/or tail of an audio clip. Keeps the audible * block at least `MIN_VISIBLE` (0.05s) long — mirrors trimClipInstance. */ trimAudioClip(