0.12.0: document drawer redesign, PWA install, clip/audio context menus + push-cascade
- Document drawer slides out from under the topbar (no tab, no hover peek); opens via project-name button, closes on outside-click or ESC. Layout: inputs row + divider + preset row, presets user-managed via popup and persisted in localStorage. - PWA manifest + minimal service worker so Chrome installs RUWIO as a standalone app — suppresses the native fullscreen exit overlay. - Clips and audio clips get right-click context menus (Duplicate / Delete) via new `duplicateClipInstance` / `duplicateAudioClip` store actions. - Clip moves cascade non-overlap in both directions; `setAudioClipPosition` now pushes backward as well as forward. New `setClipInstancePosition` gives clip instances the same behaviour. - Library drags carry timeline-shaped ghost previews (audio pill, clip pill, button thumb) instead of the library row; clip lane renders a dashed drop-target ghost and rejects window-overlap drops. - Scene / Clipart drawer tabs disappear in fullscreen; the 24 px they occupied is reclaimed for the stage. Play button sits on the exact horizontal center via a 3-column grid in the transport cluster. - Tests: context menu + push-cascade + duplicate action coverage for both clip kinds; DocumentDrawer suite rewritten for the slide-out model. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fa66ba69bb
commit
354dd8a4f0
25 files changed
+2294
-405
No files matched your search
+200
-43
@@ -33,6 +33,7 @@ import { useSceneStore } from '@/stores/scene'
|
||||
import { useClipartStore } from '@/stores/clipart'
|
||||
import { importAudioFile } from '@/lib/fileLoader'
|
||||
import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDragSource'
|
||||
import { clearClipDragSource, clipDragSource } from '@/composables/useClipDragSource'
|
||||
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
|
||||
import { sampleLayer } from '@/lib/interpolate'
|
||||
import { snapToCandidates } from '@/lib/timelineSnap'
|
||||
@@ -1663,21 +1664,83 @@ function pixelTimeFromEvent(e: DragEvent | PointerEvent, el: HTMLElement): numbe
|
||||
return ratio * duration.value
|
||||
}
|
||||
|
||||
function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) {
|
||||
if (e.dataTransfer?.types.includes('application/x-animatr-clip')) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
interface ClipInstanceGhost {
|
||||
targetTrackId: string
|
||||
startTime: number
|
||||
length: number
|
||||
}
|
||||
|
||||
/** Hover-preview for dropping a library clip onto a clip lane. Mirrors
|
||||
* `audioClipGhost`. Cleared whenever the cursor leaves the lane or
|
||||
* the drop lands. */
|
||||
const clipInstanceGhost = ref<ClipInstanceGhost | null>(null)
|
||||
|
||||
/** Full-window collision: does [start, start+length] overlap any
|
||||
* existing clip instance on this track? Stricter than a point-in-clip
|
||||
* check — catches the case where the drop point is free but the
|
||||
* dropped tail would land on top of a neighbour. Mirrors
|
||||
* `audioWindowCollides`. */
|
||||
function clipWindowCollides(
|
||||
trackId: string,
|
||||
start: number,
|
||||
length: number,
|
||||
): boolean {
|
||||
const end = start + length
|
||||
const siblings = clipInstancesByTrack.value[trackId] ?? []
|
||||
for (const ci of siblings) {
|
||||
const cEnd = ci.startTime + visibleDurationOf(ci)
|
||||
if (cEnd > start && ci.startTime < end) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function onClipLaneDragOver(e: DragEvent, clipTrackId: string) {
|
||||
const types = e.dataTransfer?.types
|
||||
if (!types?.includes('application/x-animatr-clip')) return
|
||||
e.preventDefault()
|
||||
if (!e.dataTransfer) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const time = pixelTimeFromEvent(e, el)
|
||||
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
|
||||
const src = clipDragSource.value
|
||||
const length = Math.max(0, src?.duration ?? 0)
|
||||
const collides =
|
||||
track?.locked || clipWindowCollides(clipTrackId, time, length)
|
||||
e.dataTransfer.dropEffect = collides ? 'none' : 'copy'
|
||||
if (!collides && src) {
|
||||
clipInstanceGhost.value = {
|
||||
targetTrackId: clipTrackId,
|
||||
startTime: time,
|
||||
length,
|
||||
}
|
||||
} else {
|
||||
clipInstanceGhost.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onClipLaneDragLeave() {
|
||||
clipInstanceGhost.value = null
|
||||
}
|
||||
|
||||
function onClipLaneDrop(e: DragEvent, clipTrackId: string) {
|
||||
e.preventDefault()
|
||||
clipInstanceGhost.value = null
|
||||
const track = store.scene.clipTracks?.find((t) => t.id === clipTrackId)
|
||||
if (track?.locked) return
|
||||
if (track?.locked) {
|
||||
clearClipDragSource()
|
||||
return
|
||||
}
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const time = pixelTimeFromEvent(e, el)
|
||||
const clipId = e.dataTransfer?.getData('application/x-animatr-clip')
|
||||
if (clipId) store.addClipInstance(clipId, time, clipTrackId)
|
||||
// The library payload is authoritative for the window check — the
|
||||
// composable source may have been cleared by a racing dragend.
|
||||
const clip = clipId ? store.project.clips.find((c) => c.id === clipId) : null
|
||||
const length = clip ? Math.max(0, clip.duration) : 0
|
||||
clearClipDragSource()
|
||||
if (!clipId) return
|
||||
if (clipWindowCollides(clipTrackId, time, length)) return
|
||||
store.addClipInstance(clipId, time, clipTrackId)
|
||||
}
|
||||
|
||||
interface ClipMoveState {
|
||||
@@ -1720,7 +1783,7 @@ function onClipBlockPointerMove(e: PointerEvent) {
|
||||
if (pps <= 0) return
|
||||
const deltaSec = deltaPx / pps
|
||||
const newStart = Math.max(0, m.initialStartTime + deltaSec)
|
||||
store.moveClipInstance(m.instanceId, newStart)
|
||||
store.setClipInstancePosition(m.instanceId, newStart)
|
||||
}
|
||||
|
||||
function onClipBlockPointerUp(e: PointerEvent) {
|
||||
@@ -1988,19 +2051,33 @@ async function onAudioLaneDrop(e: DragEvent, audioTrackId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onAudioClipContextMenu(e: MouseEvent, clip: AudioClip) {
|
||||
interface AudioClipMenuState {
|
||||
clipId: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const audioClipMenu = ref<AudioClipMenuState | null>(null)
|
||||
const audioClipMenuRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
function onAudioClipContextMenu(e: MouseEvent, clip: AudioClip) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// v1: a single "remove" action. The right-click context menu grows
|
||||
// later once trim handles and per-clip properties land.
|
||||
const ok = await confirmDialog({
|
||||
title: t('timeline.removeAudioClipDialog.title'),
|
||||
message: t('timeline.removeAudioClipDialog.message'),
|
||||
confirmLabel: t('timeline.removeAudioClipDialog.confirm'),
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
store.removeAudioClip(clip.id)
|
||||
audioClipMenu.value = { clipId: clip.id, x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function duplicateAudioClipFromMenu() {
|
||||
const m = audioClipMenu.value
|
||||
if (!m) return
|
||||
store.duplicateAudioClip(m.clipId)
|
||||
audioClipMenu.value = null
|
||||
}
|
||||
|
||||
function deleteAudioClipFromMenu() {
|
||||
const m = audioClipMenu.value
|
||||
if (!m) return
|
||||
store.removeAudioClip(m.clipId)
|
||||
audioClipMenu.value = null
|
||||
}
|
||||
|
||||
interface AudioClipMoveState {
|
||||
@@ -2381,22 +2458,33 @@ function onClipBlockDblClick(ci: ClipInstance) {
|
||||
store.enterClip(ci.clipId)
|
||||
}
|
||||
|
||||
async function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
|
||||
interface ClipInstanceMenuState {
|
||||
instanceId: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const clipInstanceMenu = ref<ClipInstanceMenuState | null>(null)
|
||||
const clipInstanceMenuRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) {
|
||||
e.preventDefault()
|
||||
const clip = clipById.value.get(ci.clipId)
|
||||
const ok = await confirmDialog({
|
||||
title: t('timeline.removeInstanceDialog.title'),
|
||||
message: clip
|
||||
? t('timeline.removeInstanceDialog.message', {
|
||||
name: clip.name,
|
||||
time: ci.startTime.toFixed(2),
|
||||
})
|
||||
: t('timeline.removeInstanceDialog.generic'),
|
||||
confirmLabel: t('timeline.removeInstanceDialog.confirm'),
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
store.removeClipInstance(ci.id)
|
||||
e.stopPropagation()
|
||||
clipInstanceMenu.value = { instanceId: ci.id, x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function duplicateClipInstanceFromMenu() {
|
||||
const m = clipInstanceMenu.value
|
||||
if (!m) return
|
||||
store.duplicateClipInstance(m.instanceId)
|
||||
clipInstanceMenu.value = null
|
||||
}
|
||||
|
||||
function deleteClipInstanceFromMenu() {
|
||||
const m = clipInstanceMenu.value
|
||||
if (!m) return
|
||||
store.removeClipInstance(m.instanceId)
|
||||
clipInstanceMenu.value = null
|
||||
}
|
||||
|
||||
const trackMenu = ref<TrackMenuState | null>(null)
|
||||
@@ -2622,6 +2710,16 @@ function onDocumentPointerDown(e: PointerEvent) {
|
||||
} else if (scriptFrameMenu.value) {
|
||||
scriptFrameMenu.value = null
|
||||
}
|
||||
if (clipInstanceMenu.value && clipInstanceMenuRef.value && e.target instanceof Node) {
|
||||
if (!clipInstanceMenuRef.value.contains(e.target)) clipInstanceMenu.value = null
|
||||
} else if (clipInstanceMenu.value) {
|
||||
clipInstanceMenu.value = null
|
||||
}
|
||||
if (audioClipMenu.value && audioClipMenuRef.value && e.target instanceof Node) {
|
||||
if (!audioClipMenuRef.value.contains(e.target)) audioClipMenu.value = null
|
||||
} else if (audioClipMenu.value) {
|
||||
audioClipMenu.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
@@ -2629,6 +2727,8 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
menu.value = null
|
||||
trackMenu.value = null
|
||||
scriptFrameMenu.value = null
|
||||
clipInstanceMenu.value = null
|
||||
audioClipMenu.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2694,15 +2794,18 @@ function onDocumentKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
watch([menu, trackMenu, scriptFrameMenu], ([m, tm, sm]) => {
|
||||
if (m || tm || sm) {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
} else {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
})
|
||||
watch(
|
||||
[menu, trackMenu, scriptFrameMenu, clipInstanceMenu, audioClipMenu],
|
||||
([m, tm, sm, cim, acm]) => {
|
||||
if (m || tm || sm || cim || acm) {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
} else {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function clampMenuIntoViewport(
|
||||
getEl: () => HTMLElement | null,
|
||||
@@ -2739,6 +2842,16 @@ watch(scriptFrameMenu, (sm) => {
|
||||
clampMenuIntoViewport(() => scriptFrameMenuRef.value, sm.x, sm.y)
|
||||
})
|
||||
|
||||
watch(clipInstanceMenu, (cim) => {
|
||||
if (!cim) return
|
||||
clampMenuIntoViewport(() => clipInstanceMenuRef.value, cim.x, cim.y)
|
||||
})
|
||||
|
||||
watch(audioClipMenu, (acm) => {
|
||||
if (!acm) return
|
||||
clampMenuIntoViewport(() => audioClipMenuRef.value, acm.x, acm.y)
|
||||
})
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (!scrollEl.value) return
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
@@ -3249,9 +3362,18 @@ onBeforeUnmount(() => {
|
||||
class="track-lane clip-lane"
|
||||
:style="{ width: laneWidth + 'px' }"
|
||||
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
|
||||
@dragleave.stop="onClipLaneDragLeave"
|
||||
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
|
||||
>
|
||||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||||
<span
|
||||
v-if="clipInstanceGhost && clipInstanceGhost.targetTrackId === track.clipTrackId"
|
||||
class="clip-instance-ghost"
|
||||
:style="{
|
||||
left: pxFromTime(clipInstanceGhost.startTime) + 'px',
|
||||
width: Math.max(6, pxFromTime(clipInstanceGhost.length)) + 'px',
|
||||
}"
|
||||
></span>
|
||||
<span
|
||||
v-for="ci in clipInstancesByTrack[track.clipTrackId] ?? []"
|
||||
:key="ci.id"
|
||||
@@ -3799,6 +3921,40 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="clipInstanceMenu"
|
||||
ref="clipInstanceMenuRef"
|
||||
class="kf-menu"
|
||||
:style="{ left: clipInstanceMenu.x + 'px', top: clipInstanceMenu.y + 'px' }"
|
||||
>
|
||||
<button class="kf-menu-item" @click="duplicateClipInstanceFromMenu">
|
||||
<PhCopy :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.duplicate') }}
|
||||
</button>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item danger" @click="deleteClipInstanceFromMenu">
|
||||
<PhTrash :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="audioClipMenu"
|
||||
ref="audioClipMenuRef"
|
||||
class="kf-menu"
|
||||
:style="{ left: audioClipMenu.x + 'px', top: audioClipMenu.y + 'px' }"
|
||||
>
|
||||
<button class="kf-menu-item" @click="duplicateAudioClipFromMenu">
|
||||
<PhCopy :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.duplicate') }}
|
||||
</button>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button class="kf-menu-item danger" @click="deleteAudioClipFromMenu">
|
||||
<PhTrash :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ScriptFrameDialog
|
||||
v-model="scriptDialogOpen"
|
||||
:frame-id="scriptDialogFrameId"
|
||||
@@ -5041,7 +5197,8 @@ onBeforeUnmount(() => {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.audio-clip-ghost {
|
||||
.audio-clip-ghost,
|
||||
.clip-instance-ghost {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
|
||||
Reference in new issue
Block a user