JEPA4Japan · 教程

第 11 章:画出来的文字,不等于可编辑文字

5,316字 14分钟阅读 #Canvas#前端工程#无限画布#通俗讲解

让 Canvas 负责预览、DOM 负责编辑,并正确处理字体、换行、字素、双向文本、输入法、光标与选择区。

课程进度 课程大纲 已发布 18/18 课

从一个五岁孩子也能理解的游戏开始

本章唯一的真理:文本渲染和文本编辑是两个完全不同的系统。

在一张纸上写下“Hello 👨‍👩‍👧‍👦”,拍一张照片,然后把原纸和照片都交给一个孩子。两者都显示了这些文字。请孩子在“e”和“l”之间放置插入符,选择“Hello”,使用日语输入法将读音转换为汉字,并让阅读器朗读文本。

先预测会发生什么。这些任务中,哪些是照片能够完成的?如果程序在每次 keydown 时都向字符串末尾追加一个字符,那么在中文拼音组字期间会发生什么?按下退格键删除家庭 Emoji 时,应该移除其中一个人,还是整个家庭符号?

  1. 正常查看照片速度快,并随图片一起移动
  2. 切换到原纸进行编辑插入符、选择、语言输入
  3. 精确对齐原纸位置、缩放、旋转
  4. 完成后拍摄新照片提交或取消
先回答:如果文本可见,浏览器是否就知道每个可编辑位置?不知道。像素没有插入符、选择或语言输入协议。

Canvas fillText() 就像把字母拍进一张图片。textarea 或 contenteditable 元素就像浏览器能够理解的原纸。未处于编辑状态时,渲染器绘制文本形状。进入编辑状态后,由 DOM 覆盖层接管。提交时,文档会更新,渲染器会重新绘制。取消时,覆盖层会被丢弃,原始值保持不变。

将这些玩具映射到 Canvas

玩具世界文本系统负责内容
文本的照片Canvas 预览随场景快速渲染显示
可书写的原纸textarea / contenteditable插入符、选择、剪贴板、IME、拼写检查
活字盒FontFace / document.fonts字体加载和就绪通知
字母尺TextMetrics / DOM 测量宽度、基线、实际边界和换行
一个“可见字符”字素簇对 Emoji 和组合字符执行用户操作的单位
从右向左书写的书Bidi / RTL文本算法处理视觉顺序和逻辑顺序
输入法候选卡组字事件组字期间的值不是最终的文档值
覆盖在纸上的透明胶带DOM 覆盖层变换与形状/相机对齐,并补偿缩放
写完或撕掉提交/取消一个历史事务或零次写入

这个类比有其局限。DOM 覆盖层和 Canvas 并不使用同一个布局引擎。即使字体字符串完全相同,换行、字体回退、微调和基线也可能相差若干像素。字素是用户感知的单位,不一定是语言学意义上的字符或单词。contenteditable 提供了丰富的浏览器行为,但它不是安全的富文本数据模型;粘贴的 HTML 仍然需要清理和规范化。

先消除错误直觉

  • “Canvas 能绘制文本,所以我们也能绘制插入符。” 你仍然必须重建选择、IME、Bidi、剪贴板、虚拟键盘、辅助技术和平台惯例。其成本远不止绘制一个插入符。
  • “在 keydown 时追加 event.key,可以获得最大的控制力。” 组字会产生中间按键,一个 Emoji 可能包含多个码点,而且选择位置不一定在末尾。
  • “text.length 就是字符数。” UTF-16 代码单元、Unicode 码点和字素簇各不相同。一个家庭 Emoji 的长度远大于 1。
  • “字体 URL 加载完成后,就可以测量文本了。” 字体可能尚未解码,而且回退字体可能已经参与了布局。应等待 FontFaceSet,并在字体变化后使缓存失效。
  • “Canvas measureText 会自动换行。” 它只测量所提供的行内文本。段落布局或 DOM 测量必须负责换行。
  • “将 textarea 放在形状的 x/y 坐标处就能对齐。” 此时仍缺少父级、世界和相机矩阵、旋转、变换原点、内边距、行高以及缩放补偿。
  • “将富文本存储为 innerHTML。” 这会把不受信任的 HTML、浏览器私有标记和业务模型混在一起。

生产级工具包

前置条件契约

第 4 章提供形状从局部→世界→屏幕的矩阵。第 6 章提供带有对称清理机制的 DOMOverlayLayer Portal。第 9 章包含 editing 状态以及提交/取消。第 10 章防止选择和调整大小在文本编辑期间抢夺输入。文档中的文本形状存储文本、宽度、样式令牌和方向,而不是 DOM 节点。

