Draw a scalable line with React Konva

These days I need to implement a component to draw a line on the given image and this line should be scalable, draggable and limited within the image. So here are the steps to implement it.

Konva

Definitely we need canvas to complete it, but instead of using canvas directly, it's better to use some mature library, like Konva

an HTML5 Canvas JavaScript framework that extends the 2d context by enabling canvas interactivity for desktop and mobile applications.

This library definitely can do more things, but here we would only use the drawing part of it. It also provides good documentation and examples

Setup the canvas

Let's set up the canvas with Konva's Stage. The node would fit its parent node.

 1const stageRef = useRef<HTMLDivElement | null>();
 2// calculate the width of parent's node as canvas's width
 3const stageWidth = stageRef?.current?.offsetWidth || 400;
 4return (
 5  <div
 6    ref={stageRef}
 7    style={{
 8      width: '100%',
 9      height: '100%',
10    }}
11  >
12    <Stage
13      width={stageWidth}
14      height={imgHeight} // we need to get the related image height
15    >
16      <Layer>
17        //...
18      </Layer>
19    </Stage>
20  </div>
21)

We assign the node's width as canvas's width. But we would like to keep the origianl ratio of image, instead of scaling it, so we need to calculate the related height with given image's width.

Display the image

First we need to display the image as the background. Since Konva's Image do not accept string as input, we need to generate an image html element. Give the image source string, set the image instance's src when it's loaded.

 1const useLineCrossingImage = ({ imgSrc }: { imgSrc: string }) => {
 2  const [image, setImage] = useState<HTMLImageElement | undefined>()
 3
 4  // load image with given base64 string src
 5  useEffect(() => {
 6    const imageInstance: HTMLImageElement = new window.Image()
 7    const updateImage = () => {
 8      setImage(imageInstance)
 9    }
10    imageInstance.src = imgSrc
11    imageInstance.addEventListener('load', updateImage)
12    return () => {
13      imageInstance.removeEventListener('load', updateImage)
14    }
15  }, [imgSrc])
16
17  return <Group>
18    <Image
19      image={image}
20      onMouseEnter={(e) => updateMouseCursor(e, 'crosshair')}
21    />
22  </Group>
23}

The image should fit the parent's width with original ratio, so we need the image width and calculate the height based on it and the ratio.

 1const [imgHeight, setImgHeight] = useState<number>(DEFAULT_WIDTH_HEIGHT)
 2
 3// load image with given base64 string src
 4useEffect(() => {
 5  //...
 6  const updateImage = () => {
 7    // calculate the related height with width and not changing ratio
 8    const height = (width / imageInstance.width) * imageInstance.height
 9    imageInstance.width = width
10    imageInstance.height = height
11    setImgHeight(height)
12    setImage(imageInstance)
13  }
14  //...
15}, [imgSrc, width])
16
17return {
18  imgHeight,
19  instance: (
20    <Group>
21      <Image
22        image={image}
23        onMouseEnter={(e) => updateMouseCursor(e, 'crosshair')}
24      />
25    </Group>
26  ),
27}

Finally we return the image's instance and height which would be used in the Stage.

Draw line

So the background image is done now, let's draw the line on it.

First we need to define the start and end point with Konva's type Vector2d.

1export interface Vector2d {
2  x: number;
3  y: number;
4}
5
6//...
7
8const [startPoint, setStartPoint] = useState<Vector2d | null>(null);
9const [endPoint, setEndPoint] = useState<Vector2d | null>(null);

