Gesture.jsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. import React from "react";
  2. import platform from "platform";
  3. export const isMacOS = () => {
  4. return platform.os.family === "OS X";
  5. };
  6. // From https://stackoverflow.com/questions/20110224/what-is-the-height-of-a-line-in-a-wheel-event-deltamode-dom-delta-line
  7. const getScrollLineHeight = () => {
  8. const iframe = document.createElement("iframe");
  9. iframe.src = "#";
  10. document.body.appendChild(iframe);
  11. // Write content in Iframe
  12. const idoc = iframe.contentWindow.document;
  13. idoc.open();
  14. idoc.write(
  15. "<!DOCTYPE html><html><head></head><body><span>a</span></body></html>"
  16. );
  17. idoc.close();
  18. const scrollLineHeight = idoc.body.firstElementChild.offsetHeight;
  19. document.body.removeChild(iframe);
  20. return scrollLineHeight;
  21. };
  22. const LINE_HEIGHT = getScrollLineHeight();
  23. // Reasonable default from https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
  24. const PAGE_HEIGHT = 800;
  25. const otherPointer = (pointers, currentPointer) => {
  26. const p2 = Object.keys(pointers)
  27. .map((p) => Number(p))
  28. .find((pointer) => pointer !== currentPointer);
  29. return pointers[p2];
  30. };
  31. const computeDistance = ([x1, y1], [x2, y2]) => {
  32. const distanceX = Math.abs(x1 - x2);
  33. const distanceY = Math.abs(y1 - y2);
  34. return Math.hypot(distanceX, distanceY);
  35. };
  36. const empty = () => {};
  37. const Gesture = ({
  38. children,
  39. onDrag = empty,
  40. onDragStart = empty,
  41. onDragEnd = empty,
  42. onPan = empty,
  43. onTap = empty,
  44. onLongTap = empty,
  45. onDoubleTap = empty,
  46. onZoom,
  47. }) => {
  48. const wrapperRef = React.useRef(null);
  49. const stateRef = React.useRef({
  50. moving: false,
  51. pointers: {},
  52. mainPointer: undefined,
  53. });
  54. const queueRef = React.useRef([]);
  55. // Queue event to avoid async mess
  56. const queue = React.useCallback((callback) => {
  57. queueRef.current.push(async () => {
  58. await callback();
  59. queueRef.current.shift();
  60. if (queueRef.current.length !== 0) {
  61. await queueRef.current[0]();
  62. }
  63. });
  64. if (queueRef.current.length === 1) {
  65. queueRef.current[0]();
  66. }
  67. }, []);
  68. const onWheel = React.useCallback(
  69. (e) => {
  70. const {
  71. deltaX,
  72. deltaY,
  73. clientX,
  74. clientY,
  75. deltaMode,
  76. ctrlKey,
  77. altKey,
  78. metaKey,
  79. target,
  80. } = e;
  81. // On a MacOs trackpad, the pinch gesture sets the ctrlKey to true.
  82. // In that situation, we want to use the custom scaling, not the browser default zoom.
  83. // Hence in this situation we avoid to return immediately.
  84. if (altKey || (ctrlKey && !isMacOS())) {
  85. return;
  86. }
  87. // On a trackpad, the pinch and pan events are differentiated by the crtlKey value.
  88. // On a pinch gesture, the ctrlKey is set to true, so we want to have a scaling effect.
  89. // If we are only moving the fingers in the same direction, a pan is needed.
  90. // Ref: https://medium.com/@auchenberg/detecting-multi-touch-trackpad-gestures-in-javascript-a2505babb10e
  91. if (isMacOS() && !ctrlKey) {
  92. queue(() =>
  93. onPan({
  94. deltaX: -2 * deltaX,
  95. deltaY: -2 * deltaY,
  96. button: 1,
  97. ctrlKey,
  98. metaKey,
  99. target,
  100. event: e,
  101. })
  102. );
  103. } else {
  104. // Quit if onZoom is not set
  105. if (onZoom === undefined || !deltaY) return;
  106. let scale = deltaY;
  107. switch (deltaMode) {
  108. case 1: // Pixel
  109. scale *= LINE_HEIGHT;
  110. break;
  111. case 2:
  112. scale *= PAGE_HEIGHT;
  113. break;
  114. default:
  115. }
  116. if (isMacOS()) {
  117. scale *= 2;
  118. }
  119. queue(() => onZoom({ scale, clientX, clientY, event: e }));
  120. }
  121. },
  122. [onPan, onZoom, queue]
  123. );
  124. const onPointerDown = React.useCallback(
  125. ({
  126. target,
  127. button,
  128. clientX,
  129. clientY,
  130. pointerId,
  131. altKey,
  132. ctrlKey,
  133. metaKey,
  134. isPrimary,
  135. }) => {
  136. // Add pointer to map
  137. stateRef.current.pointers[pointerId] = { clientX, clientY };
  138. if (isPrimary) {
  139. // Clean mainPoint on primary pointer
  140. stateRef.current.mainPointer = undefined;
  141. }
  142. if (stateRef.current.mainPointer !== undefined) {
  143. if (stateRef.current.mainPointer !== pointerId) {
  144. // This is not the main pointer
  145. try {
  146. const { clientX: clientX2, clientY: clientY2 } = otherPointer(
  147. stateRef.current.pointers,
  148. pointerId
  149. );
  150. const newClientX = (clientX2 + clientX) / 2;
  151. const newClientY = (clientY2 + clientY) / 2;
  152. const distance = computeDistance(
  153. [clientX2, clientY2],
  154. [clientX, clientY]
  155. );
  156. // We update previous position as the new position is the center beetween both finger
  157. Object.assign(stateRef.current, {
  158. pressed: true,
  159. moving: false,
  160. gestureStart: false,
  161. startX: clientX,
  162. startY: clientY,
  163. prevX: newClientX,
  164. prevY: newClientY,
  165. startDistance: distance,
  166. prevDistance: distance,
  167. });
  168. } catch (e) {
  169. console.log("Error while getting other pointer. Ignoring", e);
  170. stateRef.current.mainPointer === undefined;
  171. }
  172. }
  173. return;
  174. }
  175. // We set the mainpointer
  176. stateRef.current.mainPointer = pointerId;
  177. // And prepare move
  178. Object.assign(stateRef.current, {
  179. pressed: true,
  180. moving: false,
  181. gestureStart: false,
  182. startX: clientX,
  183. startY: clientY,
  184. prevX: clientX,
  185. prevY: clientY,
  186. currentButton: button,
  187. target,
  188. timeStart: Date.now(),
  189. longTapTimeout: setTimeout(async () => {
  190. stateRef.current.noTap = true;
  191. queue(() =>
  192. onLongTap({
  193. clientX,
  194. clientY,
  195. altKey,
  196. ctrlKey,
  197. metaKey,
  198. target,
  199. })
  200. );
  201. }, 750),
  202. });
  203. try {
  204. target.setPointerCapture(pointerId);
  205. } catch (e) {
  206. console.log("Fail to capture pointer", e);
  207. }
  208. },
  209. [onLongTap, queue]
  210. );
  211. const onPointerMove = React.useCallback(
  212. (e) => {
  213. if (stateRef.current.pressed) {
  214. const {
  215. pointerId,
  216. clientX: eventClientX,
  217. clientY: eventClientY,
  218. altKey,
  219. ctrlKey,
  220. metaKey,
  221. pointerType,
  222. } = e;
  223. if (stateRef.current.mainPointer !== pointerId) {
  224. // Event from other pointer
  225. stateRef.current.pointers[pointerId] = {
  226. clientX: eventClientX,
  227. clientY: eventClientY,
  228. };
  229. return;
  230. }
  231. stateRef.current.moving = true;
  232. // Do we have two fingers ?
  233. const twoFingers = Object.keys(stateRef.current.pointers).length === 2;
  234. let clientX, clientY, distance;
  235. if (twoFingers) {
  236. // Find other pointerId
  237. const { clientX: clientX2, clientY: clientY2 } = otherPointer(
  238. stateRef.current.pointers,
  239. pointerId
  240. );
  241. // Update client X with the center of each touch
  242. clientX = (clientX2 + eventClientX) / 2;
  243. clientY = (clientY2 + eventClientY) / 2;
  244. distance = computeDistance(
  245. [clientX2, clientY2],
  246. [eventClientX, eventClientY]
  247. );
  248. } else {
  249. clientX = eventClientX;
  250. clientY = eventClientY;
  251. }
  252. // We drag if
  253. // On non touch device
  254. // - Button is 0
  255. // - Alt key is no pressed
  256. // or on touch devices
  257. // - We use only one finger
  258. const shouldDrag =
  259. pointerType !== "touch"
  260. ? stateRef.current.currentButton === 0 && !altKey
  261. : !twoFingers;
  262. if (shouldDrag) {
  263. // Send drag start on first move
  264. if (!stateRef.current.gestureStart) {
  265. wrapperRef.current.style.cursor = "move";
  266. stateRef.current.gestureStart = true;
  267. // Clear tap timeout
  268. clearTimeout(stateRef.current.longTapTimeout);
  269. queue(() =>
  270. onDragStart({
  271. deltaX: 0,
  272. deltaY: 0,
  273. startX: stateRef.current.startX,
  274. startY: stateRef.current.startY,
  275. distanceX: 0,
  276. distanceY: 0,
  277. button: stateRef.current.currentButton,
  278. altKey,
  279. ctrlKey,
  280. metaKey,
  281. target: stateRef.current.target,
  282. event: e,
  283. })
  284. );
  285. }
  286. // Create closure
  287. const deltaX = clientX - stateRef.current.prevX;
  288. const deltaY = clientY - stateRef.current.prevY;
  289. const distanceX = clientX - stateRef.current.startX;
  290. const distanceY = clientY - stateRef.current.startY;
  291. // Drag event
  292. queue(() =>
  293. onDrag({
  294. deltaX,
  295. deltaY,
  296. startX: stateRef.current.startX,
  297. startY: stateRef.current.startY,
  298. distanceX,
  299. distanceY,
  300. button: stateRef.current.currentButton,
  301. altKey,
  302. ctrlKey,
  303. metaKey,
  304. target: stateRef.current.target,
  305. event: e,
  306. })
  307. );
  308. } else {
  309. if (!stateRef.current.gestureStart) {
  310. wrapperRef.current.style.cursor = "move";
  311. stateRef.current.gestureStart = true;
  312. // Clear tap timeout on first move
  313. clearTimeout(stateRef.current.longTapTimeout);
  314. }
  315. // Create closure
  316. const deltaX = clientX - stateRef.current.prevX;
  317. const deltaY = clientY - stateRef.current.prevY;
  318. const target = stateRef.current.target;
  319. // Pan event
  320. queue(() =>
  321. onPan({
  322. deltaX,
  323. deltaY,
  324. button: stateRef.current.currentButton,
  325. altKey,
  326. ctrlKey,
  327. metaKey,
  328. target,
  329. event: e,
  330. })
  331. );
  332. if (
  333. twoFingers &&
  334. distance !== stateRef.current.prevDistance &&
  335. onZoom
  336. ) {
  337. const scale = stateRef.current.prevDistance - distance;
  338. if (Math.abs(scale) > 0) {
  339. queue(() =>
  340. onZoom({
  341. scale,
  342. clientX,
  343. clientY,
  344. event: e,
  345. })
  346. );
  347. stateRef.current.prevDistance = distance;
  348. }
  349. }
  350. }
  351. stateRef.current.prevX = clientX;
  352. stateRef.current.prevY = clientY;
  353. }
  354. },
  355. [onDrag, onDragStart, onPan, onZoom, queue]
  356. );
  357. const onPointerUp = React.useCallback(
  358. (e) => {
  359. const {
  360. clientX,
  361. clientY,
  362. altKey,
  363. ctrlKey,
  364. metaKey,
  365. target,
  366. pointerId,
  367. } = e;
  368. if (!stateRef.current.pointers[pointerId]) {
  369. // Pointer already gone previously with another event
  370. // ignoring it
  371. return;
  372. }
  373. // Remove pointer from map
  374. delete stateRef.current.pointers[pointerId];
  375. if (stateRef.current.mainPointer !== pointerId) {
  376. // If this is not the main pointer we quit here
  377. return;
  378. }
  379. while (Object.keys(stateRef.current.pointers).length > 0) {
  380. // If was main pointer but we have another one, this one become main
  381. stateRef.current.mainPointer = Number(
  382. Object.keys(stateRef.current.pointers)[0]
  383. );
  384. try {
  385. stateRef.current.target.setPointerCapture(
  386. stateRef.current.mainPointer
  387. );
  388. return;
  389. } catch (e) {
  390. console.log("Fails to set pointer capture", e);
  391. stateRef.current.mainPointer = undefined;
  392. delete stateRef.current.pointers[
  393. Object.keys(stateRef.current.pointers)[0]
  394. ];
  395. }
  396. }
  397. stateRef.current.mainPointer = undefined;
  398. stateRef.current.pressed = false;
  399. // Clear longTap
  400. clearTimeout(stateRef.current.longTapTimeout);
  401. if (stateRef.current.moving) {
  402. // If we were moving, send drag end event
  403. stateRef.current.moving = false;
  404. queue(() =>
  405. onDragEnd({
  406. deltaX: clientX - stateRef.current.prevX,
  407. deltaY: clientY - stateRef.current.prevY,
  408. startX: stateRef.current.startX,
  409. startY: stateRef.current.startY,
  410. distanceX: clientX - stateRef.current.startX,
  411. distanceY: clientY - stateRef.current.startY,
  412. button: stateRef.current.currentButton,
  413. altKey,
  414. ctrlKey,
  415. metaKey,
  416. event: e,
  417. })
  418. );
  419. wrapperRef.current.style.cursor = "auto";
  420. } else {
  421. const now = Date.now();
  422. if (stateRef.current.noTap) {
  423. stateRef.current.noTap = false;
  424. } else {
  425. // Send tap event only if time less than 300ms
  426. if (stateRef.current.timeStart - now < 300) {
  427. queue(() =>
  428. onTap({
  429. clientX,
  430. clientY,
  431. altKey,
  432. ctrlKey,
  433. metaKey,
  434. target,
  435. })
  436. );
  437. }
  438. }
  439. }
  440. },
  441. [onDragEnd, onTap, queue]
  442. );
  443. const onDoubleTapHandler = React.useCallback(
  444. (event) => {
  445. onDoubleTap(event);
  446. },
  447. [onDoubleTap]
  448. );
  449. return (
  450. <div
  451. onWheel={onWheel}
  452. onPointerDown={onPointerDown}
  453. onPointerMove={onPointerMove}
  454. onPointerUp={onPointerUp}
  455. onPointerOut={onPointerUp}
  456. onPointerLeave={onPointerUp}
  457. onPointerCancel={onPointerUp}
  458. onDoubleClick={onDoubleTapHandler}
  459. style={{ touchAction: "none" }}
  460. ref={wrapperRef}
  461. >
  462. {children}
  463. </div>
  464. );
  465. };
  466. export default Gesture;