0

I am using Konva.js to build a canvas app. This app allows user to drag an image and zoom at any point. This works perfectly on Desktop. However, I like this app to be responsive for mobile devices. The problem here is canvas zoom and image drag cannot work synchronously. Since 'onTouchMove' event works when image dragged, this app does not work as intended.

import React, { useEffect, useState, useRef } from 'react';
import {
  Stage, Layer, Image,
} from 'react-konva';
import Rectangle from './rectangle.component';

const GenericCanvas = ({
  canvasWidth,
  canvasHeight,
  imageUrl,
  imgWidth,
  imgHeight,
  rects,
  zoom,
  imageMove,
  alpha,
  borderDash,
  rectDraggable,
  fillStatus,
  onClickRect,
  reset,
  setReset,
  imageType,
  displayedFigure,
  displayedTable,
  clickedRect
}) => {
  const [image, setImage] = useState(null);
  const [stageScale, setStageScale] = useState(1);
  const [stageX, setStageX] = useState(0);
  const [stageY, setStageY] = useState(0);
  const [lastX, setLastX] = useState(0);
  const [lastY, setLastY] = useState(0);
  const [hasImageLoaded, setHasImageLoaded] = useState(false);

  const handleImageLastPosition = (e) => {
    setLastX(e.target.attrs.x);
    setLastY(e.target.attrs.y);
  };

  const handleWheel = (e) => {
    e.evt.preventDefault();

    const scaleBy = 1.2;
    const stage = e.target.getStage();
    const oldScale = stage.scaleX();
    const mousePointTo = {
      x: stage.getPointerPosition().x / oldScale - stage.x() / oldScale,
      y: stage.getPointerPosition().y / oldScale - stage.y() / oldScale,
    };

    const newScale = e.evt.deltaY > 0
      ? (oldScale > 3 ? oldScale : (oldScale * scaleBy)) : oldScale < 1
        ? oldScale : (oldScale / scaleBy);

    setStageScale(newScale);
    setStageX(-(mousePointTo.x - stage.getPointerPosition().x / newScale) * newScale);
    setStageY(-(mousePointTo.y - stage.getPointerPosition().y / newScale) * newScale);
  };

  function getDistance(p1, p2) {
    return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
  }

  function getCenter(p1, p2) {
    return {
      x: (p1.x + p2.x) / 2,
      y: (p1.y + p2.y) / 2,
    };
  }

  var lastCenter = null;
  var lastDist = 0;

  const handleMultiTouch = (e) => {
    e.evt.preventDefault();
    var touch1 = e.evt.touches[0];
    var touch2 = e.evt.touches[1];
    const stage = e.target.getStage();

    if (touch1 && touch2) {
      if (stage.isDragging()) {
        stage.stopDrag();
      }

      imageMove= false;

      var p1 = {
        x: touch1.clientX,
        y: touch1.clientY,
      };
      var p2 = {
        x: touch2.clientX,
        y: touch2.clientY,
      };

      if (!lastCenter) {
        lastCenter = getCenter(p1, p2);
        return;
      }
      var newCenter = getCenter(p1, p2);

      var dist = getDistance(p1, p2);

      if (!lastDist) {
        lastDist = dist;
      }

      // local coordinates of center point
      var pointTo = {
        x: (newCenter.x - stage.x()) / stage.scaleX(),
        y: (newCenter.y - stage.y()) / stage.scaleX(),
      };

      var scale = stage.scaleX() * (dist / lastDist);

      stage.scaleX(scale);
      stage.scaleY(scale);

      // calculate new position of the stage
      var dx = newCenter.x - lastCenter.x;
      var dy = newCenter.y - lastCenter.y;

      var newPos = {
        x: newCenter.x - pointTo.x * scale + dx,
        y: newCenter.y - pointTo.y * scale + dy,
      };

      stage.position(newPos);
      stage.batchDraw();

      lastDist = dist;
      lastCenter = newCenter;
    }
  };

  const multiTouchEnd = () => {
    lastCenter = null;
    lastDist = 0;
  }

  useEffect(() => {
    setHasImageLoaded(false);
    if (imageUrl) {
      const matchedImg = new window.Image();
      matchedImg.src = `${API_URL + imageUrl}`;
      matchedImg.onload = () => {
        setHasImageLoaded(true);
        setImage(matchedImg);
      };
    }

  }, [size, imageUrl]);

  let imgDraggable = true
  return (
    <Stage
      width={canvasWidth}
      height={canvasHeight}
      onWheel={zoom ? handleWheel : null}
      scaleX={stageScale}
      onTouchMove={handleMultiTouch}
      onTouchEnd={() => {
        multiTouchEnd()
      }}
      scaleY={stageScale}
      x={stageX}
      y={stageY}
    >
      <Layer>
        <Image
          x={lastX}
          y={lastY}
          ref={stageRef}
          image={image}
          width={imgWidth * alpha || 0}
          height={imgHeight * alpha || 0}
          onDragMove={(e) => {
            if (e.evt.touches.length === 2) {
              imgDraggable = false
            }
            handleImageLastPosition(e)
          }}
          draggable={imgDraggable}
        />
        {rects?.length && hasImageLoaded ?
          rects.map((rect) => 
            (
            <Rectangle
              key={uuid()}
              x={imageType === 'figure' ? rect.bbox.x0 * alpha + lastX : rect.PN_bbox.x0 * alpha + lastX}
              y={imageType === 'figure' ? rect.bbox.y0 * alpha + lastY : rect.PN_bbox.y0 * alpha + lastY}
              width={imageType === 'figure' ? rect.bbox.x1 * alpha - rect.bbox.x0 * alpha : rect.PN_bbox.x1 * alpha - rect.PN_bbox.x0 * alpha}
              height={imageType === 'figure' ? rect.bbox.y1 * alpha - rect.bbox.y0 * alpha : rect.PN_bbox.y1 * alpha - rect.PN_bbox.y0 * alpha}
              rectDraggable={rectDraggable}
              borderDash={borderDash}
              lastX={lastX}
              lastY={lastY}
              alpha={alpha}
              rect={rect}
              fillStatus={clickedRect?.item_no === rect?.item_no}
              imageId={imageType === 'table' ? displayedTable?.img_id : displayedFigure?.img_id}
              imageType={imageType}
              onClickRect={onClickRect}
            />
            )
          ) : null
        }
      </Layer>
    </Stage>
  );
};

