在先前我们基于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 时, 需要将其修正为前节点末尾 // contentn[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输入法的各种输入场景。