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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-08 21:13:09 +02:00
1 parent 42a3759d9d
commit 852dce5142
4 files changed
+53 -14

No files matched your search

+25 -14
View File
@@ -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);
}
+8
View File
@@ -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,
+8
View File
@@ -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', () => {
+12
View File
@@ -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);