Skip to content

Commit 1eb18e4

Browse files
committed
Jog wheel touch event updates
1 parent 11d099c commit 1eb18e4

7 files changed

Lines changed: 215 additions & 9 deletions

File tree

src/app/src/features/Jogging/components/AJog.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
aMinusJog,
66
aPlusJog,
77
continuousJogAxis,
8+
isPrimaryPress,
89
type JoggerProps,
910
stopContinuousJog,
1011
} from "app/features/Jogging/utils/Jogging.ts";
@@ -33,6 +34,7 @@ export function AJog({
3334
},
3435
{
3536
threshold,
37+
filterEvents: isPrimaryPress,
3638
onCancel: () => {
3739
aPlusJog(distance, feedrate, false);
3840
posthog?.capture("jog_a_plus", {
@@ -57,6 +59,7 @@ export function AJog({
5759
},
5860
{
5961
threshold,
62+
filterEvents: isPrimaryPress,
6063
onCancel: () => {
6164
aMinusJog(distance, feedrate, false);
6265
posthog?.capture("jog_a_minus", {
@@ -94,6 +97,7 @@ export function AJog({
9497
bottomLabel={`Jog ${axis} minus`}
9598
onTopKeyDown={(e) => handleKeyDown(e, aPlusJog)}
9699
onBottomKeyDown={(e) => handleKeyDown(e, aMinusJog)}
100+
onPressCancel={stopContinuousJog}
97101
/>
98102
<img
99103
src={aLabels}

src/app/src/features/Jogging/components/JogWheel.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { WORKSPACE_MODE } from "app/constants";
33

44
import {
55
continuousJogAxis,
6+
isPrimaryPress,
67
stopContinuousJog,
78
xMinusJog,
89
xMinusYMinus,
@@ -46,6 +47,7 @@ export function JogWheel({
4647
},
4748
{
4849
threshold,
50+
filterEvents: isPrimaryPress,
4951
onCancel: () => {
5052
xPlusJog(distance, feedrate, false);
5153
posthog?.capture("jog_x_plus", {
@@ -70,6 +72,7 @@ export function JogWheel({
7072
},
7173
{
7274
threshold,
75+
filterEvents: isPrimaryPress,
7376
onCancel: () => {
7477
xMinusJog(distance, feedrate, false);
7578
},
@@ -88,6 +91,7 @@ export function JogWheel({
8891
},
8992
{
9093
threshold,
94+
filterEvents: isPrimaryPress,
9195
onCancel: () => {
9296
yPlusJog(distance, feedrate, false);
9397
posthog?.capture("jog_y_plus", {
@@ -110,6 +114,7 @@ export function JogWheel({
110114
},
111115
{
112116
threshold,
117+
filterEvents: isPrimaryPress,
113118
onCancel: () => {
114119
yMinusJog(distance, feedrate, false);
115120
posthog?.capture("jog_y_minus", {
@@ -133,6 +138,7 @@ export function JogWheel({
133138
},
134139
{
135140
threshold,
141+
filterEvents: isPrimaryPress,
136142
onCancel: () => {
137143
xPlusYMinus(distance, feedrate, false);
138144
posthog?.capture("jog_x_plus_y_minus", {
@@ -155,6 +161,7 @@ export function JogWheel({
155161
},
156162
{
157163
threshold,
164+
filterEvents: isPrimaryPress,
158165
onCancel: () => {
159166
xPlusYPlus(distance, feedrate, false);
160167
posthog?.capture("jog_x_plus_y_plus", {
@@ -178,6 +185,7 @@ export function JogWheel({
178185
},
179186
{
180187
threshold,
188+
filterEvents: isPrimaryPress,
181189
onCancel: () => {
182190
xMinusYPlus(distance, feedrate, false);
183191
posthog?.capture("jog_x_minus_y_plus", {
@@ -200,6 +208,7 @@ export function JogWheel({
200208
},
201209
{
202210
threshold,
211+
filterEvents: isPrimaryPress,
203212
onCancel: () => {
204213
xMinusYMinus(distance, feedrate, false);
205214
posthog?.capture("jog_x_minus_y_minus", {
@@ -229,18 +238,27 @@ export function JogWheel({
229238
}
230239
};
231240

241+
// A cancelled pointer never produces the release that stops the jog, and
242+
// use-long-press binds neither pointercancel nor touchcancel. Both bubble,
243+
// so one pair on the root covers every wedge below.
232244
return (
233245
<svg
234246
viewBox="0 0 200 200"
235247
fill="none"
236248
className={cn(
237-
"hover:transition-all duration-200 w-[180px] portrait:w-[210px] h-[180px] portrait:h-[210px]",
249+
// touch-none keeps the browser from reclaiming a hold as a scroll.
250+
// If it does, it sends pointercancel instead of pointerup and the
251+
// long press never finishes - the jog would run on.
252+
"touch-none hover:transition-all duration-200 w-[180px] portrait:w-[210px] h-[180px] portrait:h-[210px]",
238253
{
239254
"cursor-pointer": canClick,
240255
"cursor-not-allowed": !canClick,
241256
},
242257
)}
243258
xmlns="http://www.w3.org/2000/svg"
259+
onPointerCancel={stopContinuousJog}
260+
onTouchCancel={stopContinuousJog}
261+
onContextMenu={(event) => event.preventDefault()}
244262
>
245263
<path
246264
id="xPlusYMinus"

src/app/src/features/Jogging/components/TabJog.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ export interface TabJogProps {
99
bottomLabel?: string;
1010
onTopKeyDown?: (e: React.KeyboardEvent) => void;
1111
onBottomKeyDown?: (e: React.KeyboardEvent) => void;
12+
/**
13+
* Called when the browser cancels a hold instead of releasing it. Owners
14+
* pass their jog-stop here - use-long-press binds neither pointercancel nor
15+
* touchcancel, so without this a cancelled hold keeps the machine moving.
16+
*/
17+
onPressCancel?: () => void;
1218
}
1319

1420
const TabJog = (props: TabJogProps) => {
@@ -22,10 +28,18 @@ const TabJog = (props: TabJogProps) => {
2228
viewBox="0 0 50 187"
2329
fill="none"
2430
xmlns="http://www.w3.org/2000/svg"
25-
className={cn("w-[45px] portrait:w-[52px] h-[168px] portrait:h-[195px]", {
26-
"cursor-pointer": props.canClick,
27-
"cursor-not-allowed": !props.canClick,
28-
})}
31+
className={cn(
32+
// touch-none keeps the browser from reclaiming a hold as a scroll,
33+
// which would cancel the pointer rather than release it.
34+
"touch-none w-[45px] portrait:w-[52px] h-[168px] portrait:h-[195px]",
35+
{
36+
"cursor-pointer": props.canClick,
37+
"cursor-not-allowed": !props.canClick,
38+
},
39+
)}
40+
onPointerCancel={props.onPressCancel}
41+
onTouchCancel={props.onPressCancel}
42+
onContextMenu={(event) => event.preventDefault()}
2943
>
3044
<path
3145
role="button"

src/app/src/features/Jogging/components/ZJog.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import zLabels from "app/features/Jogging/assets/zLabels.svg";
33
import TabJog from "app/features/Jogging/components/TabJog.tsx";
44
import {
55
continuousJogAxis,
6+
isPrimaryPress,
67
type JoggerProps,
78
stopContinuousJog,
89
zMinusJog,
@@ -29,6 +30,7 @@ export function ZJog({
2930
},
3031
{
3132
threshold,
33+
filterEvents: isPrimaryPress,
3234
onCancel: () => {
3335
zPlusJog(distance, feedrate, false);
3436
posthog?.capture("jog_z_plus", {
@@ -51,6 +53,7 @@ export function ZJog({
5153
},
5254
{
5355
threshold,
56+
filterEvents: isPrimaryPress,
5457
onCancel: () => {
5558
zMinusJog(distance, feedrate, false);
5659
posthog?.capture("jog_z_minus", {
@@ -87,6 +90,7 @@ export function ZJog({
8790
bottomLabel="Jog Z minus"
8891
onTopKeyDown={(e) => handleKeyDown(e, zPlusJog)}
8992
onBottomKeyDown={(e) => handleKeyDown(e, zMinusJog)}
93+
onPressCancel={stopContinuousJog}
9094
/>
9195
<img
9296
src={zLabels}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright (C) 2021 Sienci Labs Inc.
3+
*
4+
* This file is part of gSender.
5+
*
6+
* gSender is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License as published by
8+
* the Free Software Foundation, under version 3 of the License.
9+
*
10+
* gSender is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with gSender. If not, see <https://www.gnu.org/licenses/>.
17+
*
18+
* Contact for information regarding this program and its license
19+
* can be sent through gSender@sienci.com or mailed to the main office
20+
* of Sienci Labs Inc. in Waterloo, Ontario, Canada.
21+
*
22+
*/
23+
24+
import { act, fireEvent, render, screen } from "@testing-library/react";
25+
import { JogWheel } from "../JogWheel";
26+
27+
// Mock-prefixed so babel-plugin-jest-hoist allows the jest.mock() factory below
28+
// (hoisted above this declaration) to close over it.
29+
const mockCommand = jest.fn();
30+
31+
jest.mock("app/lib/controller", () => ({
32+
__esModule: true,
33+
default: {
34+
command: (...args: unknown[]) => mockCommand(...args),
35+
},
36+
}));
37+
38+
jest.mock("app/store", () => ({
39+
__esModule: true,
40+
default: { get: (_key: string, fallback: unknown) => fallback },
41+
}));
42+
43+
jest.mock("@posthog/react", () => ({
44+
usePostHog: () => null,
45+
}));
46+
47+
jest.mock("app/hooks/useWorkspaceState", () => ({
48+
useWorkspaceState: () => ({ mode: "DEFAULT" }),
49+
}));
50+
51+
const THRESHOLD = 200;
52+
53+
const renderWheel = () =>
54+
render(
55+
<JogWheel canClick feedrate={1000} distance={1} threshold={THRESHOLD} />,
56+
);
57+
58+
const commandsNamed = (name: string) =>
59+
mockCommand.mock.calls.filter((call) => call[0] === name);
60+
61+
beforeEach(() => {
62+
jest.useFakeTimers();
63+
mockCommand.mockClear();
64+
});
65+
66+
afterEach(() => {
67+
jest.useRealTimers();
68+
});
69+
70+
describe("JogWheel continuous jog release", () => {
71+
it("stops the jog when the browser cancels the pointer", () => {
72+
renderWheel();
73+
const wedge = screen.getByLabelText("Jog X plus");
74+
75+
fireEvent.pointerDown(wedge, { button: 0 });
76+
act(() => {
77+
jest.advanceTimersByTime(THRESHOLD + 10);
78+
});
79+
expect(commandsNamed("jog:start")).toHaveLength(1);
80+
81+
// A cancelled pointer never produces the pointerup that use-long-press
82+
// listens for, so without our own handler the machine would keep going.
83+
fireEvent.pointerCancel(wedge);
84+
85+
expect(commandsNamed("jog:stop")).toHaveLength(1);
86+
});
87+
88+
it("still stops on an ordinary release", () => {
89+
renderWheel();
90+
const wedge = screen.getByLabelText("Jog X plus");
91+
92+
fireEvent.pointerDown(wedge, { button: 0 });
93+
act(() => {
94+
jest.advanceTimersByTime(THRESHOLD + 10);
95+
});
96+
fireEvent.pointerUp(wedge);
97+
98+
expect(commandsNamed("jog:start")).toHaveLength(1);
99+
expect(commandsNamed("jog:stop")).toHaveLength(1);
100+
});
101+
102+
it("treats a press shorter than the threshold as a single step jog", () => {
103+
renderWheel();
104+
const wedge = screen.getByLabelText("Jog X plus");
105+
106+
fireEvent.pointerDown(wedge, { button: 0 });
107+
act(() => {
108+
jest.advanceTimersByTime(THRESHOLD / 2);
109+
});
110+
fireEvent.pointerUp(wedge);
111+
112+
expect(commandsNamed("jog:start")).toHaveLength(0);
113+
expect(commandsNamed("gcode")).toHaveLength(1);
114+
});
115+
116+
it("ignores a secondary-button press so no jog is left running", () => {
117+
renderWheel();
118+
const wedge = screen.getByLabelText("Jog X plus");
119+
120+
// The context menu swallows the release, so a right-click must never
121+
// start a jog in the first place. Dispatched as a MouseEvent because
122+
// jsdom has no PointerEvent, and fireEvent's fallback plain Event drops
123+
// the `button` this case is entirely about.
124+
fireEvent(
125+
wedge,
126+
new MouseEvent("pointerdown", { bubbles: true, button: 2 }),
127+
);
128+
act(() => {
129+
jest.advanceTimersByTime(THRESHOLD + 10);
130+
});
131+
132+
expect(commandsNamed("jog:start")).toHaveLength(0);
133+
});
134+
});

src/app/src/features/Jogging/index.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,30 @@ export function Jogging({ hideRotary = false }) {
239239
[handleJoystickJog],
240240
);
241241

242+
// Last line of defence for a hold whose release never arrives - the jog
243+
// control unmounting under a finger, or the window going away mid-press.
244+
// use-long-press only clears its own timer in that case, so nothing else
245+
// would send the stop.
246+
//
247+
// Deliberately unconditional rather than gated on the last known machine
248+
// state: a stop that arrives with nothing jogging is a no-op server-side
249+
// and an ignored 0x85 at the firmware, while a stale "not jogging" reading
250+
// would skip the stop in exactly the case this exists for.
251+
useEffect(() => {
252+
const stopIfHidden = () => {
253+
if (document.hidden) {
254+
stopContinuousJog();
255+
}
256+
};
257+
document.addEventListener("visibilitychange", stopIfHidden);
258+
window.addEventListener("blur", stopContinuousJog);
259+
return () => {
260+
document.removeEventListener("visibilitychange", stopIfHidden);
261+
window.removeEventListener("blur", stopContinuousJog);
262+
stopContinuousJog();
263+
};
264+
}, []);
265+
242266
useEffect(() => {
243267
if (!initialized) {
244268
const jogValues = store.get("widgets.axes.jog.normal", {});
@@ -661,10 +685,6 @@ export function Jogging({ hideRotary = false }) {
661685
}
662686
}
663687

664-
const stopContinuousJog = () => {
665-
controller.command("jog:stop");
666-
};
667-
668688
const handleShortcutJog = ({
669689
axis,
670690
}: {

src/app/src/features/Jogging/utils/Jogging.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ export function stopContinuousJog() {
118118
controller.command("jog:stop");
119119
}
120120

121+
/**
122+
* Only a primary-button press may start a jog. A right-click would otherwise
123+
* start a continuous jog and then lose its release to the context menu,
124+
* leaving the machine moving. The pendant already guards this way.
125+
*/
126+
export function isPrimaryPress(event: { button?: number }) {
127+
if ("button" in event && typeof event.button === "number") {
128+
return event.button === 0;
129+
}
130+
return true;
131+
}
132+
121133
export interface JogDistances {
122134
X?: number;
123135
Y?: number;

0 commit comments

Comments
 (0)