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(