Text editing in HTML5 canvas with Konva
User can't directly edit Konva.Text
content for many reasons. In fact canvas API is not designed for such purpose.
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'; Konva._fixTextRendering = true; 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({ node: 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(); 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)'; } 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); textNode.show(); tr.show(); tr.forceUpdate(); } function setTextareaWidth(newWidth) { if (!newWidth) { newWidth = textNode.placeholder.length * textNode.fontSize(); } textarea.style.width = newWidth + 'px'; } textarea.addEventListener('keydown', function (e) { if (e.keyCode === 13 && !e.shiftKey) { textNode.text(textarea.value); removeTextarea(); } if (e.keyCode === 27) { 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'; Konva._fixTextRendering = true; const TextEditor = ({ 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 ( <Html> <textarea ref={textareaRef} style={{ minHeight: '1em', position: 'absolute', }} /> </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]); trRef.current.getLayer().batchDraw(); } }, [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} /> {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, }" @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'; Konva._fixTextRendering = true; 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()]); transformerNode.value.getNode().getLayer().batchDraw(); }); 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)'; } transform += 'translateY(-' + 2 + 'px)'; 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.keyCode === 13 && !e.shiftKey) { text.value = textarea.value; removeTextarea(); } if (e.keyCode === 27) { 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); }); }; const handleTransform = (e) => { const node = textNode.value.getNode(); textWidth.value = node.width() * node.scaleX(); node.setAttrs({ width: node.width() * node.scaleX(), scaleX: 1, }); }; </script>