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时,移除role、aria-multiline、aria-label; - 对 JSDOM 等不支持
contentEditable的环境做了防御性处理。
三种触发跨块选择的方式
原文档明确列出了跨块选择的三种触发途径,对应的源码实现如下:
| 触发方式 | 触发时机 | 对应源码 |
|---|---|---|
| 鼠标拖拽选择 | 鼠标左键按住并离开某个可编辑字段时 | use-drag-selection.js |
| Shift+Click 选择 | mousedown时 | use-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)对原生表单控件的保护逻辑:例如number、date等需要上下键操作的原生输入框不参与垂直导航,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监听,主要流程如下:
- 从原生
Selection中提取起始节点与结束节点。extractSelectionStartNode/extractSelectionEndNode处理了“锚点不是文本节点时,offset 表示子节点索引”的 DOM 语义,并专门修正了**三击(triple click)**导致的选区越过块边界、实际并未视觉选中下一块的边界情况(extractSelectionEndNode中isTripleClick分支)。 - 通过
getBlockClientId( node )把节点映射为块,若起止节点都不属于任何块则直接返回。 - 根据起止是否在同一块内分派不同的 store action:
- 单块内选区:若富文本实例自己会同步选区则交还给它;否则调用
selectionChange( { start, end } ),其中包含attributeKey与精确offset(结束偏移在越界时会被钳制到文本末尾)。 - 跨块多选:利用
getBlockParents计算两个块到根部的路径,findDepth找到最近公共祖先层级,调用multiSelect( startPath[ depth ], endPath[ depth ] )把多选提升到合适的兄弟层级;若两个块是祖先-后代关系(不存在可提升的兄弟块),则按“外层块视为完全选中”处理。
- 单块内选区:若富文本实例自己会同步选区则交还给它;否则调用
- 折叠选区(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/paste(ensureMultiBlockSelectionSync):当检测到原生选区横跨多个块时,先补发一次同步,保证剪贴板处理器读取到的 store 状态是准确的。剪贴板数据组装(setClipboardBlocks,同时写入text/html与text/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 的兼容处理); onBeforeInput与onCompositionStart同样处理了多选场景下 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”时把焦点收回画布容器,避免焦点丢失。
七、整体工作流程小结
综合以上源码,一条完整的“跨块选择”链路可以概括为:
- 触发:鼠标拖拽(
mouseout)、Shift+Click(mousedown)或方向键(keydown)让选区触及块边界; - 放行:
setContentEditableWrapper把画布容器临时设为contentEditable,浏览器原生选区得以跨越多个块(其余默认行为被严格拦截); - 同步:
useSelectionObserver监听selectionchange,提取起止节点 → 映射 clientId → 计算公共祖先层级 → 调用selectionChange/multiSelect/selectBlock写入 store(富文本内部则同步更精确的 attributeKey + offset); - 回写:
useMultiSelection依据 store 多选状态重置 DOM 原生选区; - 编辑:
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),仅供参考