Gutenberg Writing Flow 源码解析:基于 contentEditable 与 selectionchange 的跨块选择机制
2026/9/17 2:52:48 网站建设 项目流程

Gutenberg Writing Flow 源码解析:基于 contentEditable 与 selectionchange 的跨块选择机制

【免费下载链接】gutenbergThe Block Editor project for WordPress and beyond. Plugin is available from the official repository.项目地址: https://gitcode.com/GitHub_Trending/gu/gutenberg

Writing Flow 是 Gutenberg 块编辑器画布中负责“跨块选择(selection across blocks)”的核心钩子。本文以 packages/block-editor/src/components/writing-flow/readme.md 为主线,结合仓库源码深入剖析它如何借助临时开启contentEditable、监听selectionchange事件、同步块编辑器 store,从而支撑鼠标拖拽、Shift+Click、方向键导航以及 Backspace / Delete / Enter 等跨块编辑操作。读完本文,你将理解 Writing Flow 的整体架构、每个子钩子的职责与调用链,以及它处理跨块选择这一难题的完整设计思路。

一、Writing Flow 是什么

Writing Flow 是一个包裹在BlockList外层的 React 组件(见 packages/block-editor/src/components/writing-flow/index.jsx),它的核心职责可以用一句话概括:

This hook handles selection across blocks.(该钩子负责跨块的选区处理。)

编辑器中的内容由一个个独立的块(Block)组成,每个块的富文本内容通常是独立的contentEditable区域。原生浏览器的选区机制天然被限制在单一的可编辑节点内,无法直接横跨多个块。Writing Flow 要解决的就是打破这一边界,让用户能够:

  • 用鼠标在多个块之间拖拽出跨块选区;
  • 用 Shift+Click 从当前块扩展到另一个块;
  • 用键盘方向键把选区推进到相邻块的边缘;
  • 在跨块选区上执行 Backspace、Delete、Enter 等编辑动作。

从 index.jsx 可以看到,useWritingFlow()通过useMergeRefs把十多个子钩子的 ref 效果合并到同一个画布容器节点上,形成一个“监听链”,每个钩子各司其职:

