diff --git a/web/src/audio-keys.ts b/web/src/audio-keys.ts index 0b54b831..db6fd584 100644 --- a/web/src/audio-keys.ts +++ b/web/src/audio-keys.ts @@ -233,7 +233,10 @@ export const MOUSE_GUIDE: readonly PointerGuideRow[] = [ label: "Grab handle", description: "Drag the playhead from where it is — nothing moves until you do", }, - { label: "Shift + grab", description: "Same handle drag at quarter speed for fine placement" }, + { + label: "Shift + grab", + description: "Precision rail (±5 min) around the playhead's position", + }, { label: "Shift + click", description: "Jump to the nearest chapter line" }, { label: "Shift + drag", description: "Precision rail (±5 min) around the nearest line" }, { label: "Wheel", description: "Nudge ±5s (Shift: ±60s)" }, diff --git a/web/src/audio-scrubber.test.ts b/web/src/audio-scrubber.test.ts index 3215c150..766378aa 100644 --- a/web/src/audio-scrubber.test.ts +++ b/web/src/audio-scrubber.test.ts @@ -10,7 +10,6 @@ import { dragScale, DRAG_REF_PX, DRAG_SCALE_MIN, - GRAB_FINE_SCALE, GRAB_SLOP_PX, isHandleGrab, nearestChapterLine, @@ -236,15 +235,13 @@ describe("isHandleGrab / applyGrab (§9 handle grab: no teleport)", () => { ); }); - it("crawls at fine scale without teleporting when Shift toggles mid-drag", () => { + it("moves 1:1 with pointer travel and clamps to the book", () => { const pxPerSecond = 800 / duration26h; - expect(applyGrab(5000, 100, pxPerSecond, duration26h, GRAB_FINE_SCALE)).toBeCloseTo( - 5000 + 100 * ((1 / pxPerSecond) * GRAB_FINE_SCALE), + expect(applyGrab(5000, 100, pxPerSecond, duration26h)).toBeCloseTo( + 5000 + (100 / 800) * duration26h, 5, ); - // Scale changes only affect SUBSEQUENT travel — a zero-delta move - // with a new scale must leave the position alone. - expect(applyGrab(5000, 0, pxPerSecond, duration26h, GRAB_FINE_SCALE)).toBe(5000); + expect(applyGrab(50, 100, 0, duration26h)).toBe(50); }); it("clamps to the book and survives a dead bar", () => { diff --git a/web/src/audio-scrubber.ts b/web/src/audio-scrubber.ts index bcedfe50..62eb932f 100644 --- a/web/src/audio-scrubber.ts +++ b/web/src/audio-scrubber.ts @@ -79,7 +79,9 @@ export function snapToChapterPx( // thumb, in bar pixels, for it to count as grabbing the HANDLE rather // than clicking the track. A grab never teleports the position — not // to the click X (free-drag behavior) and not to a chapter line -// (Shift+click behavior) — it drags RELATIVE to where the handle is. +// (Shift+click behavior): a plain grab drags RELATIVE to where the +// handle is, and Shift+grab opens the precision rail anchored at the +// current position. export const GRAB_SLOP_PX = 12; // isHandleGrab reports whether a pointerdown at clientX lands on the @@ -97,24 +99,19 @@ export function isHandleGrab( return Math.abs(clientX - thumbX) <= slopPx; } -// GRAB_FINE_SCALE is the Shift+grab travel factor: the same relative -// drag crawls at quarter speed for precise placement. -export const GRAB_FINE_SCALE = 0.25; - // applyGrab advances a HANDLE drag by one pointermove: incremental -// (like applyDrag, a scale change never teleports the cursor), but -// with a FIXED scale instead of a pull-away zoom — the mouse is always -// on the bar, so Shift is the precision control, not vertical offset. +// (like applyDrag, so the cursor never teleports), 1:1 with pointer +// travel. (Shift+grab uses the precision rail instead of a scaled +// drag — see scrubPointerDown.) export function applyGrab( position: number, dxPx: number, pxPerSecond: number, duration: number, - scale = 1, ): number { const clamped = Math.min(duration, Math.max(0, position)); if (pxPerSecond <= 0 || duration <= 0) return clamped; - return Math.min(duration, Math.max(0, clamped + (dxPx / pxPerSecond) * scale)); + return Math.min(duration, Math.max(0, clamped + dxPx / pxPerSecond)); } // ---- §9 touch precision: vertical pull-away = progressive zoom ---- diff --git a/web/src/audio.ts b/web/src/audio.ts index 40320e50..6d11971e 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -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") {