Skip to content
Source
Sign inSign up

phoneTrackpad.ts

136 lines4.0 KB

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/** 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 orbitTimer = setTimeout(endOrbit, 1200);
87 }
88 };
89 const gestureScale = (event: Event) => {
90 if (
91 !("scale" in event) ||
92 typeof event.scale !== "number" ||
93 !Number.isFinite(event.scale) ||
94 event.scale <= 0
95 )
96 return null;
97 return event.scale;
98 };
99 const start = (event: Event) => {
100 consume(event);
101 endOrbit();
102 finish();
103 begin(event);
104 scale = gestureScale(event) ?? 1;
105 };
106 const change = (event: Event) => {
107 consume(event);
108 const next = gestureScale(event);
109 if (scale === null || next === null) return;
110 pinch?.move(Math.log(next / scale));
111 scale = next;
112 };
113 const end = (event: Event) => {
114 consume(event);
115 finish();
116 };
117 canvas.addEventListener("wheel", wheel, { passive: false });
118 canvas.addEventListener("gesturestart", start, { passive: false });
119 canvas.addEventListener("gesturechange", change, { passive: false });
120 canvas.addEventListener("gestureend", end, { passive: false });
121 return {
122 endOrbit,
123 cancel() {
124 endOrbit();
125 finish();
126 },
127 dispose() {
128 canvas.removeEventListener("wheel", wheel);
129 canvas.removeEventListener("gesturestart", start);
130 canvas.removeEventListener("gesturechange", change);
131 canvas.removeEventListener("gestureend", end);
132 endOrbit();
133 finish();
134 },
135 };