在先前我们基于Range
对象与Selection
对象实现了基本的浏览器选区操作,并且基于编辑器数据模型设计了RawRange
和Range
对象两种选区模型。在这里我们需要将浏览器选区与编辑器选区关联起来,以此来确认应用变更时的操作区间,相当于我们需要基于DOM
实现受控的选区同步。
- 开源地址: https://github.com/WindRunnerMax/BlockKit
- 在线编辑: https://windrunnermax.github.io/BlockKit/
- 项目笔记: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
从零实现富文本编辑器项目的相关文章:
- 深感一无所长,准备试着从零开始写个富文本编辑器
- 从零实现富文本编辑器#2-基于MVC模式的编辑器架构设计
- 从零实现富文本编辑器#3-基于Delta的线性数据结构模型
- 从零实现富文本编辑器#4-浏览器选区模型的核心交互策略
- 从零实现富文本编辑器#5-编辑器选区模型的状态结构表达
- 从零实现富文本编辑器#6-浏览器选区与编辑器选区模型同步
描述
当前的主要目标是将浏览器选区与编辑器选区模型同步,也就是希望实现受控的DOM
选区同步。实际上这里需要考虑的问题非常多,例如DOM
节点是非常复杂的,特别是在支持插件化的渲染模式下,如何将其归一化,以及如何处理ContentEditable
的受控渲染问题等等。
我们先来处理最简单的选区同步问题,也就是纯文本节点的选区Case
。先来回顾一下浏览器中纯文本的选区操作,下面的例子中,我们就可以获取文本片段23
的位置,这里的firstChild
是Text
节点,即值为Node.TEXT_NODE
类型,这样才可以计算文本内容的片段。
<span id="$1">123456</span>
<script>
const range = document.createRange();
range.setStart($1.firstChild, 1);//$
range.setEnd($1.firstChild, 3);
console.log(range.getBoundingClientRect());
</script>
在编辑器选区模型中,我们定义了Range
对象以及RawRange
对象来表示编辑器选区状态。RawRange
对象的设计与Quill
编辑器的选区设计保持一致,毕竟通常来说选区设计的直接依赖便是数据结构的设计,RawPoint
对象则直接维护了起始偏移的值。
export class RawPoint {
constructor(
/** 起始偏移 */
public offset: number
) {}
}
export class RawRange {
constructor(
/** 起始点 */
public start: number,
/** 长度 */
public len: number
) {}
}
Range
对象选区的设计直接基于编辑器状态的实现,基于Point
对象维护了行索引和行内偏移,Range
对象则维护了选区的起始点和结束点。此时的Range
对象中区间永远是从start
指向end
,通过isBackward
来标记此时是否反选状态。
export class Point {
constructor(
/** 行索引 */
public line: number,
/** 行内偏移 */
public offset: number
) {}
}
export class Range {
/** 选区起始点 */
public readonly start: Point;
/** 选区结束点 */
public readonly end: Point;
/** 选区方向反选 */
public isBackward: boolean;
/** 选区折叠状态 */
public isCollapsed: boolean;
}
实际上这里进行选区同步的主要目标是我们希望借助ContentEditable
实现内容的输入,以及借助浏览器原本的选区模型来实现文本选择效果,而不是额外维护input
实现输入以及自绘选区来实现文本选择效果。因此我们借助了更多浏览器能力,则需要大量逻辑来实现受控的模式同步。
而在整个流程中,我们需要完成双向的转换。当浏览器选区发生变化的时候,我们需要获取最新的DOM
选区,并将其转换为Model
选区。而在编辑器内容变更、主动设置选区等场景下,需要将编辑器选区转换为浏览器选区,并且设置到DOM
节点中。
我们的编辑器实际上是要完成类似于slate
的架构,因为我们希望的是core
与视图分离,所以选区、渲染这方面的实现都需要在react
这个包里实现,相关的state
是在core
里实现的。在这里我们可以参考quill
和slate
的选区实现,并且总结出相关的实现:
- 无论是
slate
还是quill
都是更专注于处理点Point
,当然quill
的最后一步是将点做减法转化为length
。但是在这一步之前,都是在处理Point
这个概念的,因为本身浏览器的选区也是通过Anchor
与Focus
这两个Point
来实现的,所以转换也是需要继承这个实现。 - 无论是
slate
还是quill
也都会将浏览器选区进行一个Normalize
化,主要是为了将选区的内容打到Text
节点上,并且再来计算Text
节点的offset
。毕竟富文本实际上专注的还是Text
节点,各种富文本内容也是基于这个节点类似于fake-text
来实现的。 quill
因为是自行实现的View
层,所以其维护的节点都在Blot
中,所以将浏览器的选区映射到quill
的选区相对会简单一些。而slate
是借助于React
实现的View
层,那么映射的过程就变的复杂了起来,所以在slate
当中可以看到大量的形似于data-slate-leaf
的节点,这都是slate
用来计算的标记,当然其实也不仅仅是选区的标记。- 在每次选区变换的时候,是不能将所有的节点都遍历一遍来找目标节点,或者遍历一遍去计算每个点对应的位置来构造
Range
。所以实际上在渲染视图时就需要一个Map
来做映射,将真实的DOM
节点来映射一个对象,这个对象保存着这个节点的key
,offset
,length
等信息,这样WeakMap
对象就派上了用场,之后在计算的时候就可以直接通过DOM
节点作为key
来获取这个节点的信息,而不需要再去遍历一遍。
浏览器选区同步
我们首先实现浏览器选区同步到编辑器选区的逻辑,我们分别称为DOM
选区以及Model
选区。既然我们将其称之为DOM
选区,那么我们必须要以DOM
节点为基础来获取选区信息,而DOM
的实现是比较复杂的,因此我们必须要兼容各种情况,整体来说我们需要解决如下几个问题:
- 文本节点选区,对于纯文本节点类型的选区处理是最常见的情况,当启用
ContentEditable
状态时,通常情况下光标都落于文本节点上的。在这种情况下,我们只需要通过Selection
获取StaticRange
对象,然后根据编辑器内建State
来转为Model
选区即可。 - 非文本节点选区,对于非文本节点的选区则是相对复杂一些的情况,主要是需要基于编辑器的
DOM
设计模型来处理选区落点问题。当然在编辑器中进行图文混排的情况是比较常见的,行级嵌入的节点例如图片、视频之类的容易处理,行内的嵌入节点例如Mention
节点则更加复杂。 - 选区折叠与反选,对于选区的折叠状态和反选状态,我们需要在编辑器选区对象中进行标记,并且在转换时需要注意处理。折叠选区的情况我们可以直接从
Selection
对象中获取,而反选状态则需要通过Range
对象的起始点和结束点进行判断,主要是通过选区的相关对象判断。 - 浏览器事件兼容,浏览器的默认交互中存在比较多的默认事件来处理,例如双击文本内容时,浏览器会自动选择词组。三击行内容时,浏览器的选区会落在行节点上,我们需要将其重新落到文本节点上。按住
alt
键且按左右键或者执行删除操作时,浏览器都会按词组移动/扩展选区。
那么我们从选区变化的源头开始处理,即从OnSelectionChange
事件起始的回调函数,在该回调函数中我们需要获取Selection
对象,以及静态选区的Range
对象。从Selection
对象中获取静态选区需要注意,因为Firefox
支持多段选区,而其他内核不支持,这里我们只处理首段选区。
// packages/core/src/selection/utils/dom.ts
const sel = window.getSelection();
if (!selection || !selection.anchorNode || !selection.focusNode) {
return null;
}
let range: DOMStaticRange | null = null;
if (selection.rangeCount >= 1) {
range = selection.getRangeAt(0);
}
if (!range) {
const compat = document.createRange();
compat.setStart(selection.anchorNode, selection.anchorOffset);
compat.setEnd(selection.focusNode, selection.focusOffset);
range = compat;
}
return range;
接下来我们需要判断当前的选区是否在编辑器容器节点内,毕竟如果不在编辑器内的选区我们应该忽略掉。紧接着我们需要判断当前选区是否需要处于反选状态,这里的判断非常简单,因为本身Selection
对象和Range
对象提供的节点和偏移是一致的,因此只需要判断其等同性即可。
// packages/core/src/selection/utils/dom.ts
const isBackwardDOMRange = (sel: DOMSelection | null, staticSel: DOMStaticRange | null) => {
if (!sel || !staticSel) return false;
const { anchorNode, anchorOffset, focusNode, focusOffset } = sel;
const { startContainer, startOffset, endContainer, endOffset } = staticSel;
return (
anchorNode !== startContainer ||
anchorOffset !== startOffset ||
focusNode !== endContainer ||
focusOffset !== endOffset
);
};
// packages/core/src/selection/index.ts
const { startContainer, endContainer, collapsed } = staticSel;
if (!root.contains(startContainer)) {
return void 0;
}
if (!collapsed && !root.contains(endContainer)) {
return void 0;
}
const backward = isBackwardDOMRange(sel, staticSel);
接下来就是重点要处理的部分了,Range
对象是基于Node
节点实现的,换句话说Range
对象就跟我们数学上的区间定义一样,是通过起始节点来处理的。那么我们就以节点为基础来处理模型选区的转换,需要将选区节点进行标准化以及转换模型节点,我们以折叠选区为例来处理。
// packages/core/src/selection/utils/native.ts
const { startContainer, endContainer, collapsed, startOffset, endOffset } = staticSel;
const domPoint = { node: startContainer, offset: startOffset };
const anchorDOMPoint = normalizeDOMPoint(domPoint, {
isCollapsed: true,
isEndNode: false,
});
const startRangePoint = toModelPoint(editor, anchorDOMPoint, {
isCollapsed: true,
isEndNode: false,
nodeContainer: startContainer,
nodeOffset: startOffset,
});
const endRangePoint = startRangePoint.clone();
return new Range(startRangePoint, endRangePoint, isBackward);
这其中的normalizeDOMPoint
方法是来处理节点的标准化处理,因为DOM
节点的类型是可以是非常复杂的,我们必须要兼容这些情况,特别是非文本的节点类型。针对于纯文本选区的类型,通常来说我们只需要通过渲染节点对应的State
来得到映射的模型选区节点即可。
而对于非文本的选区节点,我们需要相对更加复杂的处理。首选我们需要明确我们的编辑器选区设计,针对于类似图片等节点,我们放置一个零宽字符的文本节点在放置光标。这样便于我们归一化处理,同样的换行尾节点我们也是使用零宽字符处理,类似语雀是使用<br>
节点来处理的。
那么很明显,如果出现非文本节点的选区,我们需要查找到其内部设计好带标记的零宽字符节点。这种情况下我们只能通过不断迭代的方式来处理,直到我们找到目标的节点为止,理论上而言非文本节点浏览器的选区是落在最外层的contenteditable=false
的节点的,因此我们考虑层级查找即可。
// packages/core/src/selection/utils/native.ts
let { node, offset } = domPoint;
const { isCollapsed, isEndNode } = context;
// 此处说明节点非 Text 节点, 需要将选区转移到 Text 节点
// 例如 行节点、Void、Leaf 节点
if (isDOMElement(node) && node.childNodes.length) {
// 选区节点的偏移可以是最右侧的插值位置, offset 则为其之前的节点总数
let isLast = offset === node.childNodes.length;
let index = isLast ? offset - 1 : offset;
[node, index] = getEditableChildAndIndex(
node,
index,
isLast ? DIRECTION.BACKWARD : DIRECTION.FORWARD
);
// 若是新的 index 小于选区的 offset, 则应该认为需要从新节点末尾开始查找
// 注意此时的 offset 和查找的 index 都是 node 节点的子节点, 因此可以比较
isLast = index < offset;
// 如果仍然是非文本节点, 则继续层级查找
while (isDOMElement(node) && node.childNodes.length) {
const i = isLast ? node.childNodes.length - 1 : 0;
[node] = getEditableChildAndIndex(node, i, isLast ? DIRECTION.BACKWARD : DIRECTION.FORWARD);
}
offset = isLast && node.textContent !== null ? node.textContent.length : 0;
}
return { node, offset };
getEditableChildAndIndex
方法则是用来尝试迭代所有的子节点,来获取parent
中index
处附近的可编辑节点和索引。此外这个方法是优先以direction
方向进行查找,当向前查找和向后查找都无效时,则只能返回最后查找过的节点和索引。
// packages/core/src/selection/utils/dom.ts
const { childNodes } = parent;
let child = childNodes[index];
let i = index;
let triedForward = false;
let triedBackward = false;
// 当前节点为 注释节点/空元素节点/不可编辑元素节点 时, 继续查找下一个可编辑节点
while (
isDOMComment(child) ||
(isDOMElement(child) && child.childNodes.length === 0) ||
(isDOMElement(child) && child.getAttribute(EDITABLE) === "false")
) {
if (triedForward && triedBackward) {
break;
}
if (i >= childNodes.length) {
triedForward = true;
i = index - 1;
// <- 向后查找 -1
direction = DIRECTION.BACKWARD;
continue;
}
if (i < 0) {
triedBackward = true;
i = index + 1;
// -> 向前查找 +1
direction = DIRECTION.FORWARD;
continue;
}
child = childNodes[i];
index = i;
// +1: 向前查找 -1: 向后查找
const increment = direction === DIRECTION.FORWARD ? 1 : -1;
i = i + increment;
}
return [child, index];
而对于toModelPoint
方法,则是最终将标准化的DOMPoint
节点转换为ModelPoint
。在这个方法中我们需要根据文本节点来拿到渲染模型标记的data-leaf
和data-node
两种节点,这两个节点本质上是我们用以实现状态映射的节点,拿到状态表达后,我们就可以计算模型选区了。
// packages/core/src/selection/utils/native.ts
const { offset, node } = domPoint;
const leafNode = getLeafNode(node);
let lineIndex = 0;
let leafOffset = 0;
const lineNode = getLineNode(leafNode);
const lineModel = editor.model.getLineState(lineNode);
// COMPAT: 在没有 LineModel 的情况, 选区会置于 BlockState 最前
if (lineModel) {
lineIndex = lineModel.index;
}
const leafModel = editor.model.getLeafState(leafNode);
// COMPAT: 在没有 LeafModel 的情况, 选区会置于 Line 最前
if (leafModel) {
leafOffset = leafModel.offset + offset;
}
// ... 兼容特殊节点情况
return new Point(lineIndex, leafOffset);
在上边的兼容特殊Case
的情况中,我们先来处理行末尾的\n
节点类型,当前节点为data-zero-enter
时, 需要将其修正为前节点末尾。这样做的主要原因是我们计算选区仅存在一个位置的插值,而作为\n
节点是存在两个位置的插值的,这样会导致多一个位置的偏移,因此需要额外处理。
// packages/core/src/selection/utils/model.ts
// Case 1: 当前节点为 data-zero-enter 时, 需要将其修正为前节点末尾
// content\n[caret] => content[caret]\n
const isEnterZero = isEnterZeroNode(node);
if (isEnterZero && offset) {
leafOffset = Math.max(leafOffset - 1, 0);
return new Point(lineIndex, leafOffset);
}
其实这里还有个比较有趣的问题,我们的目标是标准化全部以纯文本的形式处理选区的光标落点。而类似语雀这种在行末尾使用<br>
换行而不是零宽字符的节点,就仅存在值为0
的单个插值,这就是由于编辑器基础设计不同,而导致需要处理不同表现的DOM
格式。
至此我们实现了编辑器的纯文本的浏览器节点转换到编辑器选区模型的逻辑,当然在这里我们省略了很多细节的处理逻辑,特别是在toModelPoint
方法中实现的很多特殊Case
处理。而对于非纯文本节点,例如图片、视频节点的处理,我们在后续实现Void
节点的时候再细聊。
编辑器选区同步
当浏览器选区变化同步到编辑器选区后,选区同步这件事并没有真正完成。即使现在我们看似是已经根据浏览器选区位置计算出了模型选区位置,但是此时选区的位置是我们所需要的吗,自然是并非如此。我们实现的是编辑器,是需要输入内容的,这样的话我们就必须让选区/光标在我们受控的位置上。
实际上如果仅仅是只读模式下,编辑器仅感知在模型选区的位置是基本可行的,但是在编辑模式下输入内容就不够了。我们此时还需要根据模型选区的位置,再来同步到我们所希望的DOM
节点上,本质上还是遵循我们的受控原则。此外,诸如输入光标跟随、行级工具栏等都需要依赖主动设置选区的能力。
那么这个流程就变成了,浏览器选区变化 -> 编辑器选区变化 -> 浏览器选区设置。我们这里就很容易发现一个问题,设置选区行为就变成了死循环,浏览器变化导致编辑器选区设置,然后又变更了浏览器选区,再次导致选区同步,无限循环下去。那么自然我们需要加入判断,选区无变化时避免再次设置。
// packages/core/src/selection/index.ts
const sel = toDOMRange(this.editor, range);
if (!sel || !sel.startContainer || !sel.endContainer) {
this.editor.logger.warning("Invalid DOM Range", sel, range);
selection.removeAllRanges();
return false;
}
const currentStaticSel = getStaticSelection(selection);
if (isEqualDOMRange(sel, currentStaticSel)) {
return true;
}
// ...
然而只是这样还不足以处理所有问题,浏览器中会存在很多默认行为以及操作来处理选区。例如我们可以通过拖拽光标来快速移动光标,此时会不断触发selection
变更事件,而如果此时我们不断设置选区,再加上鼠标移动光标行为移动选区,会导致大量的事件执行,因此我们需要限制执行。
// packages/core/src/selection/index.ts
/**
* 检查时间片执行次数限制
*/
protected limit() {
const now = Date.now();
// 如果距离上次记录时间超过 500ms, 重置执行次数
if (now - this.lastRecord >= 500) {
this.execution = 0;
this.lastRecord = now;
}
// 如果循环执行次数超过 100 次的限制, 需要打断执行
if (this.execution++ >= 100) {
this.editor.logger.error("Selection Exec Limit", this.execution);
return true;
}
return false;
}
当然试想我们本质上是因为鼠标按下事件移动选区,导致选区不断重设选区并且与我们同步的选区冲突,因此我们是可以在鼠标按下之后的状态下避免主动设置选区。此外,由于鼠标按键抬起时不一定会导致选区变化的事件,因此我们还需要在鼠标抬起时再次设置一次选区。
// packages/core/src/selection/index.ts
public updateDOMSelection(force = false) {
const range = this.current;
if (!range || this.editor.state.get(EDITOR_STATE.COMPOSING)) {
return false;
}
// ...
}
/**
* 强制刷新浏览器选区
*/
@Bind
protected onForceUpdateDOMSelection() {
if (!this.editor.state.get(EDITOR_STATE.FOCUS)) {
return void 0;
}
this.updateDOMSelection(true);
}
描述完了浏览器选区与编辑器选区同步逻辑的问题后,我们来实现toDOMRange
方法,将ModelRange
转换为DOMRange
。实际上相对来说,这里的实现并不会像toModelRange
那么复杂,因为我们的模型选区是简单的格式,而不像DOM
结构那么复杂,且实际对应的DOM
由状态模块控制。
// packages/core/src/selection/utils/native.ts
/**
* 将 ModalRange 转换为 DOMRange
* @param editor
* @param range
*/
export const toDOMRange = (editor: Editor, range: Range): DOMRange | null => {
const { start, end } = range;
const startDOMPoint = toDOMPoint(editor, start);
const endDOMPoint = range.isCollapsed ? startDOMPoint : toDOMPoint(editor, end);
if (!startDOMPoint.node || !endDOMPoint.node) {
return null;
}
const domRange = window.document.createRange();
// 选区方向必然是 start -> end
const { node: startNode, offset: startOffset } = startDOMPoint;
const { node: endNode, offset: endOffset } = endDOMPoint;
const startTextNode = getTextNode(startNode);
const endTextNode = getTextNode(endNode);
if (startTextNode && endTextNode) {
domRange.setStart(startTextNode, Math.min(startOffset, startTextNode.length));
domRange.setEnd(endTextNode, Math.min(endOffset, endTextNode.length));
return domRange;
}
return null;
};
toDOMPoint
方法则是相对比较复杂的实现,我们需要从editor
的状态模块中获取当前的行状态和叶子状态,并且基于状态的映射来获取相应的DOM
。这里的DOM
节点是在react
包里设置的映射关系,这里实际上是与DOM
有关的实现,属于我们需要遵循的规则设计。
// packages/react/src/model/line.tsx
const LineView: FC<{ editor: Editor; index: number; lineState: LineState; }> = props => {
const { editor, lineState } = props;
const setModel = (ref: HTMLDivElement | null) => {
if (ref) {
editor.model.setLineModel(ref, lineState);
}
};
return (
<div
{...{ [NODE_KEY]: true }}
ref={setModel}
dir="auto"
className={cs(runtime.classList)}
style={runtime.style}
>
{runtime.children}
</div>
);
}
// packages/react/src/model/leaf.tsx
const LeafView: FC<{ editor: Editor; index: number; leafState: LeafState; }> = props => {
const { editor, leafState } = props;
const setModel = (ref: HTMLSpanElement | null) => {
if (ref) {
editor.model.setLeafModel(ref, leafState);
}
};
return (
<span
{...{ [LEAF_KEY]: true }}
ref={setModel}
className={runtime.classList.join(" ")}
style={runtime.style}
>
{runtime.children}
</span>
);
};
通过状态与节点的映射,我们就可以拿到其对应的节点了,当然这里获取的节点并不一定可靠,因此需要一些兜底的手段来处理。而后续的逻辑是,我们在LineNode
的基础上查找所有的叶子节点容器DOM
,然后根据每个叶子节点的文本长度来计算偏移量,以此来得到对应的节点及偏移。
// packages/core/src/selection/utils/native.ts
export const toDOMPoint = (editor: Editor, point: Point): DOMPoint => {
const { line, offset } = point;
const blockState = editor.state.block;
const lineState = blockState && blockState.getLine(line);
// 这里理论上可以增加从 DOM 找最近的 LineNode 的逻辑
// 可以防止修改内容后状态变更, 此时立即更新选区导致的节点查找问题
const lineNode = editor.model.getLineNode(lineState);
if (!lineNode) {
return { node: null, offset: 0 };
}
if (isDOMText(lineNode)) {
return { node: lineNode, offset: offset };
}
const selector = `span[${LEAF_STRING}], span[${ZERO_SPACE_KEY}]`;
// 所有文本类型标记的节点, 此处的查找方式倾向于左节点优先
const leaves = Array.from(lineNode.querySelectorAll(selector));
let start = 0;
for (let i = 0; i < leaves.length; i++) {
const leaf = leaves[i];
if (!leaf || leaf instanceof HTMLElement === false || leaf.textContent === null) {
continue;
}
// Leaf 节点的长度, 即处理 offset 关注的实际偏移量
let len = leaf.textContent.length;
if (leaf.hasAttribute(ZERO_SPACE_KEY)) {
// 先通用地处理为 1, 此时长度不应该为 0, 具体长度需要检查 fake len
// 存在由于 IME 破坏该节点内容的情况, 此时直接取 text len 不可靠
len = 1;
}
const end = start + len;
if (offset <= end) {
// Offset 在此处会被处理为相对于当前节点的偏移量
// 例如: text1text2 offset: 7 -> text1te|xt2
// current node is text2 -> start = 5
// end = 5(start) + 5(len) = 10
// offset = 7 < 10 -> new offset = 7(offset) - 5(start) = 2
const nodeOffset = Math.max(offset - start, 0);
return { node: leaf, offset: nodeOffset };
}
start = end;
}
return { node: null, offset: 0 };
};
我们在设置编辑器选区的时候,还需要分离设置模型选区以及设置到浏览器选区的逻辑。这部分设计的主要原因是,我们可以批量处理浏览器变更后的DOM
选区设计,还有在内容输入的时候,我们在apply
的时候会统一处理选区变换,而在视图层异步渲染后再统一更新DOM
选区。
// packages/core/src/selection/index.ts
/**
* 更新选区模型
* @param range 选区
* @param force [?=false] 强制更新浏览器选区
*/
public set(range: Range | null, force = false): void {
if (Range.equals(this.current, range)) {
this.current = range;
// [cursor]\n 状态按右箭头 Model 校准, 但是 DOM 没有校准
// 因此即使选区没有变化, 在 force 模式下也需要更新 DOM 选区
force && this.updateDOMSelection();
return void 0;
}
this.previous = this.current;
this.current = range;
this.editor.logger.debug("Selection Change", range);
this.editor.event.trigger(EDITOR_EVENT.SELECTION_CHANGE, {
previous: this.previous,
current: this.current,
});
if (force) {
this.updateDOMSelection();
}
}
至此,我们已经根据编辑器选区模型转换到了浏览器的具体DOM
节点以及偏移,那么我们就可以使用浏览器的API
具体设置到浏览器上。后续的逻辑处理,就需要根据最开始描述的选区场景、限制执行等方案来进行,此外实际上这里需要大量的Case
处理,同样我们在后边再描述。
// packages/core/src/selection/index.ts
const { startContainer, startOffset, endContainer, endOffset } = sel;
// 这里的 Backward 以 Range 状态为准
if (range.isBackward) {
selection.setBaseAndExtent(endContainer, endOffset, startContainer, startOffset);
} else {
selection.setBaseAndExtent(startContainer, startOffset, endContainer, endOffset);
}
总结
在先前我们基于浏览器的选区API
实现了基本的选区操作,并且基于编辑器状态设计了编辑器模型选区表达,以此来实现编辑器中应用变更时的操作范围表达。紧接着在这里我们实现了编辑器选区和模型选区的双向同步,以此来实现受控的选区操作,这是编辑器中非常重要的基础能力。
接下来我们需要在编辑器选区模块的基础上,根据浏览器的BeforeInput
事件以及Compositing
相关事件,来实现编辑器的内容输入。编辑器的输入是个比较复杂的问题,我们同样需要处理ContentEditable
的复杂DOM
结构默认行为,并且还需要兼容IME
输入法的各种输入场景。