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:
thandClaude Opus 4.7 committed 2026-04-24 07:41:26 +02:00
1 parent fa66ba69bb
commit 354dd8a4f0
25 files changed
+2294 -405

No files matched your search

+200 -43
View File
@@ -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;