好友
阅读权限10
听众
最后登录1970-1-1
|
本帖最后由 wonder2018 于 2024-11-26 15:34 编辑
Canvas 实现的纯文本编辑器
这是一个基于使用 canvas 实现的简单文本编辑器,具有以下特性。
- 支持中英文输入。
- 能够正常显示中文输入法的拼音提示。
- 能够使用鼠跨行标选中一些文本并高亮。
- 能够显示文本插入点光标。
- 能够在行尾自动换行
关键技术
-
主体框架
示例将字符串拆成单个文字进行存储,这样方便为每个字符设置不同的属性。渲染时将画布划分成网格,每个文字根据索引填入对应的网格中,使用帧动画来循环检查每个字符是否需要重新渲染,只重绘发送变化的部分。
-
实现中文输入的方法
canvas 本身无法唤起中文输入法,也不支持 CompositionEvent ,因此在页面中嵌入一个 textarea 来完成事件监听部分。通过 pointer-events 使它不再处理鼠标事件来让 canvas 能够正常获取鼠标位置并处理后续文本选择操作。
-
实现中文输入法拼音提示的方法
使用一组 CompositionEvent 来获取拼音提示的部分。需要注意的是,一些网络文章推荐使用 change 事件与 CompositionEvent 结合使用。但这样会造成英文模式下无法捕获每个字母的输入事件。因此本示例使用 input 事件代替 change。虽然这样会带来许多事件重复,但基于 js 单线程运行的特性,可以将每次输入的文本缓存起来,触发事件时如果发现文本没变就直接返回。以此可以减少重复事件带来的影响。
document.body.addEventListener("click", () => textarea.focus());
textarea.addEventListener("input", (e) => resetText(e.target.value));
textarea.addEventListener("compositionstart", (e) => resetText(e.target.value));
textarea.addEventListener("compositionupdate", (e) => resetText(e.target.value));
textarea.addEventListener("compositionend", (e) => resetText(e.target.value));
/** @Param {string} str*/
const charSeq = [];
let currentText = "";
function resetText(str) {
if (str == currentText) return;
currentText = str;
charSeq.length = str.length;
startChar = null;
endChar = null;
str.split("").forEach((i, ind) => {
const charObj = charSeq[ind] || { char: i, changed: true, selected: false };
if (charObj.char == i) return (charSeq[ind] = charObj);
charObj.char = i;
charObj.changed = true;
});
}
一些缺陷
- 由于主体渲染逻辑的设计问题,目前暂未实现主动换行的逻辑。并且也未为西文字符设计布局方式。所有字符均按照中文字符进行排列。
- 暂未实现在文本中间插入字符和替换字符的功能。后续可以考虑使用 H5 的 selection api 和 range api 实现将 textarea 的光标以及选区和 canvas 同步。
- 暂未实现横向滚动条和纵向滚动条
完整代码
在桌面新建 txt 文件,将代码粘贴进去,保存为 utf8 格式文本,然后将扩展名改为 html 即可在浏览器中使用。
<!DOCTYPE html>
<html lang="zh_CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="author" content="wonder2018" />
<title>canvas editor demo</title>
<style>
html,
body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
body {
position: relative;
}
.editor {
position: absolute;
margin: auto;
left: 0;
bottom: 0;
right: 0;
top: 0;
padding: 15px;
border: 1px solid #000;
border-radius: 8px;
overflow: hidden;
}
.editor > canvas,
.editor > textarea {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
margin: auto;
cursor: text;
}
.editor > textarea {
opacity: 0;
pointer-events: none;
user-select: none;
}
</style>
</head>
<body>
<div class="editor">
<canvas></canvas>
<textarea></textarea>
</div>
</body>
<script>
const wrap = document.querySelector("div.editor");
const textarea = document.querySelector("textarea");
const cvs = document.querySelector("canvas");
const ctx = cvs.getContext("2d");
const width = 800;
const height = 600;
const bgc = "#fff";
const color = "#000";
const selectedBgc = "#000";
const selectedColor = "#fff";
const fontSize = 16;
const lineHeight = 20;
const charPadding = [Math.floor((lineHeight - fontSize) / 2), 0];
const charWidth = fontSize + charPadding[1] * 2;
const cursorPadding = [Math.floor((lineHeight - fontSize) / 2), 2];
const cursorWidth = 2;
const cursorHeight = lineHeight - cursorPadding[0] * 2;
const charSeq = [];
let currentText = "";
let lastTextLength = charSeq.length;
let isCursorShow = false;
let isPrintCursor = true;
let isSelecting = false;
let startChar = null;
let endChar = null;
initCvs();
initEvent();
startRender();
setInterval(() => (isPrintCursor = !isPrintCursor), 500);
function initCvs() {
cvs.width = width;
cvs.height = height;
cvs.style.width = `${width}px`;
cvs.style.height = `${height}px`;
wrap.style.width = `${width}px`;
wrap.style.height = `${height}px`;
textarea.style.width = `${width}px`;
textarea.style.height = `${height}px`;
textarea.style.fontSize = `${fontSize}px`;
textarea.focus();
}
function initEvent() {
document.body.addEventListener("click", () => textarea.focus());
textarea.addEventListener("input", (e) => resetText(e.target.value));
textarea.addEventListener("compositionstart", (e) => resetText(e.target.value));
textarea.addEventListener("compositionupdate", (e) => resetText(e.target.value));
textarea.addEventListener("compositionend", (e) => resetText(e.target.value));
cvs.addEventListener("mousedown", (e) => startSelect(e));
cvs.addEventListener("mousemove", (e) => endSelect(e, false));
cvs.addEventListener("mouseup", (e) => endSelect(e, true));
cvs.addEventListener("mouseleave", (e) => endSelect(e, true));
}
/** @param {string} str*/
function resetText(str) {
if (str == currentText) return;
currentText = str;
charSeq.length = str.length;
startChar = null;
endChar = null;
str.split("").forEach((i, ind) => {
const charObj = charSeq[ind] || { char: i, changed: true, selected: false };
if (charObj.char == i) return (charSeq[ind] = charObj);
charObj.char = i;
charObj.changed = true;
});
}
/** @param {MouseEvent} e*/
function startSelect(e) {
e.stopPropagation();
e.preventDefault();
isSelecting = true;
startChar = pointerCharInd(e.offsetX, e.offsetY);
endChar = null;
}
/** @param {MouseEvent} e*/
function endSelect(e, stop) {
if (!isSelecting) return;
e.stopPropagation();
e.preventDefault();
isSelecting = !stop;
endChar = pointerCharInd(e.offsetX, e.offsetY);
console.log({ startChar, endChar });
}
function pointerCharInd(x, y) {
const row = Math.floor(Math.min(Math.max(y, 0), height) / lineHeight);
const col = Math.floor(Math.min(Math.max(x, 0), width) / charWidth);
const letterCount = letterCountPerLine();
return row * letterCount + col;
}
function startRender() {
renderFrame();
return requestAnimationFrame(startRender);
}
function renderFrame() {
toggleCursor();
if (!isChanged()) return;
eraseChar(lastTextLength);
isCursorShow = false;
for (let i = 0; i < lastTextLength; i++) {
printChar(charSeq[i], i);
}
lastTextLength = charSeq.length;
return;
}
function toggleCursor() {
const { row, col } = getPosition(lastTextLength);
const isSelected = startChar != null && endChar != null && Math.min(startChar, endChar) <= lastTextLength;
if (!isPrintCursor || isSelected) {
isCursorShow = false;
eraseChar(lastTextLength);
return;
}
printCursor(lastTextLength);
}
function getPosition(ind) {
const letterCount = letterCountPerLine();
const row = Math.floor(ind / letterCount);
const col = ind % letterCount;
return { row, col };
}
function letterCountPerLine() {
return Math.floor(width / fontSize) || 1;
}
function eraseChar(ind) {
const { row, col } = getPosition(ind);
ctx.beginPath();
ctx.fillStyle = bgc;
ctx.fillRect(col * charWidth, row * lineHeight, charWidth, lineHeight);
ctx.closePath();
}
function printCursor(ind) {
if (!isPrintCursor) return;
const { row, col } = getPosition(ind);
isCursorShow = true;
ctx.beginPath();
ctx.fillStyle = color;
ctx.fillRect(col * charWidth + cursorPadding[1], row * lineHeight + cursorPadding[0], cursorWidth, cursorHeight);
ctx.closePath();
}
function printChar(charObj, ind) {
const isSelected = isIndSelected(ind);
if (charObj && !charObj.changed && charObj.selected == isSelected) return;
if (!charObj) return eraseChar(ind);
charObj.changed = false;
charObj.selected = isSelected;
const { row, col } = getPosition(ind);
ctx.beginPath();
ctx.fillStyle = isSelected ? selectedBgc : bgc;
ctx.fillRect(col * charWidth, row * lineHeight, charWidth, lineHeight);
ctx.closePath();
ctx.beginPath();
ctx.fillStyle = isSelected ? selectedColor : color;
ctx.font = `${fontSize}px Microsoft`;
ctx.textBaseline = "middle";
ctx.fillText(charObj.char, col * charWidth + charPadding[1], row * lineHeight + charPadding[0] + Math.floor(fontSize / 2));
ctx.closePath();
}
function isIndSelected(ind) {
const selectMin = Math.min(startChar, endChar);
const selectMax = Math.max(startChar, endChar);
return startChar != null && endChar != null && selectMin <= ind && selectMax >= ind;
}
function isChanged() {
if (lastTextLength != charSeq.length) return true;
if (charSeq.some((i) => i.changed)) return true;
if (charSeq.some((i, ind) => i.selected != isIndSelected(ind))) return true;
return false;
}
</script>
</html>
|
|