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:
1 parent
b462c7fb44
commit
e5d4744977
4 files changed
+38
-37
No files matched your search
@@ -233,7 +233,10 @@ export const MOUSE_GUIDE: readonly PointerGuideRow[] = [
|
|||||||
label: "Grab handle",
|
label: "Grab handle",
|
||||||
description: "Drag the playhead from where it is — nothing moves until you do",
|
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 + click", description: "Jump to the nearest chapter line" },
|
||||||
{ label: "Shift + drag", description: "Precision rail (±5 min) around the nearest line" },
|
{ label: "Shift + drag", description: "Precision rail (±5 min) around the nearest line" },
|
||||||
{ label: "Wheel", description: "Nudge ±5s (Shift: ±60s)" },
|
{ label: "Wheel", description: "Nudge ±5s (Shift: ±60s)" },
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
dragScale,
|
dragScale,
|
||||||
DRAG_REF_PX,
|
DRAG_REF_PX,
|
||||||
DRAG_SCALE_MIN,
|
DRAG_SCALE_MIN,
|
||||||
GRAB_FINE_SCALE,
|
|
||||||
GRAB_SLOP_PX,
|
GRAB_SLOP_PX,
|
||||||
isHandleGrab,
|
isHandleGrab,
|
||||||
nearestChapterLine,
|
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;
|
const pxPerSecond = 800 / duration26h;
|
||||||
expect(applyGrab(5000, 100, pxPerSecond, duration26h, GRAB_FINE_SCALE)).toBeCloseTo(
|
expect(applyGrab(5000, 100, pxPerSecond, duration26h)).toBeCloseTo(
|
||||||
5000 + 100 * ((1 / pxPerSecond) * GRAB_FINE_SCALE),
|
5000 + (100 / 800) * duration26h,
|
||||||
5,
|
5,
|
||||||
);
|
);
|
||||||
// Scale changes only affect SUBSEQUENT travel — a zero-delta move
|
expect(applyGrab(50, 100, 0, duration26h)).toBe(50);
|
||||||
// with a new scale must leave the position alone.
|
|
||||||
expect(applyGrab(5000, 0, pxPerSecond, duration26h, GRAB_FINE_SCALE)).toBe(5000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clamps to the book and survives a dead bar", () => {
|
it("clamps to the book and survives a dead bar", () => {
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ export function snapToChapterPx(
|
|||||||
// thumb, in bar pixels, for it to count as grabbing the HANDLE rather
|
// thumb, in bar pixels, for it to count as grabbing the HANDLE rather
|
||||||
// than clicking the track. A grab never teleports the position — not
|
// than clicking the track. A grab never teleports the position — not
|
||||||
// to the click X (free-drag behavior) and not to a chapter line
|
// 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;
|
export const GRAB_SLOP_PX = 12;
|
||||||
|
|
||||||
// isHandleGrab reports whether a pointerdown at clientX lands on the
|
// isHandleGrab reports whether a pointerdown at clientX lands on the
|
||||||
@@ -97,24 +99,19 @@ export function isHandleGrab(
|
|||||||
return Math.abs(clientX - thumbX) <= slopPx;
|
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
|
// applyGrab advances a HANDLE drag by one pointermove: incremental
|
||||||
// (like applyDrag, a scale change never teleports the cursor), but
|
// (like applyDrag, so the cursor never teleports), 1:1 with pointer
|
||||||
// with a FIXED scale instead of a pull-away zoom — the mouse is always
|
// travel. (Shift+grab uses the precision rail instead of a scaled
|
||||||
// on the bar, so Shift is the precision control, not vertical offset.
|
// drag — see scrubPointerDown.)
|
||||||
export function applyGrab(
|
export function applyGrab(
|
||||||
position: number,
|
position: number,
|
||||||
dxPx: number,
|
dxPx: number,
|
||||||
pxPerSecond: number,
|
pxPerSecond: number,
|
||||||
duration: number,
|
duration: number,
|
||||||
scale = 1,
|
|
||||||
): number {
|
): number {
|
||||||
const clamped = Math.min(duration, Math.max(0, position));
|
const clamped = Math.min(duration, Math.max(0, position));
|
||||||
if (pxPerSecond <= 0 || duration <= 0) return clamped;
|
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 ----
|
// ---- §9 touch precision: vertical pull-away = progressive zoom ----
|
||||||
|
|||||||
+23
-19
@@ -44,7 +44,6 @@ import {
|
|||||||
bufferedSpanPct,
|
bufferedSpanPct,
|
||||||
chapterIndexFor,
|
chapterIndexFor,
|
||||||
denseTicks,
|
denseTicks,
|
||||||
GRAB_FINE_SCALE,
|
|
||||||
isHandleGrab,
|
isHandleGrab,
|
||||||
MAGNET_RADIUS_PX,
|
MAGNET_RADIUS_PX,
|
||||||
nearestChapterLine,
|
nearestChapterLine,
|
||||||
@@ -235,11 +234,11 @@ Alpine.data("audioReader", () => ({
|
|||||||
railTo: 0,
|
railTo: 0,
|
||||||
railTickList: [] as Array<{ start: number; title: string }>,
|
railTickList: [] as Array<{ start: number; title: string }>,
|
||||||
// §9 interaction model: "free" = 1:1 mouse drag (no rail), "rail" =
|
// §9 interaction model: "free" = 1:1 mouse drag (no rail), "rail" =
|
||||||
// Shift+drag precision window, "touch" = incremental cursor with
|
// Shift+drag precision window (also Shift+grab ON the thumb, anchored
|
||||||
// pull-away precision, "grab" = the pointerdown landed ON the
|
// at the playhead), "touch" = incremental cursor with pull-away
|
||||||
// playhead thumb — the position drags RELATIVE from where it is
|
// precision, "grab" = the pointerdown landed ON the playhead thumb —
|
||||||
// (never teleporting to the click X or a chapter line; Shift =
|
// the position drags RELATIVE from where it is (never teleporting to
|
||||||
// quarter-speed fine crawl). The rail is only visible for the
|
// the click X or a chapter line). The rail is only visible for the
|
||||||
// middle two.
|
// middle two.
|
||||||
dragMode: null as null | "free" | "rail" | "touch" | "grab",
|
dragMode: null as null | "free" | "rail" | "touch" | "grab",
|
||||||
railVisible: false,
|
railVisible: false,
|
||||||
@@ -945,12 +944,24 @@ Alpine.data("audioReader", () => ({
|
|||||||
} else if (isHandleGrab(e.clientX, rect, this.globalPosition(), duration)) {
|
} else if (isHandleGrab(e.clientX, rect, this.globalPosition(), duration)) {
|
||||||
// Grabbing the HANDLE itself: the playhead must not teleport —
|
// Grabbing the HANDLE itself: the playhead must not teleport —
|
||||||
// neither to the click X (free drag) nor to a chapter line
|
// neither to the click X (free drag) nor to a chapter line
|
||||||
// (Shift+click). The thumb checks this BEFORE the Shift branch:
|
// (Shift+click). The thumb check runs BEFORE the bare Shift
|
||||||
// the grab is relative from where the handle is, and Shift only
|
// branch: plain grab drags relative from where the handle is;
|
||||||
// slows the same drag to a fine crawl.
|
// Shift+grab opens the precision rail ANCHORED AT THE CURRENT
|
||||||
this.dragMode = "grab";
|
// POSITION (±5 min around the playhead, not around the nearest
|
||||||
this.grabLastX = e.clientX;
|
// chapter line).
|
||||||
this.scrubValue = Math.min(duration, Math.max(0, this.globalPosition()));
|
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) {
|
} else if (e.shiftKey) {
|
||||||
// Shift+click/drag: the precision rail, anchored to the NEAREST
|
// Shift+click/drag: the precision rail, anchored to the NEAREST
|
||||||
// chapter line (Shift+click = snap regardless of radius).
|
// chapter line (Shift+click = snap regardless of radius).
|
||||||
@@ -998,16 +1009,9 @@ Alpine.data("audioReader", () => ({
|
|||||||
if (this.dragMode === "grab") {
|
if (this.dragMode === "grab") {
|
||||||
// Relative handle drag: each move advances from the CURRENT
|
// Relative handle drag: each move advances from the CURRENT
|
||||||
// position by the pointer's delta (never re-maps the click X).
|
// position by the pointer's delta (never re-maps the click X).
|
||||||
// Shift = quarter-speed fine placement.
|
|
||||||
const dx = e.clientX - this.grabLastX;
|
const dx = e.clientX - this.grabLastX;
|
||||||
this.grabLastX = e.clientX;
|
this.grabLastX = e.clientX;
|
||||||
this.scrubValue = applyGrab(
|
this.scrubValue = applyGrab(this.scrubValue, dx, rect.width / duration, duration);
|
||||||
this.scrubValue,
|
|
||||||
dx,
|
|
||||||
rect.width / duration,
|
|
||||||
duration,
|
|
||||||
e.shiftKey ? GRAB_FINE_SCALE : 1,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.dragMode === "touch") {
|
if (this.dragMode === "touch") {
|
||||||
|
|||||||
Reference in new issue
Block a user