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",
|
"name": "animatr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.10.0",
|
"version": "0.10.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
|
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
|
||||||
import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader'
|
import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader'
|
||||||
|
import { clearAudioDragSource, setAudioDragSource } from '@/composables/useAudioDragSource'
|
||||||
import type { Asset } from '@/types/scene'
|
import type { Asset } from '@/types/scene'
|
||||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||||
import { loadSceneImages, renderButtonThumbnail } from '@/lib/render'
|
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.
|
// creates a new AudioClip referencing this asset.
|
||||||
e.dataTransfer.setData('application/x-animatr-audio-asset', asset.id)
|
e.dataTransfer.setData('application/x-animatr-audio-asset', asset.id)
|
||||||
e.dataTransfer.setData('text/plain', asset.name)
|
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) {
|
async function deleteAudioAsset(id: string) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted } from 'vue'
|
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 { useClipartStore } from '@/stores/clipart'
|
||||||
|
import { readAudioDuration, readAsDataUrl } from '@/lib/fileLoader'
|
||||||
|
import { clearAudioDragSource, setAudioDragSource } from '@/composables/useAudioDragSource'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const clipart = useClipartStore()
|
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('application/x-animatr-clipart', String(index))
|
||||||
e.dataTransfer.setData('text/plain', item.name)
|
e.dataTransfer.setData('text/plain', item.name)
|
||||||
if (item.kind === 'audio') {
|
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
|
// No image ghost for audio — browsers render the row itself as the
|
||||||
// default drag preview, which is what we want.
|
// default drag preview, which is what we want.
|
||||||
return
|
return
|
||||||
@@ -75,9 +93,13 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
<p v-if="!clipart.supported" class="empty-state">
|
<p v-if="!clipart.supported" class="empty-state">
|
||||||
{{ t('clipart.unsupported') }}
|
{{ t('clipart.unsupported') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="!clipart.handle" class="empty-state">
|
<div v-else-if="!clipart.handle" class="empty-state empty-state-action">
|
||||||
{{ t('clipart.empty') }}
|
<p>{{ t('clipart.empty') }}</p>
|
||||||
</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.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.loading" class="empty-state">{{ t('clipart.loading') }}</p>
|
||||||
<p v-else-if="clipart.items.length === 0" class="empty-state">
|
<p v-else-if="clipart.items.length === 0" class="empty-state">
|
||||||
@@ -263,6 +285,35 @@ function onItemDragStart(e: DragEvent, index: number) {
|
|||||||
font-style: italic;
|
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 {
|
.error-state {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px 14px;
|
padding: 16px 14px;
|
||||||
|
|||||||
+112
-12
@@ -32,6 +32,7 @@ import {
|
|||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { useClipartStore } from '@/stores/clipart'
|
import { useClipartStore } from '@/stores/clipart'
|
||||||
import { importAudioFile } from '@/lib/fileLoader'
|
import { importAudioFile } from '@/lib/fileLoader'
|
||||||
|
import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDragSource'
|
||||||
import { sampleLayer } from '@/lib/interpolate'
|
import { sampleLayer } from '@/lib/interpolate'
|
||||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||||
@@ -1807,14 +1808,34 @@ function onAudioLaneDragOver(e: DragEvent, audioTrackId: string) {
|
|||||||
if (!e.dataTransfer) return
|
if (!e.dataTransfer) return
|
||||||
const el = e.currentTarget as HTMLElement
|
const el = e.currentTarget as HTMLElement
|
||||||
const time = pixelTimeFromEvent(e, el)
|
const time = pixelTimeFromEvent(e, el)
|
||||||
// Forbid drops on top of existing clips — the browser renders a
|
const collides = audioPointInAnyClip(audioTrackId, time)
|
||||||
// "not-allowed" cursor when dropEffect is 'none'. Width-based overlap
|
e.dataTransfer.dropEffect = collides ? 'none' : 'copy'
|
||||||
// (our drop's tail landing inside the next clip) is caught at drop time.
|
// Show a ghost preview on the hovered lane. The source info (name +
|
||||||
e.dataTransfer.dropEffect = audioPointInAnyClip(audioTrackId, time) ? 'none' : 'copy'
|
// 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) {
|
async function onAudioLaneDrop(e: DragEvent, audioTrackId: string) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
audioClipGhost.value = null
|
||||||
|
clearAudioDragSource()
|
||||||
const track = audioTrackById.value.get(audioTrackId)
|
const track = audioTrackById.value.get(audioTrackId)
|
||||||
if (!track || track.locked) return
|
if (!track || track.locked) return
|
||||||
const el = e.currentTarget as HTMLElement
|
const el = e.currentTarget as HTMLElement
|
||||||
@@ -1873,11 +1894,26 @@ interface AudioClipMoveState {
|
|||||||
pointerId: number
|
pointerId: number
|
||||||
startX: number
|
startX: number
|
||||||
initialStartTime: number
|
initialStartTime: number
|
||||||
|
initialTrackId: string
|
||||||
moved: boolean
|
moved: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioClipMove = ref<AudioClipMoveState | null>(null)
|
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) {
|
function onAudioClipPointerDown(e: PointerEvent, clip: AudioClip) {
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
const track = audioTrackById.value.get(clip.audioTrackId)
|
const track = audioTrackById.value.get(clip.audioTrackId)
|
||||||
@@ -1891,10 +1927,27 @@ function onAudioClipPointerDown(e: PointerEvent, clip: AudioClip) {
|
|||||||
pointerId: e.pointerId,
|
pointerId: e.pointerId,
|
||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
initialStartTime: clip.startTime,
|
initialStartTime: clip.startTime,
|
||||||
|
initialTrackId: clip.audioTrackId,
|
||||||
moved: false,
|
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) {
|
function onAudioClipPointerMove(e: PointerEvent) {
|
||||||
const m = audioClipMove.value
|
const m = audioClipMove.value
|
||||||
if (!m || m.pointerId !== e.pointerId) return
|
if (!m || m.pointerId !== e.pointerId) return
|
||||||
@@ -1907,13 +1960,34 @@ function onAudioClipPointerMove(e: PointerEvent) {
|
|||||||
const newStart = Math.max(0, m.initialStartTime + deltaSec)
|
const newStart = Math.max(0, m.initialStartTime + deltaSec)
|
||||||
const clip = store.scene.audioClips?.find((c) => c.id === m.clipId)
|
const clip = store.scene.audioClips?.find((c) => c.id === m.clipId)
|
||||||
if (!clip) return
|
if (!clip) return
|
||||||
const asset = audioAssetById.value.get(clip.audioAssetId)
|
// Horizontal drag always moves the clip on its CURRENT track with
|
||||||
const length = asset ? Math.max(0, asset.duration - (clip.trimStart ?? 0) - (clip.trimEnd ?? 0)) : 0
|
// cascade push. Track-switch is deferred to pointerup (to keep the
|
||||||
// Refuse overlapping positions — the clip stays at its last valid
|
// pointer capture anchored on a stable DOM element).
|
||||||
// startTime. Cursor keeps tracking the pointer; block stops moving
|
store.setAudioClipPosition(m.clipId, newStart, clip.audioTrackId)
|
||||||
// once it meets a neighbour's edge.
|
|
||||||
if (audioWindowCollides(clip.audioTrackId, newStart, length, clip.id)) return
|
// If the cursor is hovering over a different audio track, preview
|
||||||
store.moveAudioClip(m.clipId, newStart)
|
// 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) {
|
function onAudioClipPointerUp(e: PointerEvent) {
|
||||||
@@ -1922,8 +1996,13 @@ function onAudioClipPointerUp(e: PointerEvent) {
|
|||||||
const el = e.currentTarget as HTMLElement
|
const el = e.currentTarget as HTMLElement
|
||||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||||||
const wasMoved = m.moved
|
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
|
audioClipMove.value = null
|
||||||
// Block the contextmenu-after-drag that some browsers synthesise.
|
audioClipGhost.value = null
|
||||||
if (wasMoved) e.preventDefault()
|
if (wasMoved) e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3092,6 +3171,7 @@ onBeforeUnmount(() => {
|
|||||||
muted: track.muted,
|
muted: track.muted,
|
||||||
}"
|
}"
|
||||||
:style="trackColorStyle(track.color)"
|
:style="trackColorStyle(track.color)"
|
||||||
|
:data-audio-track-id="track.audioTrackId"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="track-name audio-track-name"
|
class="track-name audio-track-name"
|
||||||
@@ -3114,6 +3194,7 @@ onBeforeUnmount(() => {
|
|||||||
class="track-lane audio-lane"
|
class="track-lane audio-lane"
|
||||||
:style="{ width: laneWidth + 'px' }"
|
:style="{ width: laneWidth + 'px' }"
|
||||||
@dragover.stop="!track.locked ? onAudioLaneDragOver($event, track.audioTrackId) : null"
|
@dragover.stop="!track.locked ? onAudioLaneDragOver($event, track.audioTrackId) : null"
|
||||||
|
@dragleave.stop="onAudioLaneDragLeave"
|
||||||
@drop.stop="!track.locked ? onAudioLaneDrop($event, track.audioTrackId) : null"
|
@drop.stop="!track.locked ? onAudioLaneDrop($event, track.audioTrackId) : null"
|
||||||
>
|
>
|
||||||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||||||
@@ -3134,6 +3215,14 @@ onBeforeUnmount(() => {
|
|||||||
{{ audioAssetById.get(clip.audioAssetId)?.name ?? '—' }}
|
{{ audioAssetById.get(clip.audioAssetId)?.name ?? '—' }}
|
||||||
</span>
|
</span>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4653,6 +4742,17 @@ onBeforeUnmount(() => {
|
|||||||
cursor: default;
|
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 {
|
.audio-track.muted .audio-clip-block {
|
||||||
opacity: 0.45;
|
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()
|
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 () => {
|
test('audio clipart items render as .audio-tile with the music-note icon, not an img', async () => {
|
||||||
const { useClipartStore } = await import('@/stores/clipart')
|
const { useClipartStore } = await import('@/stores/clipart')
|
||||||
const s = useClipartStore()
|
const s = useClipartStore()
|
||||||
@@ -4287,20 +4316,213 @@ export function registerAllTests(): void {
|
|||||||
handle.unmount()
|
handle.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('drag stops at the neighbour\'s edge when the move would overlap', async () => {
|
test('pointerup over a different audio track switches the clip to that track', async () => {
|
||||||
// Two clips on the same track: A at t=0 (length 2), B at t=5.
|
// The live app (which our tests render alongside) ALSO has a
|
||||||
// Drag B leftward; it must not be pushed into A's 0..2 window.
|
// Timeline mounted. `document.elementFromPoint` would return
|
||||||
// The last valid position is startTime=2 (exactly abutting A).
|
// 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 s = makeFreshStore()
|
||||||
const asset = s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 2 })
|
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()
|
const track = s.addAudioTrack()
|
||||||
s.addAudioClip(asset.id, 0, track.id) // A
|
const handle = mountComponent(Timeline, {})
|
||||||
const b = s.addAudioClip(asset.id, 5, track.id)! // B
|
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, {})
|
const handle = mountComponent(Timeline, {})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const blocks = handle.host.querySelectorAll('.audio-track .audio-clip-block') as NodeListOf<HTMLElement>
|
const blocks = handle.host.querySelectorAll('.audio-track .audio-clip-block') as NodeListOf<HTMLElement>
|
||||||
expect(blocks.length).toBe(2)
|
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>()
|
const captured = new Set<number>()
|
||||||
;(blockB as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
|
;(blockB as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
|
||||||
captured.add(id)
|
captured.add(id)
|
||||||
@@ -4309,25 +4531,26 @@ export function registerAllTests(): void {
|
|||||||
captured.delete(id)
|
captured.delete(id)
|
||||||
}
|
}
|
||||||
;(blockB as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) => captured.has(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(
|
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
|
// Drag right by 5s → B wants startTime=5, which overlaps A.
|
||||||
// unconstrained, but overlap with A blocks further movement.
|
// Cascade pushes A from 5 to 5+2=7.
|
||||||
blockB.dispatchEvent(
|
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(
|
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()
|
await nextTick()
|
||||||
// B never entered A's window; it stayed at its last valid pos
|
expect(Math.abs(b.startTime - 5) < 0.1).toBe(true)
|
||||||
// (≥ 2). Depending on the number of move events (just one here),
|
expect(Math.abs(a.startTime - 7) < 0.1).toBe(true)
|
||||||
// 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)
|
|
||||||
handle.unmount()
|
handle.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -5536,6 +5759,49 @@ export function registerAllTests(): void {
|
|||||||
expect(copy.audioClips[0].audioAssetId).toBe(asset.id)
|
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', () => {
|
test('normalizeScenePage drops orphan audio clips pointing at a missing track', () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
const asset = addAsset(s)
|
const asset = addAsset(s)
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export const en = {
|
|||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
tab: {
|
tab: {
|
||||||
document: 'Document',
|
document: 'Document',
|
||||||
clipart: 'Clipart',
|
clipart: 'Media',
|
||||||
interface: 'Interface',
|
interface: 'Interface',
|
||||||
theme: 'UI Theme',
|
theme: 'UI Theme',
|
||||||
},
|
},
|
||||||
@@ -413,12 +413,12 @@ export const en = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clipart: {
|
clipart: {
|
||||||
title: 'Clipart library',
|
title: 'Media',
|
||||||
open: 'Open clipart library',
|
open: 'Open media',
|
||||||
close: 'Close clipart library',
|
close: 'Close media',
|
||||||
refresh: 'Reload folder',
|
refresh: 'Reload folder',
|
||||||
empty: 'Choose a folder in settings to populate the library.',
|
empty: 'Choose a folder with images and audio to populate the library.',
|
||||||
noFiles: 'No image files in this folder.',
|
noFiles: 'No images or audio files in this folder.',
|
||||||
loading: 'Loading…',
|
loading: 'Loading…',
|
||||||
unsupported: 'Your browser does not support folder access. Try Chrome or Edge.',
|
unsupported: 'Your browser does not support folder access. Try Chrome or Edge.',
|
||||||
chooseFolder: 'Choose folder…',
|
chooseFolder: 'Choose folder…',
|
||||||
@@ -426,7 +426,7 @@ export const en = {
|
|||||||
folderLabel: 'Folder',
|
folderLabel: 'Folder',
|
||||||
noFolder: 'No folder selected',
|
noFolder: 'No folder selected',
|
||||||
settingsHint:
|
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: {
|
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
|
/** Trim from the head and/or tail of an audio clip. Keeps the audible
|
||||||
* block at least `MIN_VISIBLE` (0.05s) long — mirrors trimClipInstance. */
|
* block at least `MIN_VISIBLE` (0.05s) long — mirrors trimClipInstance. */
|
||||||
trimAudioClip(
|
trimAudioClip(
|
||||||
|
|||||||
Reference in new issue
Block a user