正式知识

字体加载不仅仅是下载文件。FontFace 表示一种可加载字体,而 document.fonts 是一个 FontFaceSet。在布局或测量之前,可以调用 await document.fonts.load('16px "Canvas Sans"', sample),并在 loadingdone 之后使缓存失效。失败时,应使用明确的字体回退链并记录失败,同时不要阻塞编辑。当回退字体切换为目标字体时,文本度量、换行、形状高度和连接线锚点都可能发生变化。应重新计算派生布局,而不是悄然更改文档内容。

Canvas measureText() 返回 TextMetrics。除宽度之外,一些浏览器还会公开实际边界框、字体边界框以及与基线相关的值。textBaseline 选择一条绘制锚线;它不是 CSS 行框。基础文本渲染会显式设置字体、方向、textAlign 和基线,然后对每一行已布局的文本调用 fillText()。Canvas 没有段落换行 API。简单的文本形状可以按确定的宽度断行。对于生产级富文本,更适合在隐藏 DOM 或成熟的布局引擎中进行权威测量,并将测量版本纳入缓存键。

断行并不是在空格处执行 split。CJK 文字不要求使用空格,而且标点禁则、软连字符和长单词都需要相应规则。换行需要区分硬换行 \n 和软换行。Intl.Segmenter(...,{granularity:'grapheme'}) 可以避免拆分大多数​​字素簇。如果目标浏览器矩阵不支持它,应随应用打包一个经过 Unicode 测试夹具验证的分词库;Array.from(string) 拆分的是码点,不能作为字素处理的回退方案。要实现完整的 Unicode 断行,还需要针对产品所支持语言的断行库或 DOM 布局。应将 Emoji、组合标记、肤色修饰符和 ZWJ 序列作为用户感知的单位进行测试。

Bidi 算法决定 LTR 和 RTL 混合文本的视觉排列方式。文档存储逻辑字符串,不会将字符重新排列为屏幕顺序。形状具有 direction: auto|ltr|rtl;覆盖层使用 dir 和 CSS direction,而 Canvas 则设置上下文的 direction。绝不要手动反转阿拉伯文。字体回退可能按字素发生,因此测量必须使用与渲染相同的 CSS 字体简写属性。

IME 使用 compositionstart/update/end 与 beforeinput/input 协同工作。组字期间 DOM 值可能发生变化,但每个中间步骤都不得分别创建历史记录。compositionend 也不是唯一可能的提交时机;编辑事务结束时,提交操作使用覆盖层的当前值。keydown 只处理 Escape 和显式快捷键等控制意图,并在组字期间检查 event.isComposing。将插入符、选择、局部撤销栈和剪贴板交给原生控件。需要明确文本形状撤销与控件内部撤销之间的边界:例如,编辑期间 Ctrl/Cmd+Z 由控件处理,只有退出编辑后才交给文档历史记录处理。

textarea 适合纯文本:它稳定,并原生支持选择、IME 和移动端键盘。contenteditable 适合富文本,但需要规范化模型、粘贴内容清理、选择映射以及浏览器矩阵测试。应根据产品和隐私要求选择是否启用拼写检查、自动纠正和自动补全;绝不要将用户字符串发送到自己的遥测系统。屏幕阅读器需要可访问名称、编辑状态和焦点。在编辑状态之外,应提供 DOM 检查器或回退语义,而不是只暴露“Canvas”。

当文本区域覆盖层打开时,冻结原始值,创建 DOM,设置 value/dir/lang/spellcheck,应用完整的屏幕矩阵,聚焦,并恢复选区。每当相机或父级变换发生变化时,都要更新矩阵;仅在创建时对齐是不够的。支持缩放补偿的编辑器可以按屏幕缩放比例增大 DOM 字体大小,也可以使用矩阵变换其外层元素。无论采用哪种方式,DOM 的换行宽度都必须与 Canvas 布局的世界宽度相对应。应使用 CSS 矩阵应用旋转,而不能只旋转位置。提交时读取控件值,验证最大长度和富文本模式,然后发出一条 UpdateText 命令。除非文档模式明确规定且产品已向用户说明,否则不要在此处暗中应用 NFC/NFKC 规范化,因为规范化会改变实际的码点序列。取消不发出任何命令。退出时移除监听器和节点,然后将焦点映射回 Canvas 或检查器。

证据与兼容性(已于 2026-08-29 验证)

本章的工程增量

**起点:**文本标签仅使用 Canvas fillText(),没有插入光标、IME 或剪贴板。**终点:**非编辑状态下使用 Canvas 预览;编辑期间由 DOM 覆盖层精确覆盖文本形状。提交只更新文档一次,而取消执行零次写入。

