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
+182
-21
@@ -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
|
||||
|
||||
Reference in new issue
Block a user