Skip to content

Commit 4718a60

Browse files
veilletteclaude
andcommitted
Consolidate pointer and keyboard drag into RichDragListener
Replaces the paired DragListener + KeyboardDragListener pattern with a single RichDragListener, so pointer and keyboard drag share one positionProperty, transform, drag bounds and constraint and cannot drift apart. Disposal collapses to one listener as well. allowTouchSnag is left at its default (true); passing it explicitly was redundant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c314175 commit 4718a60

5 files changed

Lines changed: 145 additions & 100 deletions

File tree

src/track-lab/graph/AxisGestureHandler.ts

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616

1717
import type { ChartRectangle, ChartTransform } from "scenerystack/bamboo";
1818
import { Range, Vector2 } from "scenerystack/dot";
19-
import { DragListener, type Pointer, type Rectangle } from "scenerystack/scenery";
20-
import { GRAPH_ZOOM_FACTOR } from "../../TrackLabConstants.js";
19+
import { type Pointer, type Rectangle, RichDragListener } from "scenerystack/scenery";
20+
import { GRAPH_PAN_SHIFT_DRAG_SPEED, GRAPH_ZOOM_FACTOR, OVERLAY_DRAG_SPEED } from "../../TrackLabConstants.js";
2121
import TrackLabNamespace from "../../TrackLabNamespace.js";
2222
import type GraphDataManager from "./GraphDataManager.js";
2323
import type { ChartConfig, GraphDimensions } from "./GraphInteractionHandler.js";
@@ -219,27 +219,45 @@ export default class AxisGestureHandler {
219219
let mouseDragStart: number | null = null;
220220
let mouseDragInitialRange: Range | null = null;
221221

222-
const dragListener = new DragListener({
223-
start: (event) => {
224-
mouseDragStart = coord(event.pointer.point);
225-
mouseDragInitialRange = getRange().copy();
226-
},
222+
region.focusable = true;
223+
region.tagName = "div";
227224

228-
drag: (event) => {
229-
if (mouseDragStart === null || !mouseDragInitialRange) {
230-
return;
231-
}
232-
const axisSize = isX ? this.graphWidth : this.graphHeight;
233-
const delta = coord(event.pointer.point) - mouseDragStart;
234-
// X: negate (screen X and model X share direction; negation makes content follow drag).
235-
// Y: keep positive (screen Y is inverted from model Y; signs cancel, content follows drag).
236-
const modelDelta = (isX ? -1 : 1) * delta * (mouseDragInitialRange.getLength() / axisSize);
237-
setRange(new Range(mouseDragInitialRange.min + modelDelta, mouseDragInitialRange.max + modelDelta));
238-
},
225+
const dragListener = new RichDragListener({
226+
dragListenerOptions: {
227+
start: (event) => {
228+
mouseDragStart = coord(event.pointer.point);
229+
mouseDragInitialRange = getRange().copy();
230+
},
239231

240-
end: () => {
241-
mouseDragStart = null;
242-
mouseDragInitialRange = null;
232+
drag: (event) => {
233+
if (mouseDragStart === null || !mouseDragInitialRange) {
234+
return;
235+
}
236+
const axisSize = isX ? this.graphWidth : this.graphHeight;
237+
const delta = coord(event.pointer.point) - mouseDragStart;
238+
// X: negate (screen X and model X share direction; negation makes content follow drag).
239+
// Y: keep positive (screen Y is inverted from model Y; signs cancel, content follows drag).
240+
const modelDelta = (isX ? -1 : 1) * delta * (mouseDragInitialRange.getLength() / axisSize);
241+
setRange(new Range(mouseDragInitialRange.min + modelDelta, mouseDragInitialRange.max + modelDelta));
242+
},
243+
244+
end: () => {
245+
mouseDragStart = null;
246+
mouseDragInitialRange = null;
247+
},
248+
},
249+
keyboardDragListenerOptions: {
250+
keyboardDragDirection: isX ? "leftRight" : "upDown",
251+
dragSpeed: OVERLAY_DRAG_SPEED,
252+
shiftDragSpeed: GRAPH_PAN_SHIFT_DRAG_SPEED,
253+
drag: (_event, listener) => {
254+
const range = getRange();
255+
const axisSize = Math.max(1, isX ? this.graphWidth : this.graphHeight);
256+
const viewDelta = isX ? listener.modelDelta.x : listener.modelDelta.y;
257+
// Match pointer sign convention so keyboard pan feels the same as drag.
258+
const modelDelta = (isX ? -1 : 1) * viewDelta * (range.getLength() / axisSize);
259+
setRange(new Range(range.min + modelDelta, range.max + modelDelta));
260+
},
243261
},
244262
});
245263

src/track-lab/graph/PanGestureHandler.ts

Lines changed: 47 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import type { ChartRectangle, ChartTransform } from "scenerystack/bamboo";
88
import { Range, type Vector2 } from "scenerystack/dot";
9-
import { DragListener, KeyboardDragListener } from "scenerystack/scenery";
9+
import { RichDragListener } from "scenerystack/scenery";
1010
import { GRAPH_PAN_SHIFT_DRAG_SPEED, OVERLAY_DRAG_SPEED } from "../../TrackLabConstants.js";
1111
import TrackLabNamespace from "../../TrackLabNamespace.js";
1212
import type GraphDataManager from "./GraphDataManager.js";
@@ -72,59 +72,56 @@ export default class PanGestureHandler {
7272
let dragStartXRange: Range | null = null;
7373
let dragStartYRange: Range | null = null;
7474

75-
const dragListener = new DragListener({
76-
start: (event) => {
77-
const viewPoint = this.chartRectangle.globalToLocalPoint(event.pointer.point);
78-
dragStartModelPoint = this.chartTransform.viewToModelPosition(viewPoint);
79-
dragStartXRange = this.chartTransform.modelXRange.copy();
80-
dragStartYRange = this.chartTransform.modelYRange.copy();
81-
},
82-
83-
drag: (event) => {
84-
if (!(dragStartModelPoint && dragStartXRange && dragStartYRange)) {
85-
return;
86-
}
87-
88-
const viewPoint = this.chartRectangle.globalToLocalPoint(event.pointer.point);
89-
const currentModelPoint = this.chartTransform.viewToModelPosition(viewPoint);
90-
91-
const deltaX = dragStartModelPoint.x - currentModelPoint.x;
92-
const deltaY = dragStartModelPoint.y - currentModelPoint.y;
93-
94-
const newXRange = new Range(dragStartXRange.min + deltaX, dragStartXRange.max + deltaX);
95-
const newYRange = new Range(dragStartYRange.min + deltaY, dragStartYRange.max + deltaY);
96-
97-
this.dataManager.setRange(newXRange, newYRange);
98-
},
99-
100-
end: () => {
101-
dragStartModelPoint = null;
102-
dragStartXRange = null;
103-
dragStartYRange = null;
104-
},
105-
});
106-
107-
this.chartRectangle.addInputListener(dragListener);
108-
10975
// Keyboard pan (complements toolbar/programmatic pan()).
11076
this.chartRectangle.focusable = true;
11177
this.chartRectangle.tagName = "div";
11278
this.chartRectangle.addInputListener(
113-
new KeyboardDragListener({
114-
dragSpeed: OVERLAY_DRAG_SPEED,
115-
shiftDragSpeed: GRAPH_PAN_SHIFT_DRAG_SPEED,
116-
drag: (_event, listener) => {
117-
const xRange = this.chartTransform.modelXRange;
118-
const yRange = this.chartTransform.modelYRange;
119-
const bounds = this.chartRectangle.localBounds;
120-
const viewW = Math.max(1, bounds.width);
121-
const viewH = Math.max(1, bounds.height);
122-
const deltaX = (-listener.modelDelta.x * xRange.getLength()) / viewW;
123-
const deltaY = (listener.modelDelta.y * yRange.getLength()) / viewH;
124-
this.dataManager.setRange(
125-
new Range(xRange.min + deltaX, xRange.max + deltaX),
126-
new Range(yRange.min + deltaY, yRange.max + deltaY),
127-
);
79+
new RichDragListener({
80+
dragListenerOptions: {
81+
start: (event) => {
82+
const viewPoint = this.chartRectangle.globalToLocalPoint(event.pointer.point);
83+
dragStartModelPoint = this.chartTransform.viewToModelPosition(viewPoint);
84+
dragStartXRange = this.chartTransform.modelXRange.copy();
85+
dragStartYRange = this.chartTransform.modelYRange.copy();
86+
},
87+
drag: (event) => {
88+
if (!(dragStartModelPoint && dragStartXRange && dragStartYRange)) {
89+
return;
90+
}
91+
92+
const viewPoint = this.chartRectangle.globalToLocalPoint(event.pointer.point);
93+
const currentModelPoint = this.chartTransform.viewToModelPosition(viewPoint);
94+
95+
const deltaX = dragStartModelPoint.x - currentModelPoint.x;
96+
const deltaY = dragStartModelPoint.y - currentModelPoint.y;
97+
98+
const newXRange = new Range(dragStartXRange.min + deltaX, dragStartXRange.max + deltaX);
99+
const newYRange = new Range(dragStartYRange.min + deltaY, dragStartYRange.max + deltaY);
100+
101+
this.dataManager.setRange(newXRange, newYRange);
102+
},
103+
end: () => {
104+
dragStartModelPoint = null;
105+
dragStartXRange = null;
106+
dragStartYRange = null;
107+
},
108+
},
109+
keyboardDragListenerOptions: {
110+
dragSpeed: OVERLAY_DRAG_SPEED,
111+
shiftDragSpeed: GRAPH_PAN_SHIFT_DRAG_SPEED,
112+
drag: (_event, listener) => {
113+
const xRange = this.chartTransform.modelXRange;
114+
const yRange = this.chartTransform.modelYRange;
115+
const bounds = this.chartRectangle.localBounds;
116+
const viewW = Math.max(1, bounds.width);
117+
const viewH = Math.max(1, bounds.height);
118+
const deltaX = (-listener.modelDelta.x * xRange.getLength()) / viewW;
119+
const deltaY = (listener.modelDelta.y * yRange.getLength()) / viewH;
120+
this.dataManager.setRange(
121+
new Range(xRange.min + deltaX, xRange.max + deltaX),
122+
new Range(yRange.min + deltaY, yRange.max + deltaY),
123+
);
124+
},
128125
},
129126
}),
130127
);

src/track-lab/view/AutoTrackerNode.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ export class AutoTrackerNode extends Node {
169169
this.addChild(this.crosshairV);
170170

171171
// ── Drag listener: region selection ──────────────────────────────────
172+
// Rubber-band ROI selection is pointer-only; there is no sensible keyboard
173+
// mapping for freeform region drawing (auto-tracker init needs a box).
172174
const dragListener = new DragListener({
173175
start: (event) => {
174176
this.trailHead = 0;

src/track-lab/view/TrackLabScreenView.ts

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import { DerivedProperty } from "scenerystack/axon";
99
import { Vector2 } from "scenerystack/dot";
1010
import { type EmptySelfOptions, optionize } from "scenerystack/phet-core";
11-
import { DragListener, Node } from "scenerystack/scenery";
11+
import { Node, RichDragListener } from "scenerystack/scenery";
1212
import { InfoButton, ResetAllButton } from "scenerystack/scenery-phet";
1313
import { ScreenView, type ScreenViewOptions } from "scenerystack/sim";
1414
import { Tandem } from "scenerystack/tandem";
@@ -128,26 +128,39 @@ export class TrackLabScreenView extends ScreenView {
128128
});
129129

130130
// ── Panel move drag (on the header bar) ───────────────────────────────
131-
// The DragListener lives in TrackLabScreenView so it naturally operates in
132-
// TrackLabScreenView's coordinate space.
131+
// The RichDragListener lives in TrackLabScreenView so it naturally operates in
132+
// TrackLabScreenView's coordinate space. Keyboard nudges panelPositionProperty.
133133
let panelDragStartPos: Vector2 | null = null;
134134
let panelDragStartPointerPoint: Vector2 | null = null;
135+
this.videoPlayerNode.panelHeaderBar.focusable = true;
136+
this.videoPlayerNode.panelHeaderBar.tagName = "div";
135137
this.videoPlayerNode.panelHeaderBar.addInputListener(
136-
new DragListener({
137-
start: (event) => {
138-
panelDragStartPos = model.playback.panelPositionProperty.value.copy();
139-
panelDragStartPointerPoint = event.pointer.point.copy();
138+
new RichDragListener({
139+
dragListenerOptions: {
140+
start: (event) => {
141+
panelDragStartPos = model.playback.panelPositionProperty.value.copy();
142+
panelDragStartPointerPoint = event.pointer.point.copy();
143+
},
144+
drag: (event) => {
145+
if (!(panelDragStartPos && panelDragStartPointerPoint)) {
146+
return;
147+
}
148+
const delta = event.pointer.point.minus(panelDragStartPointerPoint);
149+
model.playback.panelPositionProperty.value = panelDragStartPos.plus(delta);
150+
},
151+
end: () => {
152+
panelDragStartPos = null;
153+
panelDragStartPointerPoint = null;
154+
},
140155
},
141-
drag: (event) => {
142-
if (!(panelDragStartPos && panelDragStartPointerPoint)) {
143-
return;
144-
}
145-
const delta = event.pointer.point.minus(panelDragStartPointerPoint);
146-
model.playback.panelPositionProperty.value = panelDragStartPos.plus(delta);
147-
},
148-
end: () => {
149-
panelDragStartPos = null;
150-
panelDragStartPointerPoint = null;
156+
keyboardDragListenerOptions: {
157+
dragSpeed: 120,
158+
shiftDragSpeed: 40,
159+
drag: (_event, listener) => {
160+
model.playback.panelPositionProperty.value = model.playback.panelPositionProperty.value.plus(
161+
listener.modelDelta,
162+
);
163+
},
151164
},
152165
}),
153166
);

src/track-lab/view/VideoPlayerNode.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import { DerivedProperty } from "scenerystack/axon";
99
import { Bounds2, Dimension2, Vector2 } from "scenerystack/dot";
10-
import { Circle, DOM, DragListener, Node, Rectangle, Text } from "scenerystack/scenery";
10+
import { Circle, DOM, Node, Rectangle, RichDragListener, Text } from "scenerystack/scenery";
1111
import { PhetFont } from "scenerystack/scenery-phet";
1212
import TrackLabColors from "../../TrackLabColors.js";
1313
import { VIDEO_HEIGHT, VIDEO_WIDTH } from "../../TrackLabConstants.js";
@@ -296,6 +296,7 @@ export class VideoPlayerNode extends Node {
296296
const resizeHandle = new Circle(RESIZE_HANDLE_RADIUS, {
297297
cursor: "nwse-resize",
298298
tagName: "div",
299+
focusable: true,
299300
accessibleName: a11yStrings.videoPanelResizeHandleStringProperty,
300301
});
301302
const resizeHandleColorListener = (c: import("scenerystack").Color) => {
@@ -329,18 +330,32 @@ export class VideoPlayerNode extends Node {
329330
let resizeStartHandleX = 0;
330331
let resizeStartPointerX = 0;
331332
resizeHandle.addInputListener(
332-
new DragListener({
333-
start: (event) => {
334-
resizeStartScale = model.playback.panelSizeScaleProperty.value;
335-
resizeStartHandleX = this.videoContentWrapper.right;
336-
resizeStartPointerX = event.pointer.point.x;
333+
new RichDragListener({
334+
dragListenerOptions: {
335+
start: (event) => {
336+
resizeStartScale = model.playback.panelSizeScaleProperty.value;
337+
resizeStartHandleX = this.videoContentWrapper.right;
338+
resizeStartPointerX = event.pointer.point.x;
339+
},
340+
drag: (event) => {
341+
const deltaX = event.pointer.point.x - resizeStartPointerX;
342+
// Proportional scaling: new scale = startScale × (newRightEdge / startRightEdge)
343+
const newScale = (resizeStartScale * (resizeStartHandleX + deltaX)) / resizeStartHandleX;
344+
const range = model.playback.panelSizeScaleProperty.range;
345+
model.playback.panelSizeScaleProperty.value = Math.max(range.min, Math.min(range.max, newScale));
346+
},
337347
},
338-
drag: (event) => {
339-
const deltaX = event.pointer.point.x - resizeStartPointerX;
340-
// Proportional scaling: new scale = startScale × (newRightEdge / startRightEdge)
341-
const newScale = (resizeStartScale * (resizeStartHandleX + deltaX)) / resizeStartHandleX;
342-
const range = model.playback.panelSizeScaleProperty.range;
343-
model.playback.panelSizeScaleProperty.value = Math.max(range.min, Math.min(range.max, newScale));
348+
keyboardDragListenerOptions: {
349+
keyboardDragDirection: "leftRight",
350+
dragSpeed: 80,
351+
shiftDragSpeed: 30,
352+
drag: (_event, listener) => {
353+
const current = model.playback.panelSizeScaleProperty.value;
354+
const handleX = Math.max(1, this.videoContentWrapper.right);
355+
const newScale = current * (1 + listener.modelDelta.x / handleX);
356+
const range = model.playback.panelSizeScaleProperty.range;
357+
model.playback.panelSizeScaleProperty.value = Math.max(range.min, Math.min(range.max, newScale));
358+
},
344359
},
345360
}),
346361
);

0 commit comments

Comments
 (0)