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
.
- save the start position when dragging begins -
onDragStart
; - in the end of dragging, start point's position can be obtained through target's method
getAbsolutePosition
; - calculate the length of the line according to the previous start/end points
- calculate the new end points' positions
- 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.