123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522 |
- import React from "react";
- import platform from "platform";
- export const isMacOS = () => {
- return platform.os.family === "OS X";
- };
- // From https://stackoverflow.com/questions/20110224/what-is-the-height-of-a-line-in-a-wheel-event-deltamode-dom-delta-line
- const getScrollLineHeight = () => {
- const iframe = document.createElement("iframe");
- iframe.src = "#";
- document.body.appendChild(iframe);
- // Write content in Iframe
- const idoc = iframe.contentWindow.document;
- idoc.open();
- idoc.write(
- "<!DOCTYPE html><html><head></head><body><span>a</span></body></html>"
- );
- idoc.close();
- const scrollLineHeight = idoc.body.firstElementChild.offsetHeight;
- document.body.removeChild(iframe);
- return scrollLineHeight;
- };
- const LINE_HEIGHT = getScrollLineHeight();
- // Reasonable default from https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
- const PAGE_HEIGHT = 800;
- const otherPointer = (pointers, currentPointer) => {
- const p2 = Object.keys(pointers)
- .map((p) => Number(p))
- .find((pointer) => pointer !== currentPointer);
- return pointers[p2];
- };
- const computeDistance = ([x1, y1], [x2, y2]) => {
- const distanceX = Math.abs(x1 - x2);
- const distanceY = Math.abs(y1 - y2);
- return Math.hypot(distanceX, distanceY);
- };
- const empty = () => {};
- const Gesture = ({
- children,
- onDrag = empty,
- onDragStart = empty,
- onDragEnd = empty,
- onPan = empty,
- onTap = empty,
- onLongTap = empty,
- onDoubleTap = empty,
- onZoom,
- }) => {
- const wrapperRef = React.useRef(null);
- const stateRef = React.useRef({
- moving: false,
- pointers: {},
- mainPointer: undefined,
- });
- const queueRef = React.useRef([]);
- // Queue event to avoid async mess
- const queue = React.useCallback((callback) => {
- queueRef.current.push(async () => {
- await callback();
- queueRef.current.shift();
- if (queueRef.current.length !== 0) {
- await queueRef.current[0]();
- }
- });
- if (queueRef.current.length === 1) {
- queueRef.current[0]();
- }
- }, []);
- const onWheel = React.useCallback(
- (e) => {
- const {
- deltaX,
- deltaY,
- clientX,
- clientY,
- deltaMode,
- ctrlKey,
- altKey,
- metaKey,
- target,
- } = e;
- // On a MacOs trackpad, the pinch gesture sets the ctrlKey to true.
- // In that situation, we want to use the custom scaling, not the browser default zoom.
- // Hence in this situation we avoid to return immediately.
- if (altKey || (ctrlKey && !isMacOS())) {
- return;
- }
- // On a trackpad, the pinch and pan events are differentiated by the crtlKey value.
- // On a pinch gesture, the ctrlKey is set to true, so we want to have a scaling effect.
- // If we are only moving the fingers in the same direction, a pan is needed.
- // Ref: https://medium.com/@auchenberg/detecting-multi-touch-trackpad-gestures-in-javascript-a2505babb10e
- if (isMacOS() && !ctrlKey) {
- queue(() =>
- onPan({
- deltaX: -2 * deltaX,
- deltaY: -2 * deltaY,
- button: 1,
- ctrlKey,
- metaKey,
- target,
- event: e,
- })
- );
- } else {
- // Quit if onZoom is not set
- if (onZoom === undefined || !deltaY) return;
- let scale = deltaY;
- switch (deltaMode) {
- case 1: // Pixel
- scale *= LINE_HEIGHT;
- break;
- case 2:
- scale *= PAGE_HEIGHT;
- break;
- default:
- }
- if (isMacOS()) {
- scale *= 2;
- }
- queue(() => onZoom({ scale, clientX, clientY, event: e }));
- }
- },
- [onPan, onZoom, queue]
- );
- const onPointerDown = React.useCallback(
- ({
- target,
- button,
- clientX,
- clientY,
- pointerId,
- altKey,
- ctrlKey,
- metaKey,
- isPrimary,
- }) => {
- // Add pointer to map
- stateRef.current.pointers[pointerId] = { clientX, clientY };
- if (isPrimary) {
- // Clean mainPoint on primary pointer
- stateRef.current.mainPointer = undefined;
- }
- if (stateRef.current.mainPointer !== undefined) {
- if (stateRef.current.mainPointer !== pointerId) {
- // This is not the main pointer
- try {
- const { clientX: clientX2, clientY: clientY2 } = otherPointer(
- stateRef.current.pointers,
- pointerId
- );
- const newClientX = (clientX2 + clientX) / 2;
- const newClientY = (clientY2 + clientY) / 2;
- const distance = computeDistance(
- [clientX2, clientY2],
- [clientX, clientY]
- );
- // We update previous position as the new position is the center beetween both finger
- Object.assign(stateRef.current, {
- pressed: true,
- moving: false,
- gestureStart: false,
- startX: clientX,
- startY: clientY,
- prevX: newClientX,
- prevY: newClientY,
- startDistance: distance,
- prevDistance: distance,
- });
- } catch (e) {
- console.log("Error while getting other pointer. Ignoring", e);
- stateRef.current.mainPointer === undefined;
- }
- }
- return;
- }
- // We set the mainpointer
- stateRef.current.mainPointer = pointerId;
- // And prepare move
- Object.assign(stateRef.current, {
- pressed: true,
- moving: false,
- gestureStart: false,
- startX: clientX,
- startY: clientY,
- prevX: clientX,
- prevY: clientY,
- currentButton: button,
- target,
- timeStart: Date.now(),
- longTapTimeout: setTimeout(async () => {
- stateRef.current.noTap = true;
- queue(() =>
- onLongTap({
- clientX,
- clientY,
- altKey,
- ctrlKey,
- metaKey,
- target,
- })
- );
- }, 750),
- });
- try {
- target.setPointerCapture(pointerId);
- } catch (e) {
- console.log("Fail to capture pointer", e);
- }
- },
- [onLongTap, queue]
- );
- const onPointerMove = React.useCallback(
- (e) => {
- if (stateRef.current.pressed) {
- const {
- pointerId,
- clientX: eventClientX,
- clientY: eventClientY,
- altKey,
- ctrlKey,
- metaKey,
- pointerType,
- } = e;
- if (stateRef.current.mainPointer !== pointerId) {
- // Event from other pointer
- stateRef.current.pointers[pointerId] = {
- clientX: eventClientX,
- clientY: eventClientY,
- };
- return;
- }
- stateRef.current.moving = true;
- // Do we have two fingers ?
- const twoFingers = Object.keys(stateRef.current.pointers).length === 2;
- let clientX, clientY, distance;
- if (twoFingers) {
- // Find other pointerId
- const { clientX: clientX2, clientY: clientY2 } = otherPointer(
- stateRef.current.pointers,
- pointerId
- );
- // Update client X with the center of each touch
- clientX = (clientX2 + eventClientX) / 2;
- clientY = (clientY2 + eventClientY) / 2;
- distance = computeDistance(
- [clientX2, clientY2],
- [eventClientX, eventClientY]
- );
- } else {
- clientX = eventClientX;
- clientY = eventClientY;
- }
- // We drag if
- // On non touch device
- // - Button is 0
- // - Alt key is no pressed
- // or on touch devices
- // - We use only one finger
- const shouldDrag =
- pointerType !== "touch"
- ? stateRef.current.currentButton === 0 && !altKey
- : !twoFingers;
- if (shouldDrag) {
- // Send drag start on first move
- if (!stateRef.current.gestureStart) {
- wrapperRef.current.style.cursor = "move";
- stateRef.current.gestureStart = true;
- // Clear tap timeout
- clearTimeout(stateRef.current.longTapTimeout);
- queue(() =>
- onDragStart({
- deltaX: 0,
- deltaY: 0,
- startX: stateRef.current.startX,
- startY: stateRef.current.startY,
- distanceX: 0,
- distanceY: 0,
- button: stateRef.current.currentButton,
- altKey,
- ctrlKey,
- metaKey,
- target: stateRef.current.target,
- event: e,
- })
- );
- }
- // Create closure
- const deltaX = clientX - stateRef.current.prevX;
- const deltaY = clientY - stateRef.current.prevY;
- const distanceX = clientX - stateRef.current.startX;
- const distanceY = clientY - stateRef.current.startY;
- // Drag event
- queue(() =>
- onDrag({
- deltaX,
- deltaY,
- startX: stateRef.current.startX,
- startY: stateRef.current.startY,
- distanceX,
- distanceY,
- button: stateRef.current.currentButton,
- altKey,
- ctrlKey,
- metaKey,
- target: stateRef.current.target,
- event: e,
- })
- );
- } else {
- if (!stateRef.current.gestureStart) {
- wrapperRef.current.style.cursor = "move";
- stateRef.current.gestureStart = true;
- // Clear tap timeout on first move
- clearTimeout(stateRef.current.longTapTimeout);
- }
- // Create closure
- const deltaX = clientX - stateRef.current.prevX;
- const deltaY = clientY - stateRef.current.prevY;
- const target = stateRef.current.target;
- // Pan event
- queue(() =>
- onPan({
- deltaX,
- deltaY,
- button: stateRef.current.currentButton,
- altKey,
- ctrlKey,
- metaKey,
- target,
- event: e,
- })
- );
- if (
- twoFingers &&
- distance !== stateRef.current.prevDistance &&
- onZoom
- ) {
- const scale = stateRef.current.prevDistance - distance;
- if (Math.abs(scale) > 0) {
- queue(() =>
- onZoom({
- scale,
- clientX,
- clientY,
- event: e,
- })
- );
- stateRef.current.prevDistance = distance;
- }
- }
- }
- stateRef.current.prevX = clientX;
- stateRef.current.prevY = clientY;
- }
- },
- [onDrag, onDragStart, onPan, onZoom, queue]
- );
- const onPointerUp = React.useCallback(
- (e) => {
- const {
- clientX,
- clientY,
- altKey,
- ctrlKey,
- metaKey,
- target,
- pointerId,
- } = e;
- if (!stateRef.current.pointers[pointerId]) {
- // Pointer already gone previously with another event
- // ignoring it
- return;
- }
- // Remove pointer from map
- delete stateRef.current.pointers[pointerId];
- if (stateRef.current.mainPointer !== pointerId) {
- // If this is not the main pointer we quit here
- return;
- }
- while (Object.keys(stateRef.current.pointers).length > 0) {
- // If was main pointer but we have another one, this one become main
- stateRef.current.mainPointer = Number(
- Object.keys(stateRef.current.pointers)[0]
- );
- try {
- stateRef.current.target.setPointerCapture(
- stateRef.current.mainPointer
- );
- return;
- } catch (e) {
- console.log("Fails to set pointer capture", e);
- stateRef.current.mainPointer = undefined;
- delete stateRef.current.pointers[
- Object.keys(stateRef.current.pointers)[0]
- ];
- }
- }
- stateRef.current.mainPointer = undefined;
- stateRef.current.pressed = false;
- // Clear longTap
- clearTimeout(stateRef.current.longTapTimeout);
- if (stateRef.current.moving) {
- // If we were moving, send drag end event
- stateRef.current.moving = false;
- queue(() =>
- onDragEnd({
- deltaX: clientX - stateRef.current.prevX,
- deltaY: clientY - stateRef.current.prevY,
- startX: stateRef.current.startX,
- startY: stateRef.current.startY,
- distanceX: clientX - stateRef.current.startX,
- distanceY: clientY - stateRef.current.startY,
- button: stateRef.current.currentButton,
- altKey,
- ctrlKey,
- metaKey,
- event: e,
- })
- );
- wrapperRef.current.style.cursor = "auto";
- } else {
- const now = Date.now();
- if (stateRef.current.noTap) {
- stateRef.current.noTap = false;
- } else {
- // Send tap event only if time less than 300ms
- if (stateRef.current.timeStart - now < 300) {
- queue(() =>
- onTap({
- clientX,
- clientY,
- altKey,
- ctrlKey,
- metaKey,
- target,
- })
- );
- }
- }
- }
- },
- [onDragEnd, onTap, queue]
- );
- const onDoubleTapHandler = React.useCallback(
- (event) => {
- onDoubleTap(event);
- },
- [onDoubleTap]
- );
- return (
- <div
- onWheel={onWheel}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerOut={onPointerUp}
- onPointerLeave={onPointerUp}
- onPointerCancel={onPointerUp}
- onDoubleClick={onDoubleTapHandler}
- style={{ touchAction: "none" }}
- ref={wrapperRef}
- >
- {children}
- </div>
- );
- };
- export default Gesture;
|