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:
thandClaude Opus 4.7 committed 2026-04-22 12:52:47 +02:00
1 parent 04f867547d
commit c5b1dd566a
8 files changed
+537 -42

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "animatr",
"private": true,
"version": "0.10.0",
"version": "0.10.1",
"type": "module",
"scripts": {
"dev": "vite",
+6
View File
@@ -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<Asset, { kind: 'audi
// creates a new AudioClip referencing this asset.
e.dataTransfer.setData('application/x-animatr-audio-asset', asset.id)
e.dataTransfer.setData('text/plain', asset.name)
// Surface the source info so Timeline's ghost preview has something
// to render during dragover (dataTransfer.getData is unreadable then).
setAudioDragSource({ duration: asset.duration, name: asset.name })
const el = e.currentTarget as HTMLElement
el.addEventListener('dragend', clearAudioDragSource, { once: true })
}
async function deleteAudioAsset(id: string) {
+55 -4
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
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) {
<p v-if="!clipart.supported" class="empty-state">
{{ t('clipart.unsupported') }}
</p>
<p v-else-if="!clipart.handle" class="empty-state">
{{ t('clipart.empty') }}
</p>
<div v-else-if="!clipart.handle" class="empty-state empty-state-action">
<p>{{ t('clipart.empty') }}</p>
<button class="choose-folder-btn" @click="clipart.pickFolder()">
<PhFolderOpen :size="14" />
<span>{{ t('clipart.chooseFolder') }}</span>
</button>
</div>
<p v-else-if="clipart.error" class="error-state">{{ clipart.error }}</p>
<p v-else-if="clipart.loading" class="empty-state">{{ t('clipart.loading') }}</p>
<p v-else-if="clipart.items.length === 0" class="empty-state">
@@ -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;
+111 -11
View File
@@ -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<AudioClipMoveState | null>(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<AudioClipGhost | null>(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
// 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
// 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)
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"
>
<span
class="track-name audio-track-name"
@@ -3114,6 +3194,7 @@ onBeforeUnmount(() => {
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"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
@@ -3134,6 +3215,14 @@ onBeforeUnmount(() => {
{{ audioAssetById.get(clip.audioAssetId)?.name ?? '—' }}
</span>
</span>
<span
v-if="audioClipGhost && audioClipGhost.targetTrackId === track.audioTrackId"
class="audio-clip-ghost"
:style="{
left: pxFromTime(audioClipGhost.startTime) + 'px',
width: Math.max(6, pxFromTime(audioClipGhost.length)) + 'px',
}"
></span>
</div>
</div>
@@ -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;
}
+28
View File
@@ -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<AudioDragSource | null>(null)
export function setAudioDragSource(src: AudioDragSource): void {
audioDragSource.value = src
}
export function clearAudioDragSource(): void {
audioDragSource.value = null
}
+285 -19
View File
@@ -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)
+7 -7
View File
@@ -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: {
+44
View File
@@ -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(