useMergeRefs( [ useUndoAutomaticChange(), // Escape 撤销自动变更 ref, // Tab 导航 / 焦点陷阱 useEditableRootEventHandlers(), useClipboardHandler(), // 复制 / 剪切 / 粘贴 useInput(), // Enter / Backspace / Delete / 文本输入 useEditableRoot(), useHomeEnd(), // Home / End 键 useDragSelection(), // 鼠标拖拽跨块选择 useSelectionObserver(), // selectionchange 同步到 store useClickSelection(), // Shift+Click 跨块选择 useMultiSelection(), // 多选状态落到 DOM useSelectAll(), // 全选 useArrowNav(), // 方向键跨块导航 usePreviewModeNav(), // 预览模式导航 ] )

WritingFlow组件本体(index.jsx)渲染一个带block-editor-writing-flowclass 的<div>,并在前后插入before/after两个焦点陷阱元素(由useTabNav返回)。

二、跨块选择的核心机制:临时打开 contentEditable

原文档指出,跨块选择之所以可行,关键在于临时把整个画布容器的contentEditable属性设为true。文档作者也承认“这听起来很吓人(This sounds scary)”,但实现上对默认行为做了严格控制——只允许原生选区发生,其余所有默认行为都被拦截,因此不会造成 DOM 被浏览器随意改写。

这一机制的实现集中在 utils.js 的setContentEditableWrapper( node, value, { focus } )

  • 每次选区变化都会调用它,因此先做相等性检查node.contentEditable === String( value ))以避免重复设置触发样式重算;
  • 设为true时,为容器补充role="textbox"aria-multiline="true"aria-label="Editor canvas"等无障碍属性(WAI-ARIA textbox 角色要求可访问名称),并用node.focus( { preventScroll: true } )把焦点移到容器上(Firefox 不会自动移焦,需显式处理);
  • 设为false时,移除rolearia-multilinearia-label
  • 对 JSDOM 等不支持contentEditable的环境做了防御性处理。

三种触发跨块选择的方式

原文档明确列出了跨块选择的三种触发途径,对应的源码实现如下:

触发方式触发时机对应源码
鼠标拖拽选择鼠标左键按住并离开某个可编辑字段时use-drag-selection.js
Shift+Click 选择mousedownuse-click-selection.js
键盘选择选区到达可编辑字段边缘时use-arrow-nav.js

鼠标拖拽选择(use-drag-selection.js)监听mouseout:当主键按下(buttons === 1)、鼠标从可编辑元素离开到容器外、且尚未处于多选状态、也没有正在拖拽块时,记录anchorElement并调用startMultiSelect(),随后立即setContentEditableWrapper( node, true )。源码注释给出了一个关键设计理由:

We can't rely on using the store and React because re-rending happens too slowly. We need to be able to select across instances immediately.(不能依赖 store 和 React,因为重渲染太慢,必须立刻具备跨实例选区的能力。)

Shift+Click 选择(use-click-selection.js)在mousedown时判断event.shiftKey:若当前已有选中块且点击的是不同块,则把容器置为可编辑(focus: !!attributeKey),并针对“选中的块内部没有文本选区(如图片块、间隔块)”的情况,主动把浏览器的原生锚点设置到被选块的边缘,保证 Shift+Click 后整块都落在扩展选区之内。另外,当已存在多选时,普通单击会把多选收拢为对单个块的单选(selectBlock( clickedClientId )),让用户能方便地“逃出”多选状态。

键盘方向键选择(use-arrow-nav.js)在keydown中处理:当按下 Shift+方向键且当前焦点元素已到达可编辑字段的边界(isVerticalEdge/isHorizontalEdge)时,通过getClosestTabbable找到下一个块的候选目标,并setContentEditableWrapper( node, true )让选区得以延伸过去。原文档提到“未来应考虑让方向键导航也复用 contentEditable 属性”,目前的方向键实现仍是基于getClosestTabbable+placeCaretAtHorizontalEdge/placeCaretAtVerticalEdge的显式跳转方案。

此外,文档中还提到了isNavigationCandidate(use-arrow-nav.js)对原生表单控件的保护逻辑:例如numberdate等需要上下键操作的原生输入框不参与垂直导航,TEXTAREA不参与水平导航,从而把“浏览器原生行为”和“编辑器跨块导航”划分清楚。该函数有对应的单元测试,见 packages/block-editor/src/components/writing-flow/test/index.jsdom.test.js。

三、把原生选区同步到 block editor store

既然能跨块选择了,接下来就要把原生选区状态同步到块编辑器 store,否则编辑器的选中高亮、工具栏等 UI 无法感知。原文档指出:通过监听selectionchange事件完成同步,且同步粒度是有讲究的:

  • 在 Writing Flow 层面,可以同步选中块的 clientId
  • 但当选区起始或结束于某个富文本字段时,富文本(RichText)会同步更精确的位置——块的 attributeKey 和 offset,外加 clientId。

selectionchange 观察者的实现

use-selection-observer.js 是这一同步逻辑的落地实现,它在ownerDocument上注册selectionchange监听,主要流程如下:

  1. 从原生Selection中提取起始节点与结束节点。extractSelectionStartNode/extractSelectionEndNode处理了“锚点不是文本节点时,offset 表示子节点索引”的 DOM 语义,并专门修正了**三击(triple click)**导致的选区越过块边界、实际并未视觉选中下一块的边界情况(extractSelectionEndNodeisTripleClick分支)。
  2. 通过getBlockClientId( node )把节点映射为块,若起止节点都不属于任何块则直接返回。
  3. 根据起止是否在同一块内分派不同的 store action:
    • 单块内选区:若富文本实例自己会同步选区则交还给它;否则调用selectionChange( { start, end } ),其中包含attributeKey与精确offset(结束偏移在越界时会被钳制到文本末尾)。
    • 跨块多选:利用getBlockParents计算两个块到根部的路径,findDepth找到最近公共祖先层级,调用multiSelect( startPath[ depth ], endPath[ depth ] )把多选提升到合适的兄弟层级;若两个块是祖先-后代关系(不存在可提升的兄弟块),则按“外层块视为完全选中”处理。
  4. 折叠选区(collapsed)时,若落在支持editableRoot的已选块内,则保持容器可编辑以支持选区继续外扩;否则关闭容器的可编辑状态,并把焦点还给原来的字段(同时处理 Escape 已把焦点移走的边界情况,避免误抢焦点)。

multiSelectaction 的定义在 packages/block-editor/src/store/actions.js,其行为有专门测试覆盖,见 packages/block-editor/src/store/test/actions.jsdom.test.js。

与剪贴板事件的协作

原生selectionchange是异步派发的,而复制/剪切/粘贴可能发生在 store 尚未完成跨块选区同步之前。为此useSelectionObserver还以捕获阶段监听了copy/cut/pasteensureMultiBlockSelectionSync):当检测到原生选区横跨多个块时,先补发一次同步,保证剪贴板处理器读取到的 store 状态是准确的。剪贴板数据组装(setClipboardBlocks,同时写入text/htmltext/plain)则位于 utils.js。

四、多选状态回写到 DOM:useMultiSelection

