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
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "animatr",
|
||||
"private": true,
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+112
-12
@@ -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
|
||||
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"
|
||||
>
|
||||
<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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user