From 37e7942e82331057ae59458f8709af0f57471f10 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:29:58 +0100 Subject: [PATCH] feat(loop): adjust a loop region instead of redrawing it Reported in discussion #507: changing the size of a selection meant clicking and dragging again from scratch, which restarts the whole thing and loses the precision you had. The numeric fields are the only exact route, and finding a seamless loop through them is guesswork. The region could not be touched at all -- .loop-region was pointer-events: none, so every pointerdown reached the create-drag underneath, which resets both edges to the click position. Adjusting one edge necessarily destroyed the other. Three gestures now: - a handle at either edge moves that edge alone, so a loop can be tightened one side at a time; - the body moves both edges together, preserving length, so a loop found by ear can be slid without being re-measured; - a press that does not travel still seeks, which is what clicking inside the selection did before the region took pointer events. Losing that would have been a regression for anyone who just wants to move the playhead. The handles are 12px and overhang the 2px border on both sides, because an edge you cannot reliably grab is the finnicky behaviour this is meant to replace. They only render on hover or while dragging, so the selection looks the same at rest. create-drag already guarded with `e.target.closest(".loop-region")` -- dead code until now, since the element could not receive events. It is a real guard again, alongside the stopPropagation on the region's own handler. The geometry lives in its own module rather than in transport.js, which cannot be imported outside a browser: it pulls in state.js, which touches document at module load. Same reason playbackStems.js is separate. That makes the clamping testable, which is the half that matters -- an edge crossing its partner, or a region pushed against either end of the track. MIN_LOOP_SEC moves with it and now has one definition. Pointer events throughout, so this works with touch and pen. Closes #538. --- static/css/waves.css | 44 +++++++++++++- static/index.html | 2 +- static/js/loopRegion.js | 42 ++++++++++++++ static/js/transport.js | 87 +++++++++++++++++++++++++++- tests/js/loop-drag.test.mjs | 111 ++++++++++++++++++++++++++++++++++++ 5 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 static/js/loopRegion.js create mode 100644 tests/js/loop-drag.test.mjs diff --git a/static/css/waves.css b/static/css/waves.css index ebdfcfc7..6f490621 100644 --- a/static/css/waves.css +++ b/static/css/waves.css @@ -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; diff --git a/static/index.html b/static/index.html index 7d9f58c8..c79d4064 100644 --- a/static/index.html +++ b/static/index.html @@ -617,7 +617,7 @@
- +