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
+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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user