When we click on the image, the start point should be set and its coordinates are saved, and the end point would be set when we release the mouse after dragging. So a mouse down and mouse up event are required.

 1const [value , setValue] = useState<ImageLineCrossingFormType>()
 2
 3const [isDuringNewLine, setIsDuringNewLine] = useState<boolean>(false);
 4
 5const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
 6  const target = e?.target;
 7
 8  // Draw a new line again if click on the image not the Group
 9  if (target.getClassName() === "Image") {
10    const stage = target?.getStage();
11    if (stage && stage.getPointerPosition()) {
12      setIsDuringNewLine(true);
13      setStartPoint(stage.getPointerPosition());
14      // remove previous end point when start a new line
15      setEndPoint(null);
16    }
17  }
18};
19
20const handleMouseUp = (e: Konva.KonvaEventObject<MouseEvent>) => {
21  const target = e?.target;
22  // NOTE: finish the line only when the target is image
23  if (target.getClassName() === "Image" && isDuringNewLine) {
24    const stage = target?.getStage();
25    if (stage && stage.getPointerPosition()) {
26      const endValue = stage.getPointerPosition();
27      setIsDuringNewLine(false);
28      setEndPoint(endValue);
29      // save the value
30      SET_VALUE_WITH_NAME({
31        x1: startPoint?.x ?? 0,
32        y1: startPoint?.y ?? 0,
33        x2: endValue?.x ?? DEFAULT_WIDTH_HEIGHT,
34        y2: endValue?.y ?? DEFAULT_WIDTH_HEIGHT,
35      });
36    }
37  }
38};
39
40// calculate the width of parent's node as canvas's width
41const stageWidth = stageRef?.current?.offsetWidth || DEFAULT_WIDTH_HEIGHT;
42
43// with given width, calculate the related height without changing ratio of image
44// and get the image canvas instance
45const { imgHeight, instance: ImgInstance } = useLineCrossingImage({
46  imgSrc,
47  width: stageWidth,
48});
49
50
51return (
52  <Stage
53    width={stageWidth}
54    height={imgHeight}
55    onMouseDown={handleMouseDown}
56    onMouseUp={handleMouseUp}
57  >
58    //...
59  </Stage>
60)

To make sure we can draw a new line, we need to make sure the item clicked is the image. And during the drawing, we should lock this process and set the end point only after the start point being set. To achieve that, isDuringNewLine is used to lock this process.

1const target = e?.target;
2// NOTE: finish the line only when the target is image
3if (target.getClassName() === "Image" && isDuringNewLine) 

To display the line, we are gonna use the konva's class Line with only two points (it can use infinity points in theory). To distinguish the line from other objects, let's set the mouse cursor as grab.

1<Line
2  points={[ startPoint.x, startPoint.y, endPoint.x, endPoint.y ]}
3  stroke="green"
4  strokeWidth={6}
5  onMouseEnter={(e) => updateMouseCursor(e, 'grab')} // use grab cursor for line
6/>

Scale points

The line's start and end points should also be draggable to reset their values. We can draw two circle objects around the points with Konva's class Circle.

 1<Circle
 2  x={startPoint.x}
 3  y={startPoint.y}
 4  draggable // circle can be dragged to extend the line
 5  onMouseEnter={(e) => updateMouseCursor(e, 'pointer')} // the cursor is pointer
 6  fill="white"
 7  stroke="green"
 8  strokeWidth={3}
 9  radius={6}
10/>

The circles can be dragged now, but they are not attached to the line, when we move the circle points, the line's points are not updated. So we need to bind them as a Group.

Group

When we want to transform multiple shapes together with the same operation, Group can be applied. And one thing needs to be careful, the position of the whole Group is absolute to the Stage, while all the positions of shapes within the Group would be related to the Group.

Let's give the name of the start point of Group as groupAbsoluteStart. The relative position of end point should also be applied within the Group.

 1<Group
 2  draggable
 3  // NOTE: The Group x/y should use the absolute position, we use it as start point
 4  x={groupAbsoluteStart.x}
 5  y={groupAbsoluteStart.y}
 6>
 7  <Line
 8    points={[ // NOTE: the node inside of group should use relative position
 9      0,
10      0,
11      groupAbsoluteEnd.x - groupAbsoluteStart.x,
12      groupAbsoluteEnd.y - groupAbsoluteStart.y,
13    ]}
14    stroke="green"
15    strokeWidth={6}
16    onMouseEnter={(e) => updateMouseCursor(e, 'grab')} // use grab cursor for line
17  />
18  {groupAbsoluteStart && (
19    <Circle // NOTE: the start point of start circle should always have the static relative position to the Group
20      x={0}
21      y={0}
22      draggable // circle can be dragged to extend the line
23      onMouseEnter={(e) => updateMouseCursor(e, 'pointer')} // the cursor is pointer
24      fill="white"
25      stroke="green"
26      strokeWidth={3}
27      radius={6}
28    />
29  )}
30  {groupAbsoluteEnd && (
31    <Circle
32      // NOTE: use the relative position inside of the Group
33      x={groupAbsoluteEnd.x - groupAbsoluteStart.x}
34      y={groupAbsoluteEnd.y - groupAbsoluteStart.y}
35      draggable
36      onMouseEnter={(e) => updateMouseCursor(e, 'pointer')}
37      fill="white"
38      stroke="green"
39      strokeWidth={3}
40      radius={6}
41    />
42  )}
43</Group>

