Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion static/css/waves.css
Original file line number Diff line number Diff line change
Expand Up @@ -765,10 +765,52 @@
box-shadow:
inset 1px 0 0 rgba(255, 255, 255, 0.18),
inset -1px 0 0 rgba(255, 255, 255, 0.18);
pointer-events: none;
/* Interactive so the region can be moved and its edges adjusted (#538).
A pointerdown that does not move still falls through to a seek, so
clicking inside the selection behaves as it always did. */
pointer-events: auto;
cursor: grab;
z-index: 3;
}

.loop-region.dragging {
cursor: grabbing;
}

/* Wider than the 2px border they sit on: an edge you cannot reliably grab is
the finnicky behaviour this replaces. Extends outside the region as well as
in, so the handle is catchable from either side. */
.loop-handle {
position: absolute;
top: 0;
bottom: 0;
width: 12px;
cursor: ew-resize;
z-index: 4;
}

.loop-handle-start {
left: -7px;
}

.loop-handle-end {
right: -7px;
}

/* Only visible while the pointer is on the region, so the selection reads the
same as before at rest. */
.loop-region:hover .loop-handle::after,
.loop-region.dragging .loop-handle::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 4px;
width: 4px;
background: var(--gold);
border-radius: 2px;
}

.lane-placeholder {
height: 48px;
position: relative;
Expand Down
2 changes: 1 addition & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,7 @@
<div class="waves-column">
<div id="waves-grid" class="waves-grid" aria-hidden="true"></div>
<div id="multitrack-container"></div>
<div id="loop-region" class="loop-region hidden" aria-hidden="true"></div>
<div id="loop-region" class="loop-region hidden" aria-hidden="true"><div class="loop-handle loop-handle-start" data-loop-handle="start"></div><div class="loop-handle loop-handle-end" data-loop-handle="end"></div></div>
<canvas id="beatgrid-canvas" class="beatgrid-canvas hidden" aria-hidden="true"></canvas>
</div>
<div class="wave-loading-overlay hidden" id="waveLoadingOverlay" aria-label="Loading waveform" data-i18n-aria-label="wave.loadingAria" aria-live="polite">
Expand Down
42 changes: 42 additions & 0 deletions static/js/loopRegion.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Loop-region geometry, kept apart from transport.js so it can be tested
// without a DOM (same reason playbackStems.js is its own module).

// Shortest loop worth having. Also the threshold that separates a click from a
// drag, so a press that barely moves seeks instead of resizing.
export const MIN_LOOP_SEC = 0.2;

/// Where a loop-region drag lands, given where it started and where the pointer
/// is now (#538, discussion #507).
///
/// `mode` is "start" or "end" to move one edge alone, or "move" to slide the
/// whole region. The edge cases here -- an edge crossing its partner, a region
/// pushed against either end of the track -- are where this goes wrong, not in
/// the event plumbing, which is why this is a pure function.
export function loopDragResult({
mode,
pointerTime,
grabTime,
fromStart,
fromEnd,
duration,
minLoop = MIN_LOOP_SEC,
}) {
if (mode === "start") {
// Cannot cross the far edge: minLoop is what keeps a loop audible.
return {
start: Math.max(0, Math.min(pointerTime, fromEnd - minLoop)),
end: fromEnd,
};
}
if (mode === "end") {
return {
start: fromStart,
end: Math.min(duration, Math.max(pointerTime, fromStart + minLoop)),
};
}
// Move: clamp the region as a unit. Clamping each edge on its own would
// squash the loop against the track boundary instead of stopping it there.
const length = fromEnd - fromStart;
const shift = Math.max(-fromStart, Math.min(pointerTime - grabTime, duration - fromEnd));
return { start: fromStart + shift, end: fromStart + shift + length };
}
87 changes: 86 additions & 1 deletion static/js/transport.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fmtTime, fmtTickLabel, fmtTimeMs, parseTimecode, storeGet, storeSet } from "./utils.js";
import { MIN_LOOP_SEC, loopDragResult } from "./loopRegion.js";
import {
playBtn, playMiniBtn, stopBtn, loopBtn, timeEl, masterFader,
speedBtns,
Expand Down Expand Up @@ -26,7 +27,6 @@ import { isDownbeatIndex, getBeats as getGridBeats, getBars as getGridBars } fro
import { computeCountIn } from "./metronome.js";
import { t } from "./i18n.js";

const MIN_LOOP_SEC = 0.2;
// Zoom range. 1 is the whole track fitted to the panel; there is nothing below
// it to show, so it is the floor rather than a soft default. 5 is the ceiling
// because peaks.json carries 1500 points per stem: past roughly 5x a typical
Expand Down Expand Up @@ -447,6 +447,90 @@ export function toggleLoop() {

// Click-drag on the timeline ruler or waveform body to define the loop
// region. Drag direction doesn't matter -- start and end get sorted.
// Adjust an existing loop region rather than redrawing it (#538, discussion
// #507).
//
// Three gestures on one element:
// - a handle at either edge moves only that edge, so a loop can be tightened
// one side at a time instead of being re-measured from scratch;
// - the body moves both edges together, preserving length, so a loop found by
// ear can be slid;
// - a press that does not move is still a seek, which is what the region did
// before it became interactive, and losing that would be a regression for
// anyone who just wants to click inside their selection.
//
// Pointer events throughout, so this works with touch and pen as well as a
// mouse.
function wireLoopRegionAdjust() {
if (!loopRegionEl) return;

let mode = null; // "start" | "end" | "move"
let pointerId = null;
let grabTime = 0; // where in the track the pointer went down
let fromStart = 0;
let fromEnd = 0;
let moved = false;

const apply = (t) => {
const next = loopDragResult({
mode,
pointerTime: t,
grabTime,
fromStart,
fromEnd,
duration: totalDuration,
});
setLoopStart(next.start);
setLoopEnd(next.end);
updateLoopRegionVisual();
};

loopRegionEl.addEventListener("pointerdown", (e) => {
if (e.button !== 0 || !totalDuration) return;
const t = timeFromClientX(e.clientX);
if (t === null) return;
mode = e.target.closest("[data-loop-handle]")?.dataset.loopHandle ?? "move";
pointerId = e.pointerId;
grabTime = t;
fromStart = loopStart;
fromEnd = loopEnd;
moved = false;
loopRegionEl.classList.add("dragging");
loopRegionEl.setPointerCapture(e.pointerId);
// Stops wireLoopDrag's surface handler starting a fresh selection
// underneath this one.
e.stopPropagation();
e.preventDefault();
});

loopRegionEl.addEventListener("pointermove", (e) => {
if (mode === null || e.pointerId !== pointerId) return;
const t = timeFromClientX(e.clientX);
if (t === null) return;
// Same threshold the create-drag uses to tell a click from a drag.
if (Math.abs(t - grabTime) >= MIN_LOOP_SEC) moved = true;
apply(t);
e.preventDefault();
});

const finish = (e) => {
if (mode === null || e.pointerId !== pointerId) return;
const wasMove = mode === "move";
mode = null;
pointerId = null;
loopRegionEl.classList.remove("dragging");
if (!moved && wasMove) {
// A press with no travel: seek, exactly as clicking here did before the
// region took pointer events.
setPlayheadTime(grabTime);
}
syncLoopInputs();
};

loopRegionEl.addEventListener("pointerup", finish);
loopRegionEl.addEventListener("pointercancel", finish);
}

// Tiny drags are treated as clicks and seek the playhead instead.
function wireLoopDrag() {
let dragging = false;
Expand Down Expand Up @@ -701,6 +785,7 @@ export function wireTransportButtons() {
stopBtn.addEventListener("click", stopTransport);
loopBtn.addEventListener("click", toggleLoop);
wireLoopDrag();
wireLoopRegionAdjust();
wireLoopInputs();
wireZoomButtons();
wireLaneScrollSync();
Expand Down
111 changes: 111 additions & 0 deletions tests/js/loop-drag.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Adjusting an existing loop region instead of redrawing it (#538).
//
// Only the clamping is tested: an edge crossing its partner, and a region
// pushed against either end of the track. That is where this breaks; the
// pointer plumbing is not something a node test can say anything useful about.

import { loopDragResult } from '../../static/js/loopRegion.js';

let passed = 0;
let failed = 0;

function check(name, condition, detail = '') {
if (condition) {
passed++;
console.log(`PASS ${name}`);
} else {
failed++;
console.log(`FAIL ${name}${detail ? ` -- ${detail}` : ''}`);
}
}

const near = (a, b) => Math.abs(a - b) < 1e-9;
const DURATION = 100;
// A selection from 20s to 30s, grabbed at 25s.
const base = { grabTime: 25, fromStart: 20, fromEnd: 30, duration: DURATION, minLoop: 0.2 };

// ─── dragging the start edge ───

{
const r = loopDragResult({ ...base, mode: 'start', pointerTime: 22 });
check('start edge moves alone', near(r.start, 22) && near(r.end, 30),
`got ${r.start}..${r.end}`);
}

{
// The whole point of the request: nudging one edge must not disturb the other.
const r = loopDragResult({ ...base, mode: 'start', pointerTime: 5 });
check('start can be dragged earlier without moving the end', near(r.end, 30));
}

{
const r = loopDragResult({ ...base, mode: 'start', pointerTime: 45 });
check('start cannot cross the end', near(r.start, 30 - 0.2) && near(r.end, 30),
`got ${r.start}..${r.end}`);
}

{
const r = loopDragResult({ ...base, mode: 'start', pointerTime: -10 });
check('start clamps at zero', near(r.start, 0));
}

// ─── dragging the end edge ───

{
const r = loopDragResult({ ...base, mode: 'end', pointerTime: 40 });
check('end edge moves alone', near(r.start, 20) && near(r.end, 40),
`got ${r.start}..${r.end}`);
}

{
const r = loopDragResult({ ...base, mode: 'end', pointerTime: 10 });
check('end cannot cross the start', near(r.start, 20) && near(r.end, 20 + 0.2),
`got ${r.start}..${r.end}`);
}

{
const r = loopDragResult({ ...base, mode: 'end', pointerTime: 500 });
check('end clamps at the track length', near(r.end, DURATION));
}

// ─── moving the whole region ───

{
const r = loopDragResult({ ...base, mode: 'move', pointerTime: 35 });
check('move shifts both edges by the same amount',
near(r.start, 30) && near(r.end, 40), `got ${r.start}..${r.end}`);
}

{
const r = loopDragResult({ ...base, mode: 'move', pointerTime: 0 });
check('move stops at the start of the track without squashing',
near(r.start, 0) && near(r.end, 10), `got ${r.start}..${r.end}`);
}

{
const r = loopDragResult({ ...base, mode: 'move', pointerTime: 1000 });
check('move stops at the end of the track without squashing',
near(r.start, 90) && near(r.end, DURATION), `got ${r.start}..${r.end}`);
}

{
// Length is the invariant a move must never change.
for (const pointerTime of [-500, 0, 12, 25, 63, 99, 500]) {
const r = loopDragResult({ ...base, mode: 'move', pointerTime });
if (!near(r.end - r.start, 10)) {
check(`move preserves length at ${pointerTime}`, false, `got ${r.end - r.start}`);
break;
}
}
check('move preserves length everywhere', true);
}

{
// A press with no travel must leave the region exactly where it was, or a
// click-to-seek inside the selection would nudge it.
const r = loopDragResult({ ...base, mode: 'move', pointerTime: base.grabTime });
check('a move of zero changes nothing', near(r.start, 20) && near(r.end, 30));
}

console.log(`\n${passed}/${passed + failed} checks passed`);
process.exit(failed === 0 ? 0 : 1);
Loading