Let a drag change only the picked clip in the Sound and Video tab (#403)
A drag with a clip picked could span a new clip in the strip or span the picked one anew over the wave. Both happened by accident while working on a clip. Now a drag only makes a new clip while none is picked. With one picked it moves its edges or the whole of it. - With no clip picked, a drag in the strip or over the wave (in the Video tab the track) spans a new clip, as before. - A click over the wave or in the track picks the clip at that point, as a click on its band does. Beside the clips it picks the sound or the video. The picked clip wins where clips lie over each other. - With a clip picked, its handles move its edges as before. A drag on its band in the strip moves the whole clip, its length kept and inside the file. Any other drag does nothing. - The rules live in waveView.ts (clipGrip, clipToPick, movedSpan) and both tabs use them. The cursor shows grab over the picked band. - The Video tab's track draws the clips not picked faint, as the wave does, so a click there has something to aim at. Tests: - Unit wave-view.test.ts: 16 tests (4 new) - Counter-check: a strip drag beside the picked clip spanning a new one again fails the picked-clip rule test - e2e 34 + 66 (atx): ALL PASSED, 162 checks, 1 new check in each - Gate (atx): 295 files, 4527 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c815d2c35f
commit
ce933a4f32
7 files changed
+249
-68
No files matched your search
@@ -18,16 +18,18 @@ import { axisTween } from '../viewTween';
|
||||
import {
|
||||
clampWave,
|
||||
type ClipBand,
|
||||
clipAt,
|
||||
type ClipGrip,
|
||||
clipGrip,
|
||||
clipToPick,
|
||||
coversShown,
|
||||
type CutSpan,
|
||||
draggedSpan,
|
||||
drawnSeconds,
|
||||
edgeAt,
|
||||
fileReach,
|
||||
fitWave,
|
||||
levelMarks,
|
||||
levelOffset,
|
||||
movedSpan,
|
||||
rulerLabel,
|
||||
rulerStep,
|
||||
secondsToX,
|
||||
@@ -71,21 +73,21 @@ const RAMP_STEPS = 32;
|
||||
const DRAWN_MARGIN = 0.5;
|
||||
|
||||
/**
|
||||
* 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 selects when it does not move: in the strip a clip or null for the sound,
|
||||
* over the wave null beside the picked clip.
|
||||
* A press over the clips: a drag moves what `grip` holds of the clip `clipId`, or with `clipId` null
|
||||
* spans a new clip. `picks` is what the press selects when it does not move: a clip or null for the
|
||||
* sound.
|
||||
*/
|
||||
type Gesture =
|
||||
| { kind: 'pan'; pointerX: number; panX: number }
|
||||
| {
|
||||
kind: 'span';
|
||||
clipId: string | null;
|
||||
edge: 'start' | 'end' | null;
|
||||
grip: ClipGrip;
|
||||
from: number;
|
||||
pointerX: number;
|
||||
moved: boolean;
|
||||
span: CutSpan;
|
||||
picks?: string | null;
|
||||
picks: string | null;
|
||||
};
|
||||
|
||||
/** A file's samples as the canvas draws them: decoded once, its block peaks made on first sight. */
|
||||
@@ -109,10 +111,11 @@ function waveOf(buffer: AudioBuffer): Wave {
|
||||
|
||||
/**
|
||||
* The Sound tab's canvas (#343): the whole file as one waveform, the loudest of its channels, under
|
||||
* a ruler in seconds. The clips of the sound (#357) stand as bands in a strip under the ruler: a
|
||||
* click picks one, a drag there spans a new one. The picked clip stands between two handles, what
|
||||
* lies outside it darker; dragging a handle moves that edge, dragging beside them spans it anew.
|
||||
* With no clip picked, a drag over the wave spans a new one too. One drag is one undo step. The
|
||||
* a ruler in seconds. The clips of the sound (#357) stand as bands in a strip under the ruler, and
|
||||
* a click there or over the wave picks the one at that point. With no clip picked a drag in either
|
||||
* spans a new one. The picked clip stands between two handles, what lies outside it darker:
|
||||
* dragging a handle moves that edge, dragging its band moves the clip whole, and any other drag
|
||||
* does nothing (#403). One drag is one undo step. The
|
||||
* wheel zooms, the middle button pans, as in the music tabs; over the level scale on the left the
|
||||
* wheel zooms the wave's height.
|
||||
*/
|
||||
@@ -383,30 +386,19 @@ export class WaveCanvas extends Widget {
|
||||
}
|
||||
if (e.button !== 0 || p.x < 0) return;
|
||||
const from = Math.max(0, Math.min(wave.duration, xToSeconds(this.view, p.x)));
|
||||
const start = {
|
||||
const clip = this.pickedClip();
|
||||
const span = clip && this.clipSpan(clip, wave);
|
||||
const inStrip = p.y >= RULER_HEIGHT && p.y < WAVE_TOP;
|
||||
this.gesture = {
|
||||
kind: 'span',
|
||||
clipId: clip?.id ?? null,
|
||||
grip: clipGrip(this.view, span, inStrip, p.x),
|
||||
from,
|
||||
pointerX: p.x,
|
||||
moved: false,
|
||||
span: { start: from, end: from },
|
||||
} as const;
|
||||
if (p.y >= RULER_HEIGHT && p.y < WAVE_TOP) {
|
||||
// In the strip a press picks the band under it, a drag spans a new clip.
|
||||
this.gesture = {
|
||||
...start,
|
||||
clipId: null,
|
||||
edge: null,
|
||||
picks: clipAt(this.view, this.bands(asset, wave), p.x),
|
||||
};
|
||||
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);
|
||||
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 };
|
||||
span: span ?? { start: from, end: from },
|
||||
picks: clipToPick(this.view, this.bands(asset, wave), clip?.id ?? null, p.x),
|
||||
};
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
@@ -423,13 +415,21 @@ export class WaveCanvas extends Widget {
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
if (!g.moved && g.edge === null && Math.abs(p.x - g.pointerX) < DRAG_START) return;
|
||||
const onEdge = g.grip === 'start' || g.grip === 'end';
|
||||
if (!g.moved && !onEdge && Math.abs(p.x - g.pointerX) < DRAG_START) return;
|
||||
// A drag that holds nothing does nothing, and is no click either.
|
||||
if (g.grip === null) {
|
||||
this.gesture = null;
|
||||
return;
|
||||
}
|
||||
g.moved = true;
|
||||
// Only the picked clip has handles: an edge moves that one.
|
||||
const clip = this.pickedClip();
|
||||
const before = clip && clip.id === g.clipId ? this.clipSpan(clip, wave) : g.span;
|
||||
const at = xToSeconds(this.view, p.x);
|
||||
g.span = draggedSpan(before, g.edge, g.from, at, wave.duration, LEAST_SPAN);
|
||||
g.span =
|
||||
g.grip === 'whole'
|
||||
? movedSpan(before, at - g.from, wave.duration)
|
||||
: draggedSpan(before, g.grip === 'new' ? null : g.grip, g.from, at, wave.duration, LEAST_SPAN);
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@ export class WaveCanvas extends Widget {
|
||||
const asset = this.asset();
|
||||
const wave = this.wave();
|
||||
if (!g.moved) {
|
||||
if (asset && g.picks !== undefined)
|
||||
if (asset)
|
||||
this.state.select(
|
||||
g.picks
|
||||
? { kind: 'clip', assetId: asset.id, clipId: g.picks }
|
||||
@@ -469,7 +469,7 @@ export class WaveCanvas extends Widget {
|
||||
const values: PropertyBag = {};
|
||||
if (next.start !== now.start) values[START_KEY] = next.start;
|
||||
if (next.end !== now.end) values[END_KEY] = next.end;
|
||||
const label = g.edge ? 'Move clip edge' : 'Span clip';
|
||||
const label = g.grip === 'whole' ? 'Move clip' : 'Move clip edge';
|
||||
if (Object.keys(values).length > 0)
|
||||
this.state.execute(new SetClipPropsCommand(asset.id, clip.id, values, label));
|
||||
}
|
||||
@@ -491,8 +491,11 @@ export class WaveCanvas extends Widget {
|
||||
|
||||
private updateCursor(x: number, y: number): void {
|
||||
const span = this.shownClipSpan();
|
||||
const inside = x >= 0 && x <= this.areaWidth && y >= WAVE_TOP && y <= this.h;
|
||||
const cursor = inside && span && edgeAt(this.view, span, x) ? 'ew-resize' : 'default';
|
||||
const across = x >= 0 && x <= this.areaWidth;
|
||||
const inStrip = across && y >= RULER_HEIGHT && y < WAVE_TOP;
|
||||
const overWave = across && y >= WAVE_TOP && y <= this.h;
|
||||
const grip = span && (inStrip || overWave) ? clipGrip(this.view, span, inStrip, x) : null;
|
||||
const cursor = grip === 'whole' ? 'grab' : grip ? 'ew-resize' : 'default';
|
||||
if (this.cursor !== cursor) this.cursor = cursor;
|
||||
}
|
||||
|
||||
@@ -751,7 +754,7 @@ export class WaveCanvas extends Widget {
|
||||
[a, 'start'],
|
||||
[b, 'end'],
|
||||
] as const) {
|
||||
const held = this.gesture?.kind === 'span' && this.gesture.edge === edge;
|
||||
const held = this.gesture?.kind === 'span' && this.gesture.grip === edge;
|
||||
this.overlay.rect(x - 1, top, 2, 2 * reach).fill(held ? c.hot : c.accent);
|
||||
const toward = edge === 'start' ? 1 : -1;
|
||||
this.overlay
|
||||
|
||||
@@ -196,6 +196,38 @@ export function clipAt(view: WaveView, bands: readonly ClipBand[], x: number): s
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a drag that starts at `x` takes hold of (#403): with no clip picked a new clip, in the strip
|
||||
* and over the wave alike. With one picked an edge of it over the wave, the whole of it on its band
|
||||
* in the strip, and nothing anywhere else.
|
||||
*/
|
||||
export type ClipGrip = 'new' | 'start' | 'end' | 'whole' | null;
|
||||
|
||||
export function clipGrip(view: WaveView, picked: CutSpan | null, inStrip: boolean, x: number): ClipGrip {
|
||||
if (!picked) return 'new';
|
||||
if (!inStrip) return edgeAt(view, picked, x);
|
||||
return x >= secondsToX(view, picked.start) && x <= secondsToX(view, picked.end) ? 'whole' : null;
|
||||
}
|
||||
|
||||
/** The clip a click at `x` picks: the picked one where it lies, else the band drawn last there. */
|
||||
export function clipToPick(
|
||||
view: WaveView,
|
||||
bands: readonly ClipBand[],
|
||||
pickedId: string | null,
|
||||
x: number,
|
||||
): string | null {
|
||||
const held = bands.find((band) => band.id === pickedId);
|
||||
if (held && clipAt(view, [held], x)) return held.id;
|
||||
return clipAt(view, bands, x);
|
||||
}
|
||||
|
||||
/** The span moved by `by` seconds, its length kept and held inside the file. */
|
||||
export function movedSpan(span: CutSpan, by: number, duration: number): CutSpan {
|
||||
const length = span.end - span.start;
|
||||
const start = clamp(span.start + by, 0, Math.max(0, duration - length));
|
||||
return { start, end: start + length };
|
||||
}
|
||||
|
||||
/** What a clip stores for a span: seconds to the nearest sample, an end at the end of the file as 0. */
|
||||
export function storedCut(span: CutSpan, duration: number, rate: number): { start: number; end: number } {
|
||||
const sample = 1 / rate;
|
||||
|
||||
@@ -23,11 +23,13 @@ import { crispText, type Size, textStyle, Theme, ui, Widget, zoomFactor } from '
|
||||
import {
|
||||
clampWave,
|
||||
type ClipBand,
|
||||
clipAt,
|
||||
type ClipGrip,
|
||||
clipGrip,
|
||||
clipToPick,
|
||||
type CutSpan,
|
||||
draggedSpan,
|
||||
edgeAt,
|
||||
fitWave,
|
||||
movedSpan,
|
||||
rulerLabel,
|
||||
rulerStep,
|
||||
secondsToX,
|
||||
@@ -64,12 +66,12 @@ type Gesture =
|
||||
| {
|
||||
kind: 'span';
|
||||
clipId: string | null;
|
||||
edge: 'start' | 'end' | null;
|
||||
grip: ClipGrip;
|
||||
from: number;
|
||||
pointerX: number;
|
||||
moved: boolean;
|
||||
span: CutSpan;
|
||||
picks?: string | null;
|
||||
picks: string | null;
|
||||
};
|
||||
|
||||
/** The tab's own player: the video shown, with its sound, apart from the ones sprites wear. */
|
||||
@@ -83,7 +85,9 @@ interface Preview {
|
||||
/**
|
||||
* The Video tab's canvas (#402): the video on top, a ruler in seconds under it, the clips as bands
|
||||
* in a strip, and a track where the picked clip stands between two handles. A click in the strip
|
||||
* picks a clip, a drag there or in the track spans a new one; dragging a handle moves that edge.
|
||||
* or the track picks the clip at that point. With no clip picked a drag in either spans a new one;
|
||||
* with one picked, dragging a handle moves that edge, dragging its band moves the clip whole, and
|
||||
* any other drag does nothing (#403).
|
||||
* One drag is one undo step. A click in the ruler shows that moment; the wheel zooms the time, the
|
||||
* middle button pans, as in the Sound tab.
|
||||
*/
|
||||
@@ -377,30 +381,28 @@ export class VideoCanvas extends Widget {
|
||||
this.seekTo(from);
|
||||
return;
|
||||
}
|
||||
const start = {
|
||||
const clip = this.pickedClip();
|
||||
const span = clip && this.clipSpan(clip, asset);
|
||||
this.gesture = {
|
||||
kind: 'span',
|
||||
clipId: clip?.id ?? null,
|
||||
grip: clipGrip(this.view, span, y < RULER_HEIGHT + STRIP_HEIGHT, p.x),
|
||||
from,
|
||||
pointerX: p.x,
|
||||
moved: false,
|
||||
span: { start: from, end: from },
|
||||
} as const;
|
||||
if (y < RULER_HEIGHT + STRIP_HEIGHT) {
|
||||
this.gesture = { ...start, clipId: null, edge: null, picks: clipAt(this.view, this.bands(asset), p.x) };
|
||||
return;
|
||||
}
|
||||
const clip = this.pickedClip();
|
||||
const span = clip && this.clipSpan(clip, asset);
|
||||
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 };
|
||||
span: span ?? { start: from, end: from },
|
||||
picks: clipToPick(this.view, this.bands(asset), clip?.id ?? null, p.x),
|
||||
};
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
const p = this.toLocal(e.global);
|
||||
const g = this.gesture;
|
||||
const asset = this.asset();
|
||||
if (!g || !asset) return;
|
||||
if (!g || !asset) {
|
||||
this.updateCursor(p.x, p.y);
|
||||
return;
|
||||
}
|
||||
if (g.kind === 'pan') {
|
||||
this.view = clampWave({ ...this.view, panX: g.panX + p.x - g.pointerX }, asset.duration, this.w);
|
||||
this.needsFit = false;
|
||||
@@ -411,22 +413,44 @@ export class VideoCanvas extends Widget {
|
||||
this.seekTo(xToSeconds(this.view, p.x));
|
||||
return;
|
||||
}
|
||||
if (!g.moved && g.edge === null && Math.abs(p.x - g.pointerX) < DRAG_START) return;
|
||||
const onEdge = g.grip === 'start' || g.grip === 'end';
|
||||
if (!g.moved && !onEdge && Math.abs(p.x - g.pointerX) < DRAG_START) return;
|
||||
// A drag that holds nothing does nothing, and is no click either.
|
||||
if (g.grip === null) {
|
||||
this.gesture = null;
|
||||
return;
|
||||
}
|
||||
g.moved = true;
|
||||
const clip = this.pickedClip();
|
||||
const before = clip && clip.id === g.clipId ? this.clipSpan(clip, asset) : g.span;
|
||||
g.span = draggedSpan(before, g.edge, g.from, xToSeconds(this.view, p.x), asset.duration, LEAST_SPAN);
|
||||
this.seekTo(g.edge === 'end' ? g.span.end : g.span.start);
|
||||
const at = xToSeconds(this.view, p.x);
|
||||
g.span =
|
||||
g.grip === 'whole'
|
||||
? movedSpan(before, at - g.from, asset.duration)
|
||||
: draggedSpan(before, g.grip === 'new' ? null : g.grip, g.from, at, asset.duration, LEAST_SPAN);
|
||||
this.seekTo(g.grip === 'end' ? g.span.end : g.span.start);
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** A grabbing hand over the picked clip's band, arrows over its handles in the track. */
|
||||
private updateCursor(x: number, y: number): void {
|
||||
const asset = this.asset();
|
||||
const clip = this.pickedClip();
|
||||
const span = asset && clip ? this.clipSpan(clip, asset) : null;
|
||||
const row = y - this.timelineTop - RULER_HEIGHT;
|
||||
const onTimeline = row >= 0 && row <= STRIP_HEIGHT + TRACK_HEIGHT;
|
||||
const grip = span && onTimeline ? clipGrip(this.view, span, row < STRIP_HEIGHT, x) : null;
|
||||
const cursor = grip === 'whole' ? 'grab' : grip ? 'ew-resize' : 'default';
|
||||
if (this.cursor !== cursor) this.cursor = cursor;
|
||||
}
|
||||
|
||||
private onPointerUp(): void {
|
||||
const g = this.gesture;
|
||||
this.gesture = null;
|
||||
if (g?.kind !== 'span') return this.redraw();
|
||||
const asset = this.asset();
|
||||
if (!g.moved) {
|
||||
if (asset && g.picks !== undefined)
|
||||
if (asset)
|
||||
this.state.select(
|
||||
g.picks
|
||||
? { kind: 'clip', assetId: asset.id, clipId: g.picks }
|
||||
@@ -454,7 +478,12 @@ export class VideoCanvas extends Widget {
|
||||
if (next.end !== now.end) values[VIDEO_END_KEY] = next.end;
|
||||
if (Object.keys(values).length > 0)
|
||||
this.state.execute(
|
||||
new SetClipPropsCommand(asset.id, clip.id, values, g.edge ? 'Move clip edge' : 'Span clip'),
|
||||
new SetClipPropsCommand(
|
||||
asset.id,
|
||||
clip.id,
|
||||
values,
|
||||
g.grip === 'whole' ? 'Move clip' : 'Move clip edge',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -564,12 +593,25 @@ export class VideoCanvas extends Widget {
|
||||
this.syncStrip(placed.map((label) => ({ ...label, y })));
|
||||
}
|
||||
|
||||
/** The video's length lit, what lies outside the picked clip dark, and a handle at each of its edges. */
|
||||
/**
|
||||
* The video's length lit, the clips not picked faint over it as in the Sound tab, and the picked
|
||||
* clip with a handle at each of its edges.
|
||||
*/
|
||||
private drawTrack(asset: VideoAsset, left: number, right: number, top: number): void {
|
||||
const c = Theme.colors;
|
||||
const y = top + RULER_HEIGHT + STRIP_HEIGHT;
|
||||
const h = TRACK_HEIGHT;
|
||||
this.overlay.rect(left, y + 4, Math.max(0, right - left), h - 8).fill({ color: c.panel, alpha: 0.8 });
|
||||
const lit = this.litClipId();
|
||||
for (const { id, span: band } of this.bands(asset)) {
|
||||
const a = secondsToX(this.view, band.start);
|
||||
const b = secondsToX(this.view, band.end);
|
||||
if (id === lit || b < 0 || a > this.w) continue;
|
||||
this.overlay
|
||||
.rect(a, y + 4, Math.max(1, b - a), h - 8)
|
||||
.fill({ color: c.accent, alpha: 0.12 })
|
||||
.stroke({ width: 1, color: c.accent, alpha: 0.35, alignment: 1 });
|
||||
}
|
||||
const clip = this.pickedClip();
|
||||
const span =
|
||||
this.gesture?.kind === 'span' && this.gesture.moved
|
||||
@@ -583,7 +625,7 @@ export class VideoCanvas extends Widget {
|
||||
[a, 'start'],
|
||||
[b, 'end'],
|
||||
] as const) {
|
||||
const held = this.gesture?.kind === 'span' && this.gesture.edge === edge;
|
||||
const held = this.gesture?.kind === 'span' && this.gesture.grip === edge;
|
||||
this.overlay.rect(x - 1, y + 2, 2, h - 4).fill(held ? c.hot : c.accent);
|
||||
const toward = edge === 'start' ? 1 : -1;
|
||||
this.overlay
|
||||
|
||||
Reference in new issue
Block a user