Editor.js Caret 模块完全指南:从 Block 间光标定位到焦点导航
【免费下载链接】editor.jsA block-style editor with clean JSON output项目地址: https://gitcode.com/gh_mirrors/ed/editor.js
导读:本文以 Editor.js 官方文档 docs/caret.md 为骨架,系统讲解块级编辑器核心模块 Caret 的设计与使用。你将在文中看到 Caret 模块的
setToBlock、setToTheLastBlock等方法如何在RangeAPI 之上工作,以及如何通过editor.caret公共 API 编程式控制光标位置,实现“跳到首块/末块/上一块/下一块”等实战能力,并理解底层 DOM 算法与键盘导航的完整调用链。
一、Caret 模块是什么
Editor.js 是一个"块样式编辑器",其内容由多个 Block 组成。要让用户在块与块之间顺畅移动光标、让工具(Tool)能通过 API 精确摆放光标位置,就需要一个专门负责“光标”的模块——Caret。
按照 docs/caret.md 的描述:
The
Caretmodule contains methods working with caret. Uses Range methods to navigate caret between blocks.
Caret 模块包含所有与光标(caret)相关的方法,底层基于浏览器的 Range API 在 Block 之间导航光标。同时,Caret 类实现了基础的 Module 类,从而持有用户配置(User configuration)和默认的 Editor.js 实例引用。
从源码看,Caret 类继承自Module(见 src/components/modules/caret.ts),通过this.Editor访问 BlockManager、BlockSelection 等其他模块。Caret 类自身也定义了一组合法的位置常量:
public get positions(): {START: string; END: string; DEFAULT: string} { return { START: 'start', END: 'end', DEFAULT: 'default', }; }start:光标置于 Block 起始位置end:光标置于 Block 末尾default:保持默认行为,若传入 offset 则应用偏移
二、核心方法 setToBlock:把光标放进指定 Block
setToBlock是 Caret 模块最核心的方法,官方文档定义其签名为:
Caret.setToBlock(block, position, offset)Method gets Block instance and puts caret to the text node with offset
方法接收一个 Block 实例,将光标放入其文本节点并应用偏移。三个参数的含义如下表(继承自 docs/caret.md):
| Param | Type | Description |
|---|---|---|
| block | Object | Block instance that BlockManager created |
| position | String | Can be 'start', 'end' or 'default'. Other values will be treated as 'default'. Shows position of the caret regarding to the Block. |
| offset | Number | caret offset regarding to the text node (Default: 0) |
2.1 源码级执行流程
在 src/components/modules/caret.ts 中,setToBlock的实现主要分三步:
- 清除旧选区:调用
BlockSelection.clearSelection(),避免残留选中状态影响新定位。 - 处理不可聚焦的 Block:如果
block.focusable为 false(例如 Delimiter 分隔线这类无输入的工具),则移除当前选区、高亮该 Block(BlockSelection.selectBlock(block))并更新currentBlock,而不是强行放入光标。 - 定位输入元素:根据 position 选取目标元素:
start→block.firstInputend→block.lastInputdefault(含其他任意值)→block.currentInput
随后针对三种 position 计算精确的节点与偏移:
- START:取输入元素内最深的第一个节点(
$.getDeepestNode(element, false)),偏移固定为 0; - END:取最深的最后一个节点(
$.getDeepestNode(element, true)),偏移为节点内容长度($.getContentLength(nodeToSet)); - DEFAULT:调用
$.getNodeByOffset(element, offset)把“相对 Block 内容”的偏移换算成“某个具体文本节点内的偏移”;若换算失败(如空 Block),则退回最深节点、偏移 0。
最后调用this.set(nodeToSet, offsetToSet)真正放置光标,并同步BlockManager的当前块与当前输入引用。
2.2 set 方法:放置光标并滚动到可视区域
set是放置光标的最终动作,其内部调用Selection.setCursor(element, offset)创建Range并设置选区(见 src/components/selection.ts)。在此基础上,set还会做可视区域校正:
const scrollOffset = 30; const { top, bottom } = Selection.setCursor(element, offset); if (top < 0) { window.scrollBy(0, top - scrollOffset); } else if (bottom > innerHeight) { window.scrollBy(0, bottom - innerHeight + scrollOffset); }如果光标新位置超出视口上方(top < 0)或下方(bottom > innerHeight),就通过window.scrollBy滚动窗口,并预留 30px 的留白。这就是为什么用 API 把光标放到很远处的 Block 时,页面会自动滚过去——用户不会“丢失光标”。
2.3 两个方向的最深节点搜索
setToBlock中反复出现的$.getDeepestNode定义在 src/components/dom.ts:它沿 DOM 树向“第一个子节点 / nextSibling”或“最后一个子节点 / previousSibling”方向递归,找到可容纳光标的最深层节点(文本节点或元素节点)。这保证了即便 Block 内部嵌套了<b>、<i>等格式化标签,光标也能落到位。
而 DEFAULT 分支使用的$.getNodeByOffset(src/components/dom.ts)则用document.createTreeWalker遍历文本节点,把“相对 Block 内容起点的总偏移”逐步累加映射到具体的{ node, offset },从而支持“把光标放到第 N 个字符”的精确操作。
三、setToTheLastBlock:定位到最后一个 Block
官方文档给出了另一个方法:
Caret.setToTheLastBlock()sets Caret at the end of last Block. If last block is not empty, inserts another empty Block which is passed as initial
setToTheLastBlock会把光标放到最后一个 Block 的末尾;如果最后一个 Block 非空,则先追加一个新的空 Block 再放入光标。源码见 src/components/modules/caret.ts:
public setToTheLastBlock(): void { const lastBlock = this.Editor.BlockManager.lastBlock; if (!lastBlock) { return; } if (lastBlock.tool.isDefault && lastBlock.isEmpty) { this.setToBlock(lastBlock); } else { const newBlock = this.Editor.BlockManager.insertAtEnd(); this.setToBlock(newBlock); } }这里的判定逻辑值得注意:只有当最后一个 Block 恰好是默认工具(tool.isDefault)且内容为空时,才直接复用该 Block;否则调用BlockManager.insertAtEnd()追加新块(新块会被渲染成初始工具),再把光标放进去。这一设计保证了编辑区底部永远有一个可输入的空块,避免光标落在不可输入的工具上——这也是块编辑器“永远能继续写下去”的关键机制。
四、公共 API:editor.caret
文档描述的 Caret 模块是内部模块,但对开发者而言,日常使用的是暴露给外部的editor.caretAPI。其类型定义见 types/api/caret.d.ts,实现位于 src/components/modules/api/caret.ts。可用方法如下:
| 方法 | 参数 | 说明 |
|---|---|---|
setToFirstBlock | position?,offset? | 光标置于第一个 Block |
setToLastBlock | position?,offset? | 光标置于最后一个 Block |
setToPreviousBlock | position?,offset? | 光标置于上一个 Block |
setToNextBlock | position?,offset? | 光标置于下一个 Block |
setToBlock | blockOrIdOrIndex,position?,offset? | 光标置于指定 Block(见下文) |
focus | atEnd? | 聚焦编辑器;atEnd=true时置于末尾 |
所有方法都返回boolean,表示是否成功定位(例如当前没有上一个/下一个 Block 时返回false)。
position 参数复用 Caret 模块的三种取值:'start'、'end'、'default'(默认值),offset 默认 0。
4.1 setToBlock 支持三种入参
editor.caret.setToBlock的第一个参数是联合类型BlockAPI | BlockAPI['id'] | number,即可以传入:
- Block 索引:
editor.caret.setToBlock(0) - Block id:
editor.caret.setToBlock('some-block-id') - BlockAPI 实例:
const block = editor.blocks.getById(id); editor.caret.setToBlock(block)
内部通过resolveBlock统一解析(见 src/components/modules/api/caret.ts),解析失败返回false。这一弹性入参在测试用例中被完整覆盖,见 test/cypress/tests/api/caret.cy.ts。
4.2 focus 与偏移的实测行为
focus(atEnd)的实现在 src/components/modules/api/caret.ts:atEnd为真时定位到最后一个 Block 的end,否则定位到第一个 Block 的start。
关于offset的边界行为,test/cypress/tests/api/caret.cy.ts 提供了几个有价值的实测断言:
- 纯文本
'Plain text content.'中传offset=5,光标精确落在第 5 个字符处(range.startOffset === 5); - 含 HTML 的内容
'1234<b>567</b>!'中传offset=6,光标会落在<b>内的文本节点'567'上、偏移 2(即“12345”之后); - 偏移超过内容长度时(如
contentLength + 10),光标被安全钳制在内容末尾,不会越界报错; - 嵌套结构
'123<b>456<i>789</i></b>!'同样能正确解析出目标节点与偏移。
也就是说:offset 是相对整个 Block 文本内容的偏移,Caret 内部会把它换算到具体的文本节点,超界时自动收敛到末尾。
五、键盘导航:navigateNext / navigatePrevious
除了直接放置光标,Caret 模块还承担键盘焦点导航职责。navigateNext(force)与navigatePrevious(force)(src/components/modules/caret.ts)负责在“当前 Block 的多个输入(如标题与正文)之间”以及“相邻 Block 之间”移动光标:
navigateNext:优先聚焦当前块的nextInput(若有),否则跳转到nextBlock;当没有下一个 Block 且当前块不是默认工具时,自动insertAtEnd()追加默认块再跳入(对应 issue #1103 的“从末尾非默认工具退出”场景),若当前块是默认块则不做处理(对应 #1414)。navigatePrevious:优先聚焦previousInput,否则跳转previousBlock的末尾。
两者的“是否允许导航”判定规则一致:
// navigateNext const isAtEnd = currentInput !== undefined ? caretUtils.isCaretAtEndOfInput(currentInput) : undefined; const navigationAllowed = force || isAtEnd || !currentBlock.focusable; // navigatePrevious const caretAtStart = currentInput !== undefined ? caretUtils.isCaretAtStartOfInput(currentInput) : undefined; const navigationAllowed = force || caretAtStart || !currentBlock.focusable;- 传
force=true时无条件导航(用于 Tab 键); - 光标位于输入末尾(或开头)时允许导航;
- Block 本身不可聚焦(如 Delimiter)时也允许导航。
其中isCaretAtEndOfInput/isCaretAtStartOfInput来自 src/components/utils/caret.ts。它们对原生输入(<input>/<textarea>)直接比较selectionEnd与值长度/0;对contenteditable则用checkContenteditableSliceForEmptiness判断光标左右两侧是否只剩不可见空白(折叠空白),从而正确处理 与普通空格的区别。
5.1 键盘事件接线
这些导航方法由 src/components/modules/blockEvents.ts 中的键盘处理器触发:
- Tab / Shift+Tab(L204-L221):
Caret.navigateNext(true)/Caret.navigatePrevious(true),导航成功则preventDefault(),否则保留浏览器原生 Tab 行为(跳出编辑器); - 方向键(L563-L628):DOWN/RIGHT(非 RTL)触发
navigateNext(),UP/LEFT(非 RTL)触发navigatePrevious(),并支持 RTL 方向翻转; - Enter 在特定输入间跳转:当首个输入未聚焦时按 Enter 跳转到下一输入(L379-L382)、末个输入已聚焦时调用
navigateNext()(L463-L466)。
六、其他工具方法
除上述定位与导航方法外,Caret 模块还提供若干辅助能力(均在 src/components/modules/caret.ts):
setToInput(input, position, offset)(L127-L147):把光标放到指定的输入元素上,支持start/end/default三种位置,并在结束后更新currentBlock.currentInput。extractFragmentFromCaretPosition()(L197-L233):从光标位置到 Block 末尾截取内容片段。对原生输入直接基于value.substring(selectionStart)切分;对 contenteditable 则克隆 Range 后extractContents()。这是“光标处拆分文本/换行拆分”类功能的基础。createShadow(element)/restoreCaret(element)(L347-L382):创建/恢复“影子光标”。createShadow在目标元素末尾插入一个cdx-shadow-caret的<span>作为占位,restoreCaret通过Selection.expandToTag选中并移除该占位,从而在需要暂时“隐藏”真实光标时保持位置记忆。insertContentAtCaretPosition(content)(L389-L422):在光标处插入 HTML 内容,将内容包进DocumentFragment,删除原选区内容后insertNode,并把新光标放到插入内容的末尾——注意它专门处理了空 fragment 与文本节点/元素节点的差异,保证跨浏览器光标位置正确。
七、与其他模块的协作
从源码调用关系看,Caret 是编辑器中“被依赖度”极高的基础模块:
- BlockManager:提供
firstBlock、lastBlock、previousBlock、nextBlock、currentBlock等导航上下文,并承担insertAtEnd()等新增块操作; - BlockSelection:
setToBlock前会clearSelection(),遇到不可聚焦块则selectBlock(block)高亮; - DOM 工具(src/components/dom.ts):
getDeepestNode、getContentLength、getNodeByOffset是定位算法的基石; - Selection 工具(src/components/selection.ts):
setCursor完成最终的 Range 创建与选区设置; - BlockEvents:键盘事件处理器把用户的 Tab/方向键/Enter 操作翻译成对 Caret 导航方法的调用。
这种职责划分也解释了 docs/caret.md 中“Caret class implements basic Module class that holds User configuration and default Editor.js instances”的含义——Caret 与其他 Module 一样,通过模块系统共享编辑器实例,从而能无缝协调这些依赖。
八、典型使用场景与示例
下面给出通过公共 API 控制光标的常见用法(可直接在浏览器控制台或自定义工具中运行):
const editor = new EditorJS({ holder: 'editorjs', // ... 其他配置 }); // 等编辑器 ready 后再操作 editor.isReady.then(() => { // 1. 聚焦编辑器开头 editor.caret.focus(); // 等价于 setToFirstBlock('start') // 2. 聚焦编辑器末尾(自动保证末尾有空块可输入) editor.caret.focus(true); // 等价于 setToLastBlock('end') // 3. 跳到第一个 / 最后一个 Block editor.caret.setToFirstBlock('end'); editor.caret.setToLastBlock('start'); // 4. 相对当前块前后移动 editor.caret.setToNextBlock('start'); editor.caret.setToPreviousBlock('end'); // 5. 按索引 / id / BlockAPI 三种方式定位 editor.caret.setToBlock(0, 'start'); // 第一个块 editor.caret.setToBlock('block-id', 'end'); // 指定 id 的块 const block = editor.blocks.getById('block-id'); editor.caret.setToBlock(block, 'default', 5); // 第 5 个字符处 // 6. 返回值可用于判断是否定位成功 const moved = editor.caret.setToNextBlock('end'); if (!moved) { console.log('已经是最后一个块了'); } });position 取值对照:
| 取值 | 行为 | 典型场景 |
|---|---|---|
'start' | 光标置于 Block 内容最前 | 从块首开始输入/替换 |
'end' | 光标置于 Block 内容最后 | 追加内容、聚焦末尾 |
'default' | 保持默认行为,可配合 offset | 精确定位到第 N 个字符 |
九、小结
Caret 模块是 Editor.js 光标体系的枢纽:对外,editor.caretAPI 让开发者能以一行代码把光标定位到任意 Block 的任意位置;对内,它借助Range、DOM 深度遍历与可视区域滚动校正,为 Tab/方向键/Enter 等键盘导航提供了可靠的底层支撑。理解它的setToBlock三参数语义(Block、position、offset)、start/end/default三种位置模式,以及navigateNext/navigatePrevious的“输入内 → 块间 → 自动补块”导航链,就能在自定义工具和业务集成中精确掌控编辑器的光标行为。
【免费下载链接】editor.jsA block-style editor with clean JSON output项目地址: https://gitcode.com/gh_mirrors/ed/editor.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考