Create an Editable, Resizable Text Label in Konva with React
— JavaScript, Software Development — 3 min read

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 height41}) {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 <textarea57 value={value}58 onChange={onChange}59 style={style}60 />61 </Html>62 );63 }64 return (65 <Text66 x={x}67 y={y}68 width={width}69 text={text}70 />71 )72}
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 onDoubleClick13}) {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: 132 });33 onResize(newWidth, newHeight);34 }35 }36
37 const transformer = isSelected ? (38 <Transformer39 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 <Text53 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}
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 height20}) {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 <EditableTextInput34 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 <ResizableText46 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}
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 <EditableText41 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}
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.
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.