Let a drag change only the picked clip in the Sound and Video tab (#403)
game-maker / gate (push) Successful in 4m15s
game-maker / suite (push) Failing after 15m40s

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:
adminandClaude Opus 5.5 committed 2026-10-11 12:51:46 +02:00
1 parent c815d2c35f
commit ce933a4f32
7 files changed
+249 -68

No files matched your search

+68 -26
View File
@@ -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