From 852dce514201798017c2bf80bd618ce235268e86 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 8 Oct 2026 21:13:09 +0200 Subject: [PATCH] Shrink a clip with the wave and drop it with a click beside (#357) Zoomed out, the wave left room above and below it, but the bright file, the picked clip and its handles still took the whole height. And a click in the wave beside the picked clip kept it picked, so only the strip could take it back to the sound. - fileReach in waveView.ts is how far up and down the file reaches: full scale and its inset, never past the area. At level 1 it is the whole height, zoomed in too. - The bright file, the shade outside the clip and the two handles are drawn within that reach. - A click over the wave beside the picked clip picks the sound. Inside the clip or on a handle nothing changes, and a drag still spans the clip anew. Tests: - Unit wave-view: 11 green (1 new), a counter-check red - e2e 34 (melektaus-atx): ALL PASSED, 91 checks (1 new) - Gate (melektaus-atx): CHECK EXIT 0, 271 files, 4195 tests Co-Authored-By: Claude Opus 5.5 --- src/editor/sound/WaveCanvas.ts | 39 ++++++++++++++++++++++------------ src/editor/sound/waveView.ts | 8 +++++++ tests/editor/wave-view.test.ts | 8 +++++++ tools/e2e/34-sound.mjs | 12 +++++++++++ 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/editor/sound/WaveCanvas.ts b/src/editor/sound/WaveCanvas.ts index a2f31eb2..28dd5b27 100644 --- a/src/editor/sound/WaveCanvas.ts +++ b/src/editor/sound/WaveCanvas.ts @@ -21,6 +21,7 @@ import { type CutSpan, draggedSpan, edgeAt, + fileReach, fitWave, levelMarks, levelOffset, @@ -65,7 +66,8 @@ const RAMP_STEPS = 32; /** * A drag that spans: an edge of the clip `clipId`, that clip anew, or with `clipId` null a new clip. - * `picks` is what a press in the strip selects when it does not move: a clip, or null for the sound. + * `picks` is what a press selects when it does not move: in the strip a clip or null for the sound, + * over the wave null beside the picked clip. */ type Gesture = | { kind: 'pan'; pointerX: number; panX: number } @@ -356,9 +358,13 @@ export class WaveCanvas extends Widget { }; return; } + // Over the wave a click beside the picked clip picks the sound again. const clip = this.pickedClip(); const span = clip && this.clipSpan(clip, wave); - this.gesture = { ...start, clipId: clip?.id ?? null, edge: span ? edgeAt(this.view, span, p.x) : null }; + const edge = span ? edgeAt(this.view, span, p.x) : null; + const beside = + span && !edge && (p.x < secondsToX(this.view, span.start) || p.x > secondsToX(this.view, span.end)); + this.gesture = { ...start, clipId: clip?.id ?? null, edge, picks: beside ? null : undefined }; } private onPointerMove(e: FederatedPointerEvent): void { @@ -487,16 +493,18 @@ export class WaveCanvas extends Widget { const mid = WAVE_TOP + wh / 2; const half = this.level * this.fullHalf; const room = wh / 2; - // The file itself a shade lighter than what lies before and after it. + // The file itself a shade lighter than what lies around it, as high as the wave is zoomed out. + const reach = fileReach(this.level, this.fullHalf, WAVE_INSET, room); const left = Math.max(0, x0); const right = Math.min(w, x1); - if (right > left) this.waveG.rect(left, WAVE_TOP, right - left, wh).fill({ color: c.panel, alpha: 0.5 }); + if (right > left) + this.waveG.rect(left, mid - reach, right - left, 2 * reach).fill({ color: c.panel, alpha: 0.5 }); if (right > left) this.waveG.rect(left, mid, right - left, 1).fill({ color: c.border }); this.drawWave(wave, left, right, mid, half, room); this.drawRuler(left, right); this.drawStrip(asset, wave); const span = this.shownClipSpan(); - if (span) this.drawCut(wave, span, mid, half, room); + if (span) this.drawCut(wave, span, mid, half, room, reach); this.drawScale(mid, room); if (this.playhead !== null) { const x = secondsToX(this.view, this.playhead); @@ -582,16 +590,19 @@ export class WaveCanvas extends Widget { this.syncStrip(placed); } - /** Dark outside the stretch, a handle at each edge, and the ramp at each edge inside the file. */ - private drawCut(wave: Wave, span: CutSpan, mid: number, half: number, room: number): void { + /** + * Dark outside the stretch, a handle at each edge, and the ramp at each edge inside the file – all + * as far up and down from the middle as the file reaches. + */ + private drawCut(wave: Wave, span: CutSpan, mid: number, half: number, room: number, reach: number): void { const c = Theme.colors; - const { h } = this; const w = this.areaWidth; + const top = mid - reach; const a = secondsToX(this.view, span.start); const b = secondsToX(this.view, span.end); const shade = { color: 0x000000, alpha: 0.55 }; - if (a > 0) this.overlay.rect(0, WAVE_TOP, a, h - WAVE_TOP).fill(shade); - if (b < w) this.overlay.rect(b, WAVE_TOP, w - b, h - WAVE_TOP).fill(shade); + if (a > 0) this.overlay.rect(0, top, a, 2 * reach).fill(shade); + if (b < w) this.overlay.rect(b, top, w - b, 2 * reach).fill(shade); const cut = cutOf(this.pickedClip()?.props ?? {}); const range = cutRange(wave.channels[0]!.length, wave.rate, { ...cut, ...span }); const ramp = (from: number, to: number, rising: boolean, curve: FadeCurve) => { @@ -620,12 +631,12 @@ export class WaveCanvas extends Widget { [b, 'end'], ] as const) { const held = this.gesture?.kind === 'span' && this.gesture.edge === edge; - this.overlay.rect(x - 1, WAVE_TOP, 2, h - WAVE_TOP).fill(held ? c.hot : c.accent); + this.overlay.rect(x - 1, top, 2, 2 * reach).fill(held ? c.hot : c.accent); const toward = edge === 'start' ? 1 : -1; this.overlay - .moveTo(x, WAVE_TOP) - .lineTo(x + toward * 8, WAVE_TOP) - .lineTo(x, WAVE_TOP + 10) + .moveTo(x, top) + .lineTo(x + toward * 8, top) + .lineTo(x, top + 10) .closePath() .fill(held ? c.hot : c.accent); } diff --git a/src/editor/sound/waveView.ts b/src/editor/sound/waveView.ts index 785aaa88..7f1edbdb 100644 --- a/src/editor/sound/waveView.ts +++ b/src/editor/sound/waveView.ts @@ -66,6 +66,14 @@ export function levelOffset(amplitude: number, level: number, half: number): num return amplitude * level * half; } +/** + * Pixels from the middle the file and its clip reach up and down: full scale and `inset` around it, + * zoomed out short of the area, zoomed in never past its `room` (#357). + */ +export function fileReach(level: number, half: number, inset: number, room: number): number { + return Math.min(room, levelOffset(1, level, half) + inset); +} + /** * The marks of the level scale: every 6 dB down from 0, as far from the middle as they are drawn. * Only those within `room` of the middle and at least `minPx` from it – 6 dB halve the distance, diff --git a/tests/editor/wave-view.test.ts b/tests/editor/wave-view.test.ts index e25caf29..f27da412 100644 --- a/tests/editor/wave-view.test.ts +++ b/tests/editor/wave-view.test.ts @@ -7,6 +7,7 @@ import { clampWave, draggedSpan, edgeAt, + fileReach, fitWave, levelMarks, MAX_LEVEL, @@ -43,6 +44,13 @@ describe('the height of the wave (#357)', () => { expect(loud[0]).toBe(-24); expect(loud).not.toContain(0); }); + + it('lets the file and its clip reach as high as full scale zoomed out, never past the area', () => { + // Full scale 100 px from the middle at level 1, 6 px inset, 106 px of room. + expect(fileReach(1, 100, 6, 106)).toBe(106); + expect(fileReach(0.25, 100, 6, 106)).toBe(31); + expect(fileReach(4, 100, 6, 106)).toBe(106); + }); }); describe('the wave view', () => { diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index a87d010f..d5a79128 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -2016,6 +2016,18 @@ export async function sound(t) { (await picked()) === clipId, JSON.stringify({ undoneClip, redoneClip, pickedBeside, picked: await picked() }), ); + const inClip = await json('__gm.soundTab.at(0.2)'); + await click(inClip.x, inClip.y); + const pickedInClip = await picked(); + const besideClip = await json('__gm.soundTab.at(0.6)'); + await click(besideClip.x, besideClip.y); + const pickedBesideClip = await picked(); + await click(onBand.x, onBand.y); + check( + 'a click over the wave inside the picked clip keeps it, beside it picks the sound', + pickedInClip === clipId && pickedBesideClip === null && (await picked()) === clipId, + JSON.stringify({ pickedInClip, pickedBesideClip, picked: await picked() }), + ); dragFrom = await json('__gm.soundTab.at(0.3)'); dragTo = await json('__gm.soundTab.at(0.5)'); await t.mouse('mouseMoved', dragFrom.x - 1, dragFrom.y);