1// @effect-diagnostics globalTimers:off - Canvas-local wheel gesture expiry.
2import {
3 phoneWheelNavigation,
4 type createPhoneInteraction,
5} from "@t3tools/client-runtime/device/phone-interaction";
6
7/** Canvas-local, non-passive listeners consume browser zoom. Safari reports cumulative pinch scale instead of Ctrl-wheel. */
8export function bindPhoneTrackpad(
9 canvas: Pick<
10 HTMLCanvasElement,
11 "addEventListener" | "removeEventListener" | "getBoundingClientRect"
12 >,
13 interaction: Pick<ReturnType<typeof createPhoneInteraction>, "navigate" | "endWheel">,
14 pinch?: {
15 begin: (x: number, y: number) => boolean;
16 move: (logScale: number) => void;
17 end: () => void;
18 },
19) {
20 let scale: number | null = null;
21 let wheelActive = false;
22 let timer: ReturnType<typeof setTimeout> | null = null;
23 let orbitActive = false;
24 let orbitTimer: ReturnType<typeof setTimeout> | null = null;
25 const endOrbit = () => {
26 if (orbitTimer) clearTimeout(orbitTimer);
27 orbitTimer = null;
28 if (!orbitActive) return;
29 orbitActive = false;
30 interaction.endWheel();
31 };
32 const finish = () => {
33 if (timer) clearTimeout(timer);
34 timer = null;
35 wheelActive = false;
36 scale = null;
37 pinch?.end();
38 };
39 const begin = (event: Event) => {
40 const rect = canvas.getBoundingClientRect();
41 const x = "clientX" in event && typeof event.clientX === "number" ? event.clientX : NaN;
42 const y = "clientY" in event && typeof event.clientY === "number" ? event.clientY : NaN;
43 pinch?.begin((x - rect.left) / rect.width, (y - rect.top) / rect.height);
44 };
45 const consume = (event: Event) => {
46 event.preventDefault();
47 event.stopPropagation();
48 };
49 const wheel = (event: WheelEvent) => {
50 consume(event);
51 if (scale !== null) return;
52 if (event.ctrlKey) {
53 endOrbit();
54 if (!wheelActive) {
55 begin(event);
56 wheelActive = true;
57 }
58 const unit =
59 event.deltaMode === 1
60 ? 16
61 : event.deltaMode === 2
62 ? canvas.getBoundingClientRect().height
63 : 1;
64 pinch?.move((-event.deltaY * unit) / 100);
65 if (timer) clearTimeout(timer);
66 timer = setTimeout(finish, 180);
67 return;
68 }
69 if (wheelActive) finish();
70 if ("momentum" in event && event.momentum === true) {
71 endOrbit();
72 return;
73 }
74 const rect = canvas.getBoundingClientRect();
75 const navigation = phoneWheelNavigation({
76 width: rect.width,
77 height: rect.height,
78 deltaX: event.deltaX,
79 deltaY: event.deltaY,
80 deltaMode: event.deltaMode,
81 ctrlKey: event.ctrlKey,
82 });
83 if (navigation && interaction.navigate(navigation)) {
84 orbitActive = true;
85 if (orbitTimer) clearTimeout(orbitTimer);
86 // Browsers without a release signal still return to a useful view.
87 orbitTimer = setTimeout(endOrbit, 1200);
88 }
89 };
90 const gestureScale = (event: Event) => {
91 if (
92 !("scale" in event) ||
93 typeof event.scale !== "number" ||
94 !Number.isFinite(event.scale) ||
95 event.scale <= 0
96 )
97 return null;
98 return event.scale;
99 };
100 const start = (event: Event) => {
101 consume(event);
102 endOrbit();
103 finish();
104 begin(event);
105 scale = gestureScale(event) ?? 1;
106 };
107 const change = (event: Event) => {
108 consume(event);
109 const next = gestureScale(event);
110 if (scale === null || next === null) return;
111 pinch?.move(Math.log(next / scale));
112 scale = next;
113 };
114 const end = (event: Event) => {
115 consume(event);
116 finish();
117 };
118 canvas.addEventListener("wheel", wheel, { passive: false });
119 canvas.addEventListener("gesturestart", start, { passive: false });
120 canvas.addEventListener("gesturechange", change, { passive: false });
121 canvas.addEventListener("gestureend", end, { passive: false });
122 return {
123 endOrbit,
124 cancel() {
125 endOrbit();
126 finish();
127 },
128 dispose() {
129 canvas.removeEventListener("wheel", wheel);
130 canvas.removeEventListener("gesturestart", start);
131 canvas.removeEventListener("gesturechange", change);
132 canvas.removeEventListener("gestureend", end);
133 endOrbit();
134 finish();
135 },
136 };
137}