添加以下文件:

  • src/engine/text/TextLayout.ts:字体缓存、字素安全换行和基线;
  • src/engine/text/FontManager.ts:FontFaceSet 就绪状态和失效处理;
  • src/ui/overlays/TextEditorOverlay.ts:textarea 生命周期和矩阵;
  • src/engine/shapes/TextShapeRenderer.ts:根据布局行绘制预览;
  • src/engine/text/__tests__/layout.test.ts:中日韩文字、Emoji、RTL 和回退字体;
  • tests/browser/text-ime.spec.ts:组合输入、选区、剪贴板、旋转和缩放。

先实现一个完整的纯函数,以便测试基础布局。它遵循硬换行、字素和最大宽度约束,并且不会使用 split('') 拆散 Emoji:

export type Measure = (text: string) => number;
export type TextLine = Readonly<{ text: string; width: number }>;

export function wrapGraphemes(
  value: string,
  maxWidth: number,
  measure: Measure,
  locale = 'und',
): readonly TextLine[] {
  if (!(maxWidth > 0)) throw new Error('TEXT_WIDTH_MUST_BE_POSITIVE');
  const segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
  const output: TextLine[] = [];
  for (const paragraph of value.split('\n')) {
    const graphemes = Array.from(segmenter.segment(paragraph), (part) => part.segment);
    if (graphemes.length === 0) {
      output.push({ text: '', width: 0 });
      continue;
    }
    let line = '',
      width = 0;
    for (const grapheme of graphemes) {
      const candidate = line + grapheme;
      const candidateWidth = measure(candidate);
      if (line && candidateWidth > maxWidth) {
        output.push({ text: line, width });
        line = grapheme;
        width = measure(grapheme);
      } else {
        line = candidate;
        width = candidateWidth;
      }
    }
    output.push({ text: line, width });
  }
  return output;
}

export async function layoutCanvasText(
  context: CanvasRenderingContext2D,
  value: string,
  cssFont: string,
  width: number,
  locale: string,
  onFontFailure: (reason: unknown) => void = () => undefined,
): Promise<readonly TextLine[]> {
  try {
    const faces = await document.fonts.load(cssFont, value || 'M');
    if (faces.length === 0) onFontFailure(new Error(`no face matched ${cssFont}`));
  } catch (cause) {
    onFontFailure(cause);
  }
  context.save();
  try {
    context.font = cssFont;
    return wrapGraphemes(value, width, (text) => context.measureText(text).width, locale);
  } finally {
    context.restore();
  }
}

下面这个完整的纯文本覆盖层不会在 keydown 时监听普通字符,不会在组合输入期间写入文档,并且会将形状→屏幕矩阵直接传给 CSS:

export type TextShape = Readonly<{
  id: string;
  text: string;
  width: number;
  minHeight: number;
  font: string;
  fontSize: number;
  lineHeight: number;
  color: string;
  direction: 'auto' | 'ltr' | 'rtl';
  language: string;
}>;
export type Matrix2D = Readonly<{
  a: number;
  b: number;
  c: number;
  d: number;
  e: number;
  f: number;
}>;
export interface TextEditPort {
  commit(id: string, before: string, after: string): void;
  focusCanvas(): void;
}

export class TextEditorOverlay {
  private readonly textarea: HTMLTextAreaElement;
  private composing = false;
  private blurPending = false;
  private closed = false;

  constructor(
    host: HTMLElement,
    private readonly shape: TextShape,
    screenMatrix: Matrix2D,
    private readonly port: TextEditPort,
  ) {
    const area = document.createElement('textarea');
    this.textarea = area;
    area.value = shape.text;
    area.dir = shape.direction;
    area.lang = shape.language;
    area.spellcheck = true;
    area.setAttribute('aria-label', 'Edit canvas text');
    area.style.position = 'absolute';
    area.style.left = '0';
    area.style.top = '0';
    area.style.width = `${shape.width}px`;
    area.style.minHeight = `${shape.minHeight}px`;
    area.style.margin = '0';
    area.style.padding = '0';
    area.style.border = '1px solid currentColor';
    area.style.background = 'Canvas';
    area.style.color = shape.color;
    area.style.font = shape.font;
    area.style.lineHeight = String(shape.lineHeight);
    area.style.resize = 'none';
    area.style.transformOrigin = '0 0';
    this.updateScreenMatrix(screenMatrix);
    area.addEventListener('compositionstart', this.onCompositionStart);
    area.addEventListener('compositionend', this.onCompositionEnd);
    area.addEventListener('keydown', this.onKeyDown);
    area.addEventListener('blur', this.onBlur);
    host.append(area);
    area.focus({ preventScroll: true });
    area.setSelectionRange(area.value.length, area.value.length);
  }