同步是双向的:不仅要把原生选区写进 store,还要在 store 产生多选时把 DOM 状态对齐。use-multi-selection.js 做的事情是:当满足“确实存在多选、处于完整选中(__unstableIsFullySelected)、块数 ≥ 2、且没有正在多选”等条件时,调用setContentEditableWrapper( node, true )并清除原生选区(removeAllRanges)。源码注释特别提醒:在 Safari 中,必须先移焦再清除选区。而initialPosition的判空(undefined/null)则让列表视图等场景可以跳过焦点转移,避免焦点被抢到画布上。

五、跨块选区上的编辑操作:Enter / Backspace / Delete / 输入

原文档指出:有了 store 中的选区状态,就可以处理 Backspace、Delete 和 Enter 了。这些逻辑集中在 use-input.js,其onKeyDown按是否处于多选状态分两条路径:

单块选中时:

  • Enter:优先尝试“输入转换”(getBlockTransforms( 'from' )type === 'enter'的转换,例如输入##后回车把段落切成标题),命中则replaceBlocks并标记自动变更;否则判断模板锁与块的splitting支持,走__unstableSplitSelection()拆分选区,或insertAfterBlock( clientId )在块后插入默认块,或在空容器内下钻插入其默认块。
  • Shift+Enter 与可编辑元素内的回车由富文本实例自行处理,Writing Flow 不拦截。

跨块多选时(这也是本小节与文档主题最相关的部分):

  • Enter:先setContentEditableWrapper( node, false )关闭容器的可编辑态,完全选中时用默认块替换选中块(replaceBlocks),否则拆分选区;
  • Backspace / Delete:同样先关闭容器可编辑态并preventDefault,完全选中时removeBlocks删除选中块;选区可合并(__unstableIsSelectionMergeable)时执行__unstableDeleteSelection删除选中文本;否则__unstableExpandSelection把选区扩展到块边界;
  • 普通字符输入:若跨块选区可合并则先删除选区再交给浏览器输入,否则preventDefault并清空原生选区(针对 Safari 即便preventDefault仍会改 DOM 的兼容处理);
  • onBeforeInputonCompositionStart同样处理了多选场景下 IME 输入法组合输入的拦截。

六、Tab 导航与焦点管理:useTabNav

除了方向键,Tab 键的流转也是 Writing Flow 的一部分。use-tab-nav.jsx 把整个画布视为页面 Tab 顺序中的一个“停靠点(canvas stop)”:

  • 画布前后各有一个透明焦点陷阱元素(before/after,样式为position: absolute; inset: 0; pointerEvents: none),落入陷阱即触发enterCanvas()进入画布;
  • enterCanvas依次处理:多选状态(焦点放容器)、已选块(焦点放上次离开的位置getLastFocus或块元素)、Zoom Out 模式(焦点放 section 根)、普通模式(焦点放第一个可聚焦元素);
  • 在画布内按Escape会“停靠”到画布前的焦点陷阱,使后续 Tab 移动到画布外的界面(如块工具栏、侧边栏);再次按 Enter、空格、F2、Escape 或点击陷阱可重新进入画布;
  • 画布内的 Tab / Shift+Tab 通过focus.tabbable.findNext / findPrevious在块之间流转,并对块内的表单元素(如图片占位符中的按钮)做了同块约束;
  • focusout时记录setLastFocus,并在“块被全部删除、焦点落到 body”时把焦点收回画布容器,避免焦点丢失。

七、整体工作流程小结

综合以上源码,一条完整的“跨块选择”链路可以概括为:

  1. 触发:鼠标拖拽(mouseout)、Shift+Click(mousedown)或方向键(keydown)让选区触及块边界;
  2. 放行setContentEditableWrapper把画布容器临时设为contentEditable,浏览器原生选区得以跨越多个块(其余默认行为被严格拦截);
  3. 同步useSelectionObserver监听selectionchange,提取起止节点 → 映射 clientId → 计算公共祖先层级 → 调用selectionChange/multiSelect/selectBlock写入 store(富文本内部则同步更精确的 attributeKey + offset);
  4. 回写useMultiSelection依据 store 多选状态重置 DOM 原生选区;
  5. 编辑useInput依据 store 选区状态处理 Enter / Backspace / Delete / 字符输入,完成跨块替换、删除或拆分。

每一个环节都有对应的源码文件与测试支撑(方向键候选判断测试见 test/index.jsdom.test.js,multiSelectaction 测试见 store/test/actions.jsdom.test.js)。如果希望深入探索,可以继续阅读同目录下的 use-clipboard-handler.js、use-editable-root.js、use-select-all.js 与 use-home-end.js 等其余子钩子,它们共同构成了 Gutenberg 画布上完整的跨块选择与导航体验。

【免费下载链接】gutenbergThe Block Editor project for WordPress and beyond. Plugin is available from the official repository.项目地址: https://gitcode.com/GitHub_Trending/gu/gutenberg

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询