Text editing in HTML5 canvas with Konva
Users cannot edit Konva.Text content directly for many reasons. The Canvas API is not designed for text editing.
It is possible to emulate text editing on canvas (by drawing blinking cursor, emulate selection, etc).
Konva has not support for such case. We recommend to edit the user input outside of your canvas with native DOM elements such as input or textarea.
If you want to enable full rich text editing features see Rich Text Demo.
Instructions: Double click on text to edit it. Type something. Press Enter or click outside to save changes.
- Vanilla
- React
- Vue
import Konva from 'konva';
const stage = new Konva.Stage({
container: 'container',
width: window.innerWidth,
height: window.innerHeight,
});
const layer = new Konva.Layer();
stage.add(layer);
const textNode = new Konva.Text({
text: 'Some text here',
x: 50,
y: 80,
fontSize: 20,
draggable: true,
width: 200,
});
layer.add(textNode);
const tr = new Konva.Transformer({
nodes: [textNode],
enabledAnchors: ['middle-left', 'middle-right'],
boundBoxFunc: function (oldBox, newBox) {
newBox.width = Math.max(30, newBox.width);
return newBox;
},
});
textNode.on('transform', function () {
textNode.setAttrs({
width: textNode.width() * textNode.scaleX(),
scaleX: 1,
});
});
layer.add(tr);
textNode.on('dblclick dbltap', () => {
textNode.hide();
tr.hide();
const textPosition = textNode.absolutePosition();
const stageBox = stage.container().getBoundingClientRect();
const areaPosition = {
x: stageBox.left + textPosition.x,
y: stageBox.top + textPosition.y,
};
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
textarea.value = textNode.text();
textarea.style.position = 'absolute';
textarea.style.top = areaPosition.y + 'px';
textarea.style.left = areaPosition.x + 'px';
textarea.style.width = textNode.width() - textNode.padding() * 2 + 'px';
textarea.style.height = textNode.height() - textNode.padding() * 2 + 5 + 'px';
textarea.style.fontSize = textNode.fontSize() + 'px';
textarea.style.border = 'none';
textarea.style.padding = '0px';
textarea.style.margin = '0px';
textarea.style.overflow = 'hidden';
textarea.style.background = 'none';
textarea.style.outline = 'none';
textarea.style.resize = 'none';
textarea.style.lineHeight = textNode.lineHeight().toString();
textarea.style.fontFamily = textNode.fontFamily();
textarea.style.transformOrigin = 'left top';
textarea.style.textAlign = textNode.align();
textarea.style.color = textNode.fill().toString();
const rotation = textNode.rotation();
let transform = '';
if (rotation) {
transform += 'rotateZ(' + rotation + 'deg)';
}
transform += 'translateY(-' + 2 + 'px)';
textarea.style.transform = transform;
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 3 + 'px';
textarea.focus();
function removeTextarea() {
textarea.parentNode.removeChild(textarea);
window.removeEventListener('click', handleOutsideClick);
window.removeEventListener('touchstart', handleOutsideClick);
textNode.show();
tr.show();
tr.forceUpdate();
}
function setTextareaWidth(newWidth = 0) {
if (!newWidth) {
newWidth = textNode.placeholder.length * textNode.fontSize();
}
textarea.style.width = newWidth + 'px';
}
textarea.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
textNode.text(textarea.value);
removeTextarea();
}
if (e.key === 'Escape') {
removeTextarea();
}
});
textarea.addEventListener('keydown', function () {
const scale = textNode.getAbsoluteScale().x;
setTextareaWidth(textNode.width() * scale);
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + textNode.fontSize() + 'px';
});
function handleOutsideClick(e) {
if (e.target !== textarea) {
textNode.text(textarea.value);
removeTextarea();
}
}
setTimeout(() => {
window.addEventListener('click', handleOutsideClick);
});
});
import { Stage, Layer, Text, Transformer } from "react-konva";
import { Html } from "react-konva-utils";
import { useEffect, useRef, useState, useCallback } from "react";
const TextArea = ({ textNode, onClose, onChange }) => {
const textareaRef = useRef(null);
useEffect(() => {
if (!textareaRef.current) return;
const textarea = textareaRef.current;
const stage = textNode.getStage();
const textPosition = textNode.position();
const stageBox = stage.container().getBoundingClientRect();
const areaPosition = {
x: textPosition.x,
y: textPosition.y,
};
// Match styles with the text node
textarea.value = textNode.text();
textarea.style.position = "absolute";
textarea.style.top = `${areaPosition.y}px`;
textarea.style.left = `${areaPosition.x}px`;
textarea.style.width = `${textNode.width() - textNode.padding() * 2}px`;
textarea.style.height = `${
textNode.height() - textNode.padding() * 2 + 5
}px`;
textarea.style.fontSize = `${textNode.fontSize()}px`;
textarea.style.border = "none";
textarea.style.padding = "0px";
textarea.style.margin = "0px";
textarea.style.overflow = "hidden";
textarea.style.background = "none";
textarea.style.outline = "none";
textarea.style.resize = "none";
textarea.style.lineHeight = textNode.lineHeight();
textarea.style.fontFamily = textNode.fontFamily();
textarea.style.transformOrigin = "left top";
textarea.style.textAlign = textNode.align();
textarea.style.color = textNode.fill();
const rotation = textNode.rotation();
let transform = "";
if (rotation) {
transform += `rotateZ(${rotation}deg)`;
}
textarea.style.transform = transform;
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight + 3}px`;
textarea.focus();
const handleOutsideClick = (e) => {
if (e.target !== textarea) {
onChange(textarea.value);
onClose();
}
};
// Add event listeners
const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onChange(textarea.value);
onClose();
}
if (e.key === "Escape") {
onClose();
}
};
const handleInput = () => {
const scale = textNode.getAbsoluteScale().x;
textarea.style.width = `${textNode.width() * scale}px`;
textarea.style.height = "auto";
textarea.style.height = `${
textarea.scrollHeight + textNode.fontSize()
}px`;
};
textarea.addEventListener("keydown", handleKeyDown);
textarea.addEventListener("input", handleInput);
setTimeout(() => {
window.addEventListener("click", handleOutsideClick);
});
return () => {
textarea.removeEventListener("keydown", handleKeyDown);
textarea.removeEventListener("input", handleInput);
window.removeEventListener("click", handleOutsideClick);
};
}, [textNode, onChange, onClose]);
return (
<textarea
ref={textareaRef}
style={{
minHeight: "1em",
position: "absolute",
}}
/>
);
};
const TextEditor = (props) => {
return (
<Html>
<TextArea {...props} />
</Html>
);
};
const EditableText = () => {
const [text, setText] = useState("Some text here");
const [isEditing, setIsEditing] = useState(false);
const [textWidth, setTextWidth] = useState(200);
const textRef = useRef();
const trRef = useRef();
useEffect(() => {
if (trRef.current && textRef.current) {
trRef.current.nodes([textRef.current]);
}
}, [isEditing]);
const handleTextDblClick = useCallback(() => {
setIsEditing(true);
}, []);
const handleTextChange = useCallback((newText) => {
setText(newText);
}, []);
const handleTransform = useCallback((e) => {
const node = textRef.current;
const scaleX = node.scaleX();
const newWidth = node.width() * scaleX;
setTextWidth(newWidth);
node.setAttrs({
width: newWidth,
scaleX: 1,
});
}, []);
return (
<Stage width={window.innerWidth} height={window.innerHeight}>
<Layer>
<Text
ref={textRef}
text={text}
x={50}
y={80}
fontSize={20}
draggable
width={textWidth}
onDblClick={handleTextDblClick}
onDblTap={handleTextDblClick}
onTransform={handleTransform}
visible={!isEditing}
/>
{isEditing && (
<TextEditor
textNode={textRef.current}
onChange={handleTextChange}
onClose={() => setIsEditing(false)}
/>
)}
{!isEditing && (
<Transformer
ref={trRef}
enabledAnchors={["middle-left", "middle-right"]}
boundBoxFunc={(oldBox, newBox) => ({
...newBox,
width: Math.max(30, newBox.width),
})}
/>
)}
</Layer>
</Stage>
);
};
export default EditableText;
<template>
<v-stage :config="stageSize">
<v-layer>
<v-text
ref="textNode"
:config="{
text: text,
x: 50,
y: 80,
fontSize: 20,
draggable: true,
width: textWidth,
visible: !isEditing,
}"
@dblclick="handleTextDblClick"
@dbltap="handleTextDblClick"
@transform="handleTransform"
/>
<v-transformer
v-if="!isEditing"
ref="transformerNode"
:config="{
enabledAnchors: ['middle-left', 'middle-right'],
boundBoxFunc: (oldBox, newBox) => {
newBox.width = Math.max(30, newBox.width);
return newBox;
},
}"
/>
</v-layer>
</v-stage>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const stageSize = {
width: window.innerWidth,
height: window.innerHeight
};
const text = ref('Some text here');
const textWidth = ref(200);
const isEditing = ref(false);
const textNode = ref(null);
const transformerNode = ref(null);
onMounted(() => {
transformerNode.value.getNode().nodes([textNode.value.getNode()]);
});
const handleTextDblClick = () => {
const textNodeKonva = textNode.value.getNode();
const stage = textNodeKonva.getStage();
const textPosition = textNodeKonva.absolutePosition();
const stageBox = stage.container().getBoundingClientRect();
const areaPosition = {
x: stageBox.left + textPosition.x,
y: stageBox.top + textPosition.y,
};
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
textarea.value = textNodeKonva.text();
textarea.style.position = 'absolute';
textarea.style.top = areaPosition.y + 'px';
textarea.style.left = areaPosition.x + 'px';
textarea.style.width = textNodeKonva.width() - textNodeKonva.padding() * 2 + 'px';
textarea.style.height = textNodeKonva.height() - textNodeKonva.padding() * 2 + 5 + 'px';
textarea.style.fontSize = textNodeKonva.fontSize() + 'px';
textarea.style.border = 'none';
textarea.style.padding = '0px';
textarea.style.margin = '0px';
textarea.style.overflow = 'hidden';
textarea.style.background = 'none';
textarea.style.outline = 'none';
textarea.style.resize = 'none';
textarea.style.lineHeight = textNodeKonva.lineHeight();
textarea.style.fontFamily = textNodeKonva.fontFamily();
textarea.style.transformOrigin = 'left top';
textarea.style.textAlign = textNodeKonva.align();
textarea.style.color = textNodeKonva.fill();
const rotation = textNodeKonva.rotation();
let transform = '';
if (rotation) {
transform += 'rotateZ(' + rotation + 'deg)';
}
textarea.style.transform = transform;
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 3 + 'px';
isEditing.value = true;
textarea.focus();
function removeTextarea() {
textarea.parentNode.removeChild(textarea);
window.removeEventListener('click', handleOutsideClick);
isEditing.value = false;
}
function setTextareaWidth(newWidth) {
if (!newWidth) {
newWidth = textNodeKonva.placeholder?.length * textNodeKonva.fontSize();
}
textarea.style.width = newWidth + 'px';
}
textarea.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
text.value = textarea.value;
removeTextarea();
}
if (e.key === 'Escape') {
removeTextarea();
}
});
textarea.addEventListener('keydown', function () {
const scale = textNodeKonva.getAbsoluteScale().x;
setTextareaWidth(textNodeKonva.width() * scale);
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + textNodeKonva.fontSize() + 'px';
});
function handleOutsideClick(e) {
if (e.target !== textarea) {
text.value = textarea.value;
removeTextarea();
}
}
setTimeout(() => {
window.addEventListener('click', handleOutsideClick);
window.addEventListener('touchstart', handleOutsideClick);
});
};
const handleTransform = (e) => {
const node = textNode.value.getNode();
textWidth.value = node.width() * node.scaleX();
node.setAttrs({
width: node.width() * node.scaleX(),
scaleX: 1,
});
};
</script>
What a textarea overlay does not cover
The method above works well when the visitor edits one field at a time. It stops being sufficient as soon as the text has to behave like a document:
- Reflow. The overlay does not re-wrap while the box is resized, so the editing view and the drawn view disagree during a transform.
- Font metrics. A web font that loads after the first paint changes the line height, so cached measurements and the caret position go stale.
- Per-character styling.
Konva.Textapplies one style to the whole node. Mixed bold, colour, or size inside one paragraph needs several nodes and your own layout pass. - History. Each keystroke has to be folded into the same undo stack as the canvas operations, or undo will jump between two separate timelines.
- Export fidelity. The exported image is drawn by the canvas, not by the textarea, so any difference between the two shows up in the output.
At that point you are writing a text engine rather than an editor feature. If you would rather not, Polotno is a commercial design editor SDK built with Konva that already solves this.