使用 Konva 在 HTML5 Canvas 中编辑文本
由于多种原因,用户无法直接编辑 Konva.Text 内容。Canvas API 的设计目的不是编辑文本。
可以在 Canvas 上模拟文本编辑,例如绘制闪烁光标和模拟选择。
Konva 不支持此功能。建议使用 input 或 textarea 等原生 DOM 元素,在 Canvas 外部编辑用户输入。
如果要启用完整的富文本编辑功能,请参阅富文本示例。
操作说明:双击文本进行编辑。输入内容。按 Enter 或在外部单击以保存更改。
- 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>
textarea 叠加层无法处理的内容
当访问者一次编辑一个字段时,上述方法效果良好。如果文本需要像文档一样工作, 此方法就不再适用:
- **重排。**调整文本框大小时,叠加层不会重新换行。因此,在变换过程中, 编辑视图与绘制视图不一致。
- **字体度量。**如果 Web 字体在首次绘制后加载,行高会发生变化。因此, 缓存的度量数据和插入符位置会失效。
- 单字符样式。
Konva.Text对整个节点应用一种样式。 要在一个段落中混合使用粗体、颜色或字号,需要多个节点和自定义 布局过程。 - **历史记录。**必须将每次按键操作合并到 Canvas 操作使用的同一个撤销堆栈中。 否则,撤销会在两个不同的时间线之间跳转。
- **导出保真度。**导出的图像由 Canvas 而不是 textarea 绘制。 因此,两者之间的任何差异都会出现在输出中。
此时,你实际上是在编写文本引擎,而不是编辑器功能。如果不想自行编写,可以使用 Polotno。这是一个使用 Konva 构建的商业设计编辑器 SDK,并且已经解决这些问题。