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

+182 -21
View File
@@ -42,6 +42,96 @@ type AssetInit = Asset extends infer A ? (A extends Asset ? Omit<A, 'id'> : neve
const KEYFRAME_EPSILON = 1e-3
/** Shared cascading push-aside helper used by the audio-clip and clip-
* instance move paths. Items on the same track ahead of the moved
* block get shoved forward to its new tail; items behind get shoved
* backward to its new head. If the backward chain would go below 0
* the whole block is clamped forward to the first fitting slot so
* nothing overlaps and nothing crosses the scene origin.
*
* The helper mutates `others` in place (updates `startTime` on each
* item) and returns the effective newStart actually applied — which
* the caller should apply to the moved item itself. */
interface CascadeItem {
id: string
startTime: number
}
function cascadePushAside<T extends CascadeItem>(
others: T[],
movedCenterOld: number,
movedLen: number,
lenOf: (item: T) => number,
desiredStart: number,
): number {
const newEnd = () => newStart + movedLen
// Use the moved clip's *old* center to decide which side each neighbor
// belongs to. Doing the split on the pre-move position prevents the
// partition from flipping partway through a drag when the user's
// cursor crosses another clip.
const leftSide: T[] = []
const rightSide: T[] = []
for (const o of others) {
const center = o.startTime + lenOf(o) / 2
if (center < movedCenterOld) leftSide.push(o)
else rightSide.push(o)
}
let newStart = Math.max(0, desiredStart)
// Backward cascade: walk left-side clips from rightmost → leftmost,
// pushing each one left to stay clear of the running ceiling. If any
// would land below 0 we shift the whole moved block right by that
// deficit so the stack on the left sits flush with the scene origin.
leftSide.sort((a, b) => b.startTime - a.startTime)
const computeLeft = (): { newStarts: Map<string, number>; deficit: number } => {
const map = new Map<string, number>()
let ceiling = newStart
let maxDeficit = 0
for (const o of leftSide) {
const len = lenOf(o)
if (o.startTime + len <= ceiling) {
// Already clear — this one stays where it is
ceiling = o.startTime
map.set(o.id, o.startTime)
} else {
const next = ceiling - len
if (next < 0) maxDeficit = Math.max(maxDeficit, -next)
ceiling = next
map.set(o.id, next)
}
}
return { newStarts: map, deficit: maxDeficit }
}
let left = computeLeft()
if (left.deficit > 0) {
newStart += left.deficit
left = computeLeft()
}
for (const o of leftSide) {
const s = left.newStarts.get(o.id)
if (s !== undefined) o.startTime = Math.max(0, s)
}
// Forward cascade: walk right-side clips leftmost → rightmost,
// pushing each one forward to stay past the running floor.
rightSide.sort((a, b) => a.startTime - b.startTime)
let floor = newEnd()
for (const o of rightSide) {
const len = lenOf(o)
if (o.startTime >= floor) {
floor = o.startTime + len
} else {
o.startTime = floor
floor = o.startTime + len
}
}
return newStart
}
const BUTTON_STATE_TIME_VALUES: readonly number[] = [
BUTTON_STATE_TIMES.normal,
BUTTON_STATE_TIMES.hover,
@@ -1722,10 +1812,11 @@ export const useSceneStore = defineStore('scene', {
},
/** Move a clip to `(startTime, audioTrackId)` on the active page and
* cascade-push any existing clips on the target track out of the way
* (forward in time) so no overlap survives. Clips that end BEFORE
* the moved clip's new startTime are left alone. Returns `false`
* when the clip or target track is unknown. */
* cascade-push any existing clips on the target track out of the
* way so no overlap survives. Clips ahead of the moved block are
* shoved forward; clips behind it are shoved backward. Hitting
* the scene origin clamps the moved clip forward. Returns `false`
* only when the clip or target track is unknown. */
setAudioClipPosition(id: string, startTime: number, audioTrackId?: string): boolean {
const page = this.activeScene
const clip = page.audioClips.find((c) => c.id === id)
@@ -1740,28 +1831,23 @@ export const useSceneStore = defineStore('scene', {
return Math.max(0, a.duration - (c.trimStart ?? 0) - (c.trimEnd ?? 0))
}
const newStart = Math.max(0, startTime)
const clipLen = lenOf(clip)
const oldCenter = clip.startTime + clipLen / 2
// Anything already on the target track, minus the one we're moving.
const others = page.audioClips
.filter((c) => c.audioTrackId === targetTrackId && c.id !== id)
.slice()
.sort((a, b) => a.startTime - b.startTime)
const others = page.audioClips.filter(
(c) => c.audioTrackId === targetTrackId && c.id !== id,
)
let floor = newStart + clipLen
for (const other of others) {
const len = lenOf(other)
// Clip entirely before the moved block — no cascade needed.
if (other.startTime + len <= newStart) continue
// Overlapping or sitting past the moved block but below the
// rolling floor → push it forward to the floor.
if (other.startTime < floor) other.startTime = floor
floor = other.startTime + len
}
const appliedStart = cascadePushAside(
others,
oldCenter,
clipLen,
lenOf,
startTime,
)
clip.audioTrackId = targetTrackId
clip.startTime = newStart
clip.startTime = appliedStart
return true
},
@@ -1801,6 +1887,24 @@ export const useSceneStore = defineStore('scene', {
this.activeScene.audioClips = this.activeScene.audioClips.filter((c) => c.id !== id)
},
/** Clone an audio clip onto the same track, placed directly after
* the original using its visible (trim-adjusted) duration. The
* copy carries trim settings and sample/loop offsets forward. */
duplicateAudioClip(id: string): string | null {
const src = this.activeScene.audioClips.find((c) => c.id === id)
if (!src) return null
const asset = this.project.assets.find((a) => a.id === src.audioAssetId)
const base = asset && asset.kind === 'audio' ? asset.duration : 1
const visible = Math.max(0.05, base - (src.trimStart ?? 0) - (src.trimEnd ?? 0))
const copy: AudioClip = {
...structuredClone(toRaw(src)),
id: uid(),
startTime: src.startTime + visible,
}
this.activeScene.audioClips.push(copy)
return copy.id
},
// -------- Button assets --------------------------------------------
addButtonAsset(name?: string, width = 200, height = 80): ButtonAsset {
@@ -2415,6 +2519,45 @@ export const useSceneStore = defineStore('scene', {
if (track) inst.clipTrackId = clipTrackId
}
},
/** Move a clip instance, cascading non-overlap across its track —
* mirrors `setAudioClipPosition`. Preferred over `moveClipInstance`
* for user-initiated drags so instances can't land on top of each
* other. Returns `false` only when the instance or target track
* is unknown. */
setClipInstancePosition(id: string, startTime: number, clipTrackId?: string): boolean {
const page = this.activeScene
const inst = page.clipInstances.find((ci) => ci.id === id)
if (!inst) return false
const targetTrackId = clipTrackId ?? inst.clipTrackId
const targetTrack = page.clipTracks.find((t) => t.id === targetTrackId)
if (!targetTrack) return false
const lenOf = (ci: ClipInstance): number => {
const clip = this.project.clips.find((c) => c.id === ci.clipId)
if (!clip) return 0
return Math.max(0, clip.duration - (ci.trimStart ?? 0) - (ci.trimEnd ?? 0))
}
const instLen = lenOf(inst)
const oldCenter = inst.startTime + instLen / 2
const others = page.clipInstances.filter(
(ci) => ci.clipTrackId === targetTrackId && ci.id !== id,
)
const appliedStart = cascadePushAside(
others,
oldCenter,
instLen,
lenOf,
startTime,
)
inst.clipTrackId = targetTrackId
inst.startTime = appliedStart
return true
},
trimClipInstance(
id: string,
patch: { startTime?: number; trimStart?: number; trimEnd?: number },
@@ -2448,6 +2591,24 @@ export const useSceneStore = defineStore('scene', {
this.activeScene.clipInstances = this.activeScene.clipInstances.filter((ci) => ci.id !== id)
if (this.selectedClipInstanceId === id) this.selectedClipInstanceId = null
},
/** Clone a clip instance onto the same clip track, placed directly
* after the original using its visible (trim-adjusted) duration.
* Per-instance transform + trim settings are carried forward. */
duplicateClipInstance(id: string): string | null {
const src = this.activeScene.clipInstances.find((ci) => ci.id === id)
if (!src) return null
const clip = this.project.clips.find((c) => c.id === src.clipId)
const base = clip?.duration ?? 1
const visible = Math.max(0.05, base - (src.trimStart ?? 0) - (src.trimEnd ?? 0))
const copy: ClipInstance = {
...structuredClone(toRaw(src)),
id: uid(),
startTime: src.startTime + visible,
}
this.activeScene.clipInstances.push(copy)
return copy.id
},
/** Update the per-instance x / y / scaleX / scaleY. Missing fields
* on the patch are left alone; clearing a field (setting it to
* the default 0 / 1) prunes it from the stored object so the