I tried to control touch event length because if it is 2 this means it comes from multitouch so zoom can be active vice versa. However, it also did not work. Thanks for any help in advance. The demo can be found on https://codesandbox.io/s/react-konva-zoom-webmobile-demo-em5o6 .

hcacode
  • 75
  • 1
  • 9
  • Can you make an online simple demo? – lavrton Feb 08 '21 at 16:12
  • https://codesandbox.io/s/react-konva-zoom-webmobile-demo-em5o6 The problem is if 'Image' is draggable mobile zoom does not work as intended. I like to drag the Image with single touch and zoom with multitouch. – hcacode Feb 09 '21 at 14:20

1 Answers1

-1

The problem in your sandbox code seems to be with this part:

if (stage.isDragging()) {
    stage.stopDrag();
  }

Firstly, it's checking if the STAGE is being dragged, not the image. But even if we set the stage as draggable and start dragging it this check will return FALSE anyway because it's done too early I think.

One solution would be to add a new state flag, for example isZooming and set this to TRUE whenever multitouch is registered. Then we can add onDragStart to the image props and in the handle function we run stage.stopDrag() if isZooming is TRUE.

Here is a modified version of your sandbox example:

https://codesandbox.io/s/react-konva-zoom-webmobile-demo-forked-mb1pg?file=/src/components/generic-canvas.jsx

Olof
  • 36
  • 3