When we finish setting the line, we need the absolute positions of start/end points. To obtain them, we can get the absolute positions of the Group in the end of dragging with event onDragEnd.

  1. save the start position when dragging begins - onDragStart;
  2. in the end of dragging, start point's position can be obtained through target's method getAbsolutePosition;
  3. calculate the length of the line according to the previous start/end points
  4. calculate the new end points' positions
  5. save the new start and end positions
 1const [savedStartPoint, setSavedStartPoint] = useState<Vector2d | null>(null)
 2
 3<Group
 4  draggable
 5  // NOTE: The Group x/y should use the absolute position, we use it as start point
 6  x={groupAbsoluteStart.x}
 7  y={groupAbsoluteStart.y}
 8  onDragStart={(e) => {
 9    const target = e?.currentTarget
10    // 1. Save the start point to calculate moved distance when drag ends
11    setSavedStartPoint({ x: target.x(), y: target.y() })
12  }}
13  onDragEnd={(e) => {
14    const target = e?.currentTarget
15    // 2. get the absolute of the group and save it as start point
16    const { x, y } = target.getAbsolutePosition()
17    // 3, 4. get the new end point based on the moved distance and previous end point
18    const newEndPointX = x - (savedStartPoint?.x ?? 0) + groupAbsoluteEnd.x
19    const newEndPointY = y - (savedStartPoint?.y ?? 0) + groupAbsoluteEnd.y
20    // 5. after we finish the drag, need to update the start and end points for the future actions
21    setGroupAbsoluteStart({ x, y })
22    setGroupAbsoluteEnd({ x: newEndPointX, y: newEndPointY })
23
24    // calculate the values with form's format
25    SET_VALUE_WITH_NAME({ x1: x, y1: y, x2: newEndPointX, y2: newEndPointY })
26  }}
27/>

For the start/end circle points, when we drag them, the line is already attached to the circle, but the related points' values should also be updated.

To avoid triggering the drag event of Group, instead of calling html event's stopPropagation, we should set cancelBubble of event as true on the onDragEnd event. Check the official doc here. And note that this cancelBubble must be done on the onDragEnd event.

NOTE: the relative position of the circle point would be changed, to keep the position consistent, we manually set its relative position as 0 (e.g for the start point)

 1// Start circle point
 2onDragEnd={(e: Konva.KonvaEventObject<MouseEvent>) => {
 3  // NOTE: MUST set the cancelBubble on the drag end event
 4  e.cancelBubble = true
 5
 6  const target = e.target
 7  // get the absolute position of the start circle and save it to the form
 8  const { x, y } = target.getAbsolutePosition()
 9  // Save the value to form when ends instead of during dragging
10  SET_VALUE_WITH_NAME({
11    x1: x / stageWidth,
12    y1: y / stageHeight,
13    x2: groupAbsoluteEnd.x / stageWidth,
14    y2: groupAbsoluteEnd.y / stageHeight,
15  })
16}}
17onDragMove={(e: Konva.KonvaEventObject<MouseEvent>) => {
18  const target = e.target
19  // NOTE: keep the circle relative position always being 0
20  target.x(0)
21  target.y(0)
22}}

Limitation

Til now, the basic feature has been implemented, drag line, circle to new position, draw a new line. But there is no border to the line, the line and its points can be dragged out of the image. To implement this, we need to check points' position during dragging. And the situation is different when the start point is left/right or higher/lower to the end.

 1const limitValue = (xValue: number, maxValue: number, minValue = 0) =>
 2  Math.max(minValue, Math.min(maxValue, xValue))
 3
 4<Group
 5  ...
 6  onDragMove={(e) => {
 7    const target = e?.currentTarget
 8    const { x, y } = target.getAbsolutePosition()
 9    // limit the move area
10    const xMovedDistance = groupAbsoluteEnd.x - groupAbsoluteStart.x
11    const yMovedDistance = groupAbsoluteEnd.y - groupAbsoluteStart.y
12
13    // the range changes when the start is behind or before end
14    if (xMovedDistance > 0) {
15      target.x(limitValue(x, stageWidth - xMovedDistance))
16    } else {
17      target.x(limitValue(x, stageWidth, 0 - xMovedDistance))
18    }
19
20    if (yMovedDistance > 0) {
21      target.y(limitValue(y, stageHeight - yMovedDistance))
22    } else {
23      target.y(limitValue(y, stageHeight, 0 - yMovedDistance))
24    }
25  }}
26/>

For the circle point, we need to calculate its absolute position and limit it within the border as well.

Summary

So this is what we want now, you can check the result on the under example and the Codes here.

comments powered by Disqus