Skip to content
Colin Wren
Twitter

Create an Editable, Resizable Text Label in Konva with React

JavaScript, Software Development3 min read

an editable sticky note in reciprocal.dev
An example of sticky notes and connection labels in Reciprocal.dev using the techniques to create editable and resizable text in Konva

During the development of Reciprocal.dev it became apparent that for most users the building blocks of a User Journey map wasn’t a mockup of a screen but a simple sticky note.

The sticky note allowed users to quickly jot down a step in a User Journey and move it across multiple contextual areas until it fit in the right place. A mockup required a lot more effort and had to sometimes change significantly to fit in with the rest of the steps in the area landed.

Adding a sticky note component to the app was pretty simple, it’s just a rectangle with some text, but the tricky part was how to enable users to edit the text quickly without having to fill out a form in order to see a change to the text.

The solution for allowing sticky notes to be edited would also need to be extended to other components such as connection labels, in order to provide a consistent UX for editing text components on the canvas.

Fortunately the Konva documentation has a demo on how to create both editable and resizable text so I had to amend these to work in react-konva and combine the two approaches into one component.

Making text editable

The trick to making editable text is to use a HTML input positioned over the canvas text and then remove the styling from the input so that when the user types, it feels as though they are updating the canvas text directly.

The react-konva-utils library has a Html component that allows for rendering HTML on top of the Konva canvas if you provide it the X and Y coordinates on the canvas that it needs to be rendered at.

Within the HTML we can render a textarea similar to the editable text demo from the Konva docs, but in order to make it feel as though that textarea is actually the text on the canvas, we need to apply styling to hide the default UI elements that give away the fact it’s a textarea .

We also need to have a means of allowing a user to exit the editing mode and go back to seeing the editing text on the canvas. My implementation does this in two ways; The first is to listen for the return and escape keys as per the konva example but I also set an onClick event on the stage so that a click outside the text area would reset the state to show the non-editable text.

1import React from "react";
2import { Text } from 'react-konva';
3import { Html } from "react-konva-utils";
4
5const RETURN_KEY = 13;
6const ESCAPE_KEY = 27;
7
8function getStyle(width, height) {
9 const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
10 const baseStyle = {
11 width: `${width}px`,
12 height: `${height}px`,
13 border: "none",
14 padding: "0px",
15 margin: "0px",
16 background: "none",
17 outline: "none",
18 resize: "none",
19 colour: "black",
20 fontSize: "16px",
21 fontFamily: "sans-serif"
22 };
23 if (isFirefox) {
24 return baseStyle;
25 }
26 return {
27 ...baseStyle,
28 margintop: "-4px"
29 };
30}
31
32export function EditableTextInput({
33 x,
34 y,
35 isEditing,
36 onToggleEdit,
37 onChange,
38 text,
39 width,
40 height
41}) {
42 function handleEscapeKeys(e) {
43 if ((e.keyCode === RETURN_KEY && !e.shiftKey) || e.keyCode === ESCAPE_KEY) {
44 onToggleEdit(e);
45 }
46 }
47
48 function handleTextChange(e) {
49 onChange(e.currentTarget.value);
50 }
51
52 if (isEditing) {
53 const style = getStyle(width, height);
54 return (
55 <Html groupProps={{ x, y }} divProps={{ style: { opacity: 1 } }}>
56 <textarea
57 value={value}
58 onChange={onChange}
59 style={style}
60 />
61 </Html>
62 );
63 }
64 return (
65 <Text
66 x={x}
67 y={y}
68 width={width}
69 text={text}
70 />
71 )
72}
The isEditing flag controls if the user sees the canvas Text or the textarea HTML input. When the stage is clicked the isEditing flag is set to false allowing the user to end editing by clicking outside the sticky note

Resizing text

The second behaviour to implement was being able to resize the text when it wasn’t being edited. This would allow the user to change the width of the text in order to fit it into a smaller space if the text was long.

This was achieved by using a Transformer component that was configured to transform the Text component, but locked to only allow resizing on the X axis.

A callback for the transformer then updates the width and height of the text at the higher level component level so that the canvas text, text input and resizable text get the new dimensions.

1import React, { useRef, useEffect } from "react";
2import { Text, Transformer } from "react-konva";
3
4export function ResizableText({
5 x,
6 y,
7 text,
8 isSelected,
9 width,
10 onResize,
11 onClick,
12 onDoubleClick
13}) {
14 const textRef = useRef(null);
15 const transformerRef = useRef(null);
16
17 useEffect(() => {
18 if (isSelected && transformerRef.current !== null) {
19 transformerRef.current.nodes([textRef.current]);
20 transformerRef.current.getLayer().batchDraw();
21 }
22 }, [isSelected]);
23
24 function handleResize() {
25 if (textRef.current !== null) {
26 const textNode = textRef.current;
27 const newWidth = textNode.width() * textNode.scaleX();
28 const newHeight = textNode.height() * textNode.scaleY();
29 textNode.setAttrs({
30 width: newWidth,
31 scaleX: 1
32 });
33 onResize(newWidth, newHeight);
34 }
35 }
36
37 const transformer = isSelected ? (
38 <Transformer
39 ref={transformerRef}
40 rotateEnabled={false}
41 flipEnabled={false}
42 enabledAnchors={["middle-left", "middle-right"]}
43 boundBoxFunc={(oldBox, newBox) => {
44 newBox.width = Math.max(30, newBox.width);
45 return newBox;
46 }}
47 />
48 ) : null;
49
50 return (
51 <>
52 <Text
53 x={x}
54 y={y}
55 ref={textRef}
56 text={text}
57 fill="black"
58 fontFamily="sans-serif"
59 fontSize={16}
60 perfectDrawEnabled={false}
61 onTransform={handleResize}
62 onClick={onClick}
63 onTap={onClick}
64 onDblClick={onDoubleClick}
65 onDblTap={onDoubleClick}
66 width={width}
67 />
68 {transformer}
69 </>
70 );
71}
The transformer is only applied when the text transform has been activated. This state is set at the higher component level and will become useful later