  private onCompositionStart = () => {
    this.composing = true;
  };
  private onCompositionEnd = () => {
    this.composing = false;
    if (this.blurPending) this.commit();
  };
  private onKeyDown = (event: KeyboardEvent) => {
    if (event.isComposing || this.composing) return;
    if (event.key === 'Escape') {
      event.preventDefault();
      this.cancel();
    }
    if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
      event.preventDefault();
      this.commit();
    }
  };
  private onBlur = () => {
    if (this.closed) return;
    if (this.composing) {
      this.blurPending = true;
      return;
    }
    this.commit();
  };

  updateScreenMatrix(matrix: Matrix2D) {
    if (this.closed) return;
    this.textarea.style.transform = `matrix(${matrix.a},${matrix.b},${matrix.c},${matrix.d},${matrix.e},${matrix.f})`;
  }

  commit() {
    if (this.closed) return;
    if (this.composing) {
      this.blurPending = true;
      return;
    }
    this.blurPending = false;
    const after = this.textarea.value;
    if (new TextEncoder().encode(after).byteLength > 100_000) {
      this.textarea.setCustomValidity('Text is too long');
      this.textarea.reportValidity();
      this.textarea.focus({ preventScroll: true });
      return;
    }
    this.textarea.setCustomValidity('');
    this.closed = true;
    this.cleanup();
    if (after !== this.shape.text) this.port.commit(this.shape.id, this.shape.text, after);
    this.port.focusCanvas();
  }
  cancel() {
    if (this.closed) return;
    this.closed = true;
    this.cleanup();
    this.port.focusCanvas();
  }
  private cleanup() {
    this.textarea.removeEventListener('compositionstart', this.onCompositionStart);
    this.textarea.removeEventListener('compositionend', this.onCompositionEnd);
    this.textarea.removeEventListener('keydown', this.onKeyDown);
    this.textarea.removeEventListener('blur', this.onBlur);
    this.textarea.remove();
  }
}

Canvas 预览使用相同的 font/lineHeight 和缓存行,并明确指定基线。direction:auto 不能直接赋给 Canvas direction,因为后者只接受 ltr/rtl/inherit。权威的 DOM 测量和布局会先为段落解析出一个 resolvedDirection,然后预览复用该值。fontSize 也必须是经过独立验证的数值。如果对 "600 16px Canvas Sans" 调用 parseFloat(),就会把字体粗细 600 误认为字体大小:

export function renderTextPreview(
  context: CanvasRenderingContext2D,
  shape: TextShape,
  lines: readonly TextLine[],
  resolvedDirection: 'ltr' | 'rtl',
) {
  context.save();
  try {
    context.font = shape.font;
    context.fillStyle = shape.color;
    context.textBaseline = 'alphabetic';
    context.direction = resolvedDirection;
    const fontSize = shape.fontSize;
    if (!(fontSize > 0) || !Number.isFinite(fontSize)) throw new Error('INVALID_FONT_SIZE');
    lines.forEach((line, index) =>
      context.fillText(line.text, 0, fontSize + index * fontSize * shape.lineHeight),
    );
  } finally {
    context.restore();
  }
}

单元测试证明,字素绝不会在 ZWJ 序列内部断开。真实浏览器测试为 IME 提供最终证据:

import { describe, expect, it } from 'vitest';
import { wrapGraphemes } from '../TextLayout';

describe('text layout', () => {
  it('treats a family emoji as one wrapping unit', () => {
    const family = '👨‍👩‍👧‍👦';
    const lines = wrapGraphemes(
      `A${family}B`,
      2,
      (text) =>
        Array.from(new Intl.Segmenter('und', { granularity: 'grapheme' }).segment(text)).length,
    );
    expect(lines.map((line) => line.text)).toEqual([`A${family}`, 'B']);
    expect(lines.flatMap((line) => [...line.text]).join('')).toBe(`A${family}B`);
  });

  it('preserves hard line breaks, including empty lines', () => {
    expect(wrapGraphemes('あ\n\nい', 10, (text) => text.length).map((line) => line.text)).toEqual([
      'あ',
      '',
      'い',
    ]);
  });
});

运行 pnpm vitest run src/engine/text。中日韩文字、Emoji、空行、宽度边界和字体缓存测试都应通过。运行 pnpm playwright test tests/browser/text-ime.spec.ts --project=chromium。组合输入期间,历史记录修订号应保持不变;结束组合输入并提交后,它应恰好增加 1。然后在 Safari/WebKit 和 Firefox 中运行剪贴板、选区和 RTL 测试。在移动设备上,手动验证虚拟键盘不会将覆盖层永久推到视口之外。

