fix(audio): Shift+grab opens the precision rail anchored at the playhead

The first grab implementation degraded Shift+grab to a quarter-speed
relative crawl — losing the ±5-minute rail the user expects when they
grab the handle with Shift held. The rail is back: Shift+grab anchors
railWindow at the CURRENT position (not the nearest chapter line, and
never teleports the playhead on pointerdown); plain grab stays relative
1:1. GRAB_FINE_SCALE is deleted as dead code, and the dialog guide
documents the restored gesture.
This commit is contained in:
John O'Keefe committed 2026-10-27 19:04:59 -04:00
1 parent b462c7fb44
commit e5d4744977
4 files changed
+38 -37

No files matched your search

+23 -19
View File
@@ -44,7 +44,6 @@ import {
bufferedSpanPct,
chapterIndexFor,
denseTicks,
GRAB_FINE_SCALE,
isHandleGrab,
MAGNET_RADIUS_PX,
nearestChapterLine,
@@ -235,11 +234,11 @@ Alpine.data("audioReader", () => ({
railTo: 0,
railTickList: [] as Array<{ start: number; title: string }>,
// §9 interaction model: "free" = 1:1 mouse drag (no rail), "rail" =
// Shift+drag precision window, "touch" = incremental cursor with
// pull-away precision, "grab" = the pointerdown landed ON the
// playhead thumb — the position drags RELATIVE from where it is
// (never teleporting to the click X or a chapter line; Shift =
// quarter-speed fine crawl). The rail is only visible for the
// Shift+drag precision window (also Shift+grab ON the thumb, anchored
// at the playhead), "touch" = incremental cursor with pull-away
// precision, "grab" = the pointerdown landed ON the playhead thumb —
// the position drags RELATIVE from where it is (never teleporting to
// the click X or a chapter line). The rail is only visible for the
// middle two.
dragMode: null as null | "free" | "rail" | "touch" | "grab",
railVisible: false,
@@ -945,12 +944,24 @@ Alpine.data("audioReader", () => ({
} else if (isHandleGrab(e.clientX, rect, this.globalPosition(), duration)) {
// Grabbing the HANDLE itself: the playhead must not teleport —
// neither to the click X (free drag) nor to a chapter line
// (Shift+click). The thumb checks this BEFORE the Shift branch:
// the grab is relative from where the handle is, and Shift only
// slows the same drag to a fine crawl.
this.dragMode = "grab";
this.grabLastX = e.clientX;
// (Shift+click). The thumb check runs BEFORE the bare Shift
// branch: plain grab drags relative from where the handle is;
// Shift+grab opens the precision rail ANCHORED AT THE CURRENT
// POSITION (±5 min around the playhead, not around the nearest
// chapter line).
this.scrubValue = Math.min(duration, Math.max(0, this.globalPosition()));
if (e.shiftKey) {
this.dragMode = "rail";
const window = railWindow(this.scrubValue, duration);
this.railFrom = window.from;
this.railTo = window.to;
this.railTickList = railTicks(window, this.chapters);
this.railVisible = true;
this.railLiftY = 0;
} else {
this.dragMode = "grab";
this.grabLastX = e.clientX;
}
} else if (e.shiftKey) {
// Shift+click/drag: the precision rail, anchored to the NEAREST
// chapter line (Shift+click = snap regardless of radius).
@@ -998,16 +1009,9 @@ Alpine.data("audioReader", () => ({
if (this.dragMode === "grab") {
// Relative handle drag: each move advances from the CURRENT
// position by the pointer's delta (never re-maps the click X).
// Shift = quarter-speed fine placement.
const dx = e.clientX - this.grabLastX;
this.grabLastX = e.clientX;
this.scrubValue = applyGrab(
this.scrubValue,
dx,
rect.width / duration,
duration,
e.shiftKey ? GRAB_FINE_SCALE : 1,
);
this.scrubValue = applyGrab(this.scrubValue, dx, rect.width / duration, duration);
return;
}
if (this.dragMode === "touch") {