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
+538 -43

No files matched your search

+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;
+112 -12
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
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;
}