回到照片游戏。渲染器显示新照片,DOM 控件是暂时覆盖在照片上的原稿纸,FontManager 是装有印刷字模的盒子,而组合输入是尚未选定的候选卡片。只有当孩子说“完成”时,我们才拍摄新照片并更新账本。取消只会移除原稿纸。

故意破坏它

注入项症状证据修复方法回归测试恢复方法
中文拼音组合输入候选内容重复;每次按键产生一次撤销记录组合/input/历史记录跟踪由原生控件管理中间值;退出时只提交一次Chromium IME 事件序列取消覆盖层;文档保持不变
日文转换按 Enter 过早退出编辑isComposing=true keydown组合输入期间忽略提交快捷键WebKit/Chromium 固定测试用例恢复选区
家庭 Emoji退格后留下破损符号,或换行将其拆开字素分段Intl.Segmenter 或 DOM 布局ZWJ/肤色/旗帜固定测试用例重建布局缓存
阿拉伯语 RTL字符顺序颠倒且插入光标位置错误逻辑值和视觉截图不要反转字符串;设置 dir/directionRTL 混合数字测试恢复逻辑文本
字体延迟加载文本跳动且连接线锚点错误fonts.status 和加载前后度量在 loadingdone 时使缓存失效延迟字体路由清除布局缓存
目标字体替换回退字体行数发生变化,但边界未更新行数和度量修订号重新计算派生高度和锚点字体交换测试执行一次确定性重绘
400% 缩放 + 旋转覆盖层与文本分离比较 CSS 和形状的屏幕矩阵使用完整矩阵和 transform-origin在 0/37/90° 下进行多档缩放截图重建覆盖层
粘贴富 HTML脚本或私有样式进入模型剪贴板 MIME/模式日志纯文本策略,或使用清理器并配合类型化富文本模型恶意 HTML 固定测试用例以原子方式拒绝粘贴
移动端虚拟键盘输入框被遮挡或焦点丢失VisualViewport/焦点跟踪可见区域滚动和焦点策略真实 iOS/Android 设备保留编辑会话并重新定位

用证据通过验收

待证明的主张自动化证据必需的手动/设备证据
基于 keydown 的构建不会破坏 IME事件重放和一条命令真实的中文/日文输入法
字素不会被拆分Segmenter 固定测试用例使用系统 Emoji 键盘执行删除
RTL/双向文本保留逻辑值值快照屏幕阅读器和插入光标方向
字体变化后可以恢复延迟加载和缓存失效不同操作系统上的回退字体
覆盖层正确对齐跨缩放和旋转的截图差异比较400% 缩放时的选区
提交/取消边界修订号为 1/0 的断言失焦、Escape 和虚拟键盘
  • FontFace、document.fonts、回退字体、加载失败和重新测量都有明确协议。
  • 文本度量、断行/换行、基线和 Canvas 预览使用显式布局数据。
  • 字素、Emoji、双向文本、RTL、IME、组合输入、插入光标和选区由正确的层负责。
  • textarea/contenteditable 的选择有 ADR;富文本、拼写检查和粘贴都有安全策略。
  • DOM 测量和覆盖层在缩放、旋转及父级变换发生变化后仍保持对齐。
  • 提交是一条命令;取消对文档执行零次写入。
  • 普通文本不是根据 keydown 手动拼装的,因此 IME、选区、剪贴板、组合输入和屏幕阅读器行为保持完好。

给五岁孩子解释

回答时不要说“DOM”、“Canvas”、“输入法”、“字素”或“双向文本”:

  1. 为什么你能在照片中看到文字,却不能在两个字母之间放一条小竖线?
  2. 当孩子仍在挑选候选字符时,为什么不能在每次按键时都更新共享账本?
  3. 为什么全家福由许多细小部分组成,却通常需要作为一个整体行动?
  4. 新问题:装有印刷字模的盒子晚到了一分钟,一行文字变成了两行。必须重新测量什么?
显示参考答案 照片只记录颜色,不记录插入位置或选中的区域。编辑需要浏览器能够理解的可书写纸张。尚未选定的候选字符只是一份草稿,因此应在孩子确认或完成编辑后只记录一次。全家福由许多符号粘合成眼睛所看到的一幅图像,不能从粘合处将它剪开。新的印刷字模具有不同的宽度,因此要重新计算每行能容纳哪些字母、镂空区域的高度、连线的位置以及显示的照片——但不要改变文本本身。