Canvas无法直接响应鼠标事件或文本选中,必须用隐藏contenteditable DOM元素承载编辑逻辑并同步排版,通过手动分词、逐字测量、坐标映射与预渲染波浪线纹理实现高性能错词标注。

Canvas 无法直接响应鼠标事件或文本选中
Canvas 是位图绘制环境,不维护 DOM 节点、文本布局或光标位置信息。你不能像在 <textarea> 或 contenteditable 元素里那样监听 input、selectionchange 或获取 window.getSelection()。所有拼写检查、词边界计算、坐标映射都得手动做。
实际可行路径只有一条:把 Canvas 当作“只读画布”,背后用一个隐藏的、同步排版的 DOM 元素(比如 <div contenteditable="true">)承载原始文本和编辑逻辑,再用 Canvas 叠加波浪线——两者通过文本内容、字体设置、行高、字号严格对齐。
- 必须监听隐藏 DOM 元素的
input和scroll事件,触发 Canvas 重绘 - 字体测量要用
ctx.measureText()+ 实际渲染字体(不能假设monospace就等宽) - 中文、英文混排时,
measureText()返回的宽度不可直接用于字符级定位,需拆字+逐个测
如何把错词位置映射到 Canvas 坐标
核心是复现 DOM 的文本流布局。不能依赖 getBoundingClientRect(),因为 Canvas 是绝对定位覆盖层,且 DOM 元素需设为 position: absolute; pointer-events: none; 避免遮挡编辑。
步骤如下:
立即学习“前端免费学习笔记(深入)”;
- 将隐藏
contenteditable元素设为与 Canvas 完全重叠(相同left/top/width/height,父容器position: relative) - 用
Range.getClientRects()获取每个词的矩形区域,但注意:它只对可见 DOM 有效;若文本换行/缩放/字体加载延迟,结果会偏移 - 更稳的方式是手动分词 + 逐行模拟排版:按空格/标点切词 → 用
ctx.measureText(word).width累加横坐标 → 遇到超宽换行 → 记录每行起始 Y 坐标 - 拼写检查库(如
typo-js)返回的是字符索引,你要用字符串.substring()截取前缀并累加其渲染宽度,才能得到左边缘 X
波浪线绘制性能很敏感,别用 beginPath() + quadraticCurveTo() 逐像素画
每个错词下画一条 8px 高、带 2px 振幅的正弦波式波浪线,若每帧都重算路径并 stroke,10 个错词就可能触发 60fps 掉帧。尤其在输入频繁时,requestAnimationFrame 回调里做太多路径操作会卡顿。
优化方案:
- 把波浪线预生成为
<canvas>纹理(比如 128×16 的离屏 canvas),只含一个周期波浪,然后用ctx.drawImage()平铺绘制 - 错词范围转成整数像素后,用
Math.floor(x)对齐纹理起点,避免 subpixel 渲染模糊 - 仅当错词列表变化(非每次 input)或视口滚动时才重绘 Canvas;DOM 元素的
scrollLeft/scrollTop改变时,只平移整个 Canvasctx.translate(),不重绘 - 禁用抗锯齿:
ctx.imageSmoothingEnabled = false,否则波浪线边缘发虚
浏览器原生拼写检查会干扰,必须关掉
Chrome/Firefox 对 contenteditable 有内置红波浪线,它和你的 Canvas 波浪线会重叠、不同步、甚至互相遮挡。必须显式关闭:
- 给隐藏 DOM 元素加 CSS:
spellcheck="false"属性 - 同时加样式:
-webkit-text-decoration-color: transparent; text-decoration: wavy red;不够,得靠caret-color: transparent+outline: none避免焦点样式干扰 - 某些版本 Safari 仍会漏触发,可在
input事件里加el.style.textDecoration = "none"强制清空
真正难的是中英文混合场景下的“词”定义——typo-js 默认按空格切分,但中文没有空格。你需要先用 segmentit 或正则 /[\u4e00-\u9fa5]+|[a-zA-Z]+/g 分词,再送入拼写检查,否则“你好world”会被当一个词漏检。


















