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

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