Rolling both actions into one component

The UX I wanted for the editable text component was that a single click enabled the resizing of the text and a double click enabled the editing of the text.

To achieve this I created a component that wrapped the EditableTextInput and ResizableText components detailed above and used state to show the appropriate component, depending on the action the user performed.

The non-active state would show the canvas text which was rendered as part of the ResizableText component so the single click just toggled the transformer on this component. A double click would swap out the ResizableText component for the EditableTextInput component.

To make the wrapper component re-usable the state wouldn’t be tracked within the wrapper but externally in the parent component so that it had control over how it presented itself when the state changed.

1import React from "react";
2import { ResizableText } from "./ResizableText";
3import { EditableTextInput } from "./EditableTextInput";
4
5const RETURN_KEY = 13;
6const ESCAPE_KEY = 27;
7
8export function EditableText({
9 x,
10 y,
11 isEditing,
12 isTransforming,
13 onToggleEdit,
14 onToggleTransform,
15 onChange,
16 onResize,
17 text,
18 width,
19 height
20}) {
21 function handleEscapeKeys(e) {
22 if ((e.keyCode === RETURN_KEY && !e.shiftKey) || e.keyCode === ESCAPE_KEY) {
23 onToggleEdit(e);
24 }
25 }
26
27 function handleTextChange(e) {
28 onChange(e.currentTarget.value);
29 }
30
31 if (isEditing) {
32 return (
33 <EditableTextInput
34 x={x}
35 y={y}
36 width={width}
37 height={height}
38 value={text}
39 onChange={handleTextChange}
40 onKeyDown={handleEscapeKeys}
41 />
42 );
43 }
44 return (
45 <ResizableText
46 x={x}
47 y={y}
48 isSelected={isTransforming}
49 onClick={onToggleTransform}
50 onDoubleClick={onToggleEdit}
51 onResize={onResize}
52 text={text}
53 width={width}
54 />
55 );
56}
The wrapper component. It takes two flags; isEditing and isTransforming which are used to show the text input and transformer. These flags are tracked and updated in the parent component

A component making use of the EditableText wrapper component needs to track the editing and transforming state but needs to also update these when the stage is clicked so a useEffect hook needs to be added to update these flags when that happens.

1import React, { useState } from "react";
2import { Group, Rect } from "react-konva";
3import { EditableText } from "./EditableText";
4
5export function EditableTextUsage({ selected, setSelected }) {
6 const [isEditing, setIsEditing] = useState(false);
7 const [isTransforming, setIsTransforming] = useState(false);
8 const [text, setText] = useState("Click to resize. Double click to edit.");
9 const [width, setWidth] = useState(200);
10 const [height, setHeight] = useState(200);
11
12 useEffect(() => {
13 if (!selected && isEditing) {
14 setIsEditing(false);
15 } else if (!selected && isTransforming) {
16 setIsTransforming(false);
17 }
18 }, [selected, isEditing, isTransforming]);
19
20 function toggleEdit() {
21 setIsEditing(!isEditing);
22 setSelected(!isEditing);
23 }
24
25 function toggleTransforming() {
26 setIsTransforming(!isTransforming);
27 setSelected(!isTransforming);
28 }
29
30 function onTextResize(newWidth, newHeight) {
31 setWidth(newWidth);
32 setHeight(newHeight);
33 }
34
35 function onTextChange(value) {
36 setText(value)
37 }
38
39return (
40 <EditableText
41 x={0}
42 y={0}
43 text={text}
44 width={width}
45 height={height}
46 onResize={onTextResize}
47 isEditing={isEditing}
48 isTransforming={isTransforming}
49 onToggleEdit={toggleEdit}
50 onToggleTransform={toggleTransforming}
51 onChange={onTextChange}
52 />
53);
54}
An implementation of the EditableText within a component. The useEffect hook listens for changes to the selected flag and resets the editing/transforming state when the flag is set to false by a click on the stage

Demo

You can find an example implementation of the editable & resizable text in a demo application I’ve created that allows a sticky note to be edited.

A demo of an editable and resizable text block. Illustrated using a sticky note-like component

Conclusion

By letting the user edit and resize the text in the canvas itself you give them a more interactive experience and more control over how the element they’re editing the text fits into the overall canvas they are working on.

This approach could be developed further by swapping out the basic textinput used for the editable text with a more feature rich editing widget as under the hood the editing functionality is rendering in a HTML block rendered onto the canvas.