Skip to content

feat(loop): adjust a loop region instead of redrawing it - #539

Merged
thcp merged 1 commit into
0.16.1from
feat/538-draggable-loop-region
Aug 31, 2026
Merged

feat(loop): adjust a loop region instead of redrawing it#539
thcp merged 1 commit into
0.16.1from
feat/538-draggable-loop-region

Conversation

@thcp

@thcp thcp commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Closes #538. From discussion #507 (@digidirt). Branches off 0.16.1.

The problem

Changing a selection's size meant re-dragging from scratch:

if you want to change selection size you have to re-click and drag again which means less precision because it restarts the whole selection process [...] The aim is to get a seamless loop: using the numeric boxes involves a lot of guesswork plus trial and error

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

gesture result
drag either edge handle moves that edge alone, the other stays put
drag the region body moves both edges together, length preserved
press without moving seeks, exactly as before

That third one matters: making the body draggable would otherwise break click-to-seek over the selection. It uses the same movement threshold the create-drag already uses to tell a click from a drag.

Handles are 12px and overhang the 2px border on both sides -- an edge you cannot reliably grab is the finnicky behaviour this replaces. They render only on hover or while dragging, so the selection looks unchanged at rest.

startDrag already guarded with e.target.closest(".loop-region"). That was dead code, since the element could not receive events; it is a real guard again now, alongside a stopPropagation on the region's own handler.

Structure

The geometry went into static/js/loopRegion.js rather than staying 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 its own module.

That makes the clamping testable, which is the half worth testing: an edge crossing its partner, or a region pushed against either end of the track. The event plumbing is not something a node test can say anything useful about.

MIN_LOOP_SEC moved with it and now has a single definition.

Verification

tests/js/loop-drag.test.mjs   12/12
npm run test:js               48/48
playwright                    83 passed (1.2m)

The e2e run matters here: it includes a loop survives zooming in and out untouched and a loop can be marked while zoomed in, so the region becoming interactive has not broken existing loop behaviour.

Checked by hand that nothing else is blocked by the region now taking pointer events: .waves-column is its parent and has no lane- or canvas-level pointer handlers, and wheel events still bubble, so wheel-zoom over a selection is unaffected.

Two notes

Targeting 0.16.1. That branch has the transport accessor from #536 which this builds on, and main does not yet. If you would rather keep 0.16.1 to fixes only, say so and I will retarget to main once 0.16.1 lands -- it should be a clean rebase.

Not implemented: keyboard nudging. Arrow keys to move a selected edge by a frame would complement this for the "seamless loop" goal, and is probably the natural follow-up. Left out to keep this reviewable.

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.
@thcp
thcp marked this pull request as ready for review August 31, 2026 21:31
@thcp
thcp merged commit 31ef8ac into 0.16.1 Aug 31, 2026
6 of 8 checks passed
@thcp
thcp deleted the feat/538-draggable-loop-region branch September 3, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant