在 HTML5 canvas 中使用 Konva 进行文本编辑
用户无法直接编辑 Konva.Text
内容,原因有很多。实际上,canvas API 并不为此目的而设计。
可以在 canvas 上模拟文本编辑(通过绘制闪烁光标、模拟选择等)。
Konva 并不支持这种情况。我们建议使用本地 DOM 元素(如 input
或 textarea
)在 canvas 外部编辑用户输入。
如果您想启用完整的富文本编辑功能,请参见 富文本演示。
说明:双击文本以编辑。输入内容。按 Enter 或点击外部以保存更改。
- 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: '一些文本', 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().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'; 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, }; // 匹配文本节点的样式 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(); } }; // 添加事件监听器 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('一些文本'); 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'; Konva._fixTextRendering = true; const stageSize = { width: window.innerWidth, height: window.innerHeight }; const text = ref('一些文本'); 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>