从零实现富文本编辑器#13-React非编辑节点的内容渲染

先前我们讨论了是编辑节点的组件预设,包括零宽字符、Embed节点、Void节点等,接下来我们需要讨论的是非编辑节点内容渲染,也就是占位节点、只读模式、插件模式、外部节点挂载等。这些节点类型在编辑器的设计中处于常见的外部节点,例如占位符号、弹出层等。

从零实现富文本编辑器系列文章

Placeholder 占位节点

在编辑器中,在内容为空的情况下,通常需要渲染一个占位节点来提示用户输入内容。在浏览器的inputtextarea中,都存在原生的占位节点实现。而在编辑器中,这部分占位节点就需要自行实现,浏览器在ContentEditable模式并不存在原生的占位节点。

在开源的编辑器中,quillslate都提供了占位节点的实现,并且还是属于典型的实现。quill的占位节点是使用CSS的伪元素来实现的,使用伪元素的好处是,完全不会影响到浏览器的DOM结构,这样也就不会影响到选区模型等设计,整体结构类似下面的内容。

html 复制代码
<div data-placeholder="请输入内容">
  ::before
  <div data-node><span data-leaf>&ZeroWidthSpace;</span></div>
</div>
css 复制代码
.block-kit-x-editable div[data-block][data-placeholder]::before {
  color: #bbbfc4;
  content: attr(data-placeholder);
  height: 0;
  pointer-events: none;
  position: absolute;
}

在这里,content是可以直接将DOM上的属性值渲染到占位节点上的,即data-placeholder属性值,这样就可以通过Js来控制属性值,进而处理占位节点的内容了。absolute主要是为了使其脱离DOM文档流,不影响选区的定位,pointer-events则是为了避免事件交互。

其实用伪元素实现的最重要的点是,在ContentEditable模式下,浏览器不会让用户编辑::before::after伪元素生成的内容。我们无法选中伪元素,其也不会参与光标、选区的计算。因为伪元素不属于DOM树,而ContentEditable只作用于真实的DOM节点及其文本内容。

而类似slate的实现,则存在两部分特殊的设计。首先是将占位节点直接渲染到Editable编辑区域内,这样就可以复用React的渲染节点作为整个占位节点。再者是占位节点是渲染在leaf区域内,这也就意味着编辑器的文本样式也会应用到占位节点上。

针对React占位节点的渲染,理论上而言之需要将其作为参数渲染到Editable编辑区域内即可。但是我们需要实现类似上述伪元素的实现,来确保占位节点的内容不会被用户编辑,那么这部分就需要用CSS来控制,即position + user-select + pointer-events

js 复制代码
<div
  {...{ [PLACEHOLDER_KEY]: true }}
  style={{
    position: "absolute",
    opacity: "0.3",
    userSelect: "none",
    pointerEvents: "none",
  }}
>
  {props.placeholder}
</div>

接下来是设置的文本样式应用问题,这里的差异主要在于文本节点的放置位置。类似于上述的伪元素实现,如果直接放在容器直属元素下的话,设置的样式自然是不会应用到占位节点上的。而若是放在leaf区域内,自然就可以将样式应用到占位节点上。

html 复制代码
<div>
  <span>请输入内容</span> <!-- 无法应用样式的占位节点内容 -->
  <div data-node>
    <span data-leaf>&ZeroWidthSpace;</span>
    <span>请输入内容</span> <!-- 可以应用样式的占位节点内容 -->
  </div>
</div>

此外,还有个特别需要关注的点,在IME进行Composing的时候,理论上是不应该显示占位节点的。而此时如果直接在编辑区域监听composing事件,则会导致选区模型重新计算,此时输入内容则会出现选区模型异常的情况。因此在这里需要独立抽离组件,避免上层的layout effect

js 复制代码
/**
 * 占位符组件
 * - 抽离组件的主要目标是避免父组件的 LayoutEffect 执行
 */
export const Placeholder: FC<{
  editor: Editor;
  lines: LineState[];
  placeholder: React.ReactNode | undefined;
}> = props => {
  const { isComposing } = useComposing(props.editor);
  return props.placeholder &&
    !isComposing &&
    props.lines.length === 1 &&
    isEmptyLine(props.lines[0], true) ? (
    <div {...{ [PLACEHOLDER_KEY]: true }}>
      {props.placeholder}
    </div>
  ) : null;
};

Readonly 只读模式

在我们的编辑器中,编辑模式主要是依赖于ContentEditable的属性值,那么在只读模式下,之需要将ContentEditable的属性值设置为false即可。理论上而言这完全是视图层的行为,之需要在React中实现DOM属性控制即可。

js 复制代码
<div
  {...{ [EDITOR_KEY]: true }}
  contentEditable={!readonly}
>
  <BlockModel></BlockModel>
</div>

除此之外,在诸如工具栏、图片、Mention等模块中,通常需要额外的控制面板来编辑相关内容,那么在只读模式下,就需要感知到状态的变化。而在React中,我们可以直接通过Context来感知到状态的变化,从而可以实现状态变化的感知。

js 复制代码
<ReadonlyContext.Provider value={!!readonly}>
  {children}
</ReadonlyContext.Provider>
js 复制代码
const ReadonlyContext = createContext<boolean>(false);
ReadonlyContext.displayName = "Readonly";

const useReadonly = () => {
  const readonly = React.useContext(ReadonlyContext);
  return { readonly };
};

const { readonly } = useReadonly();

理论上而言,编辑器的只读状态变更是需要被感知到的,否则会导致编辑器的状态不一致。不过在实际应用中,暂时还没有需要的场景,因此这里还没有实现,当前主要是在视图只读状态变化之后,设置编辑器的只读状态,而没有触发相关事件。

js 复制代码
export const BlockKit: React.FC<BlockKitProps> = props => {
  if (editor.state.get(EDITOR_STATE.READONLY) !== readonly) {
    editor.state.set(EDITOR_STATE.READONLY, readonly || false);
  }
}

Plugin 渲染插件模式

Core核心服务中,我们已经实现了一套插件的渲染模式,这部分插件模式对于基本类型的样式是没什么问题的。然而,在实现诸如超链接、引用块这些需要组合类型的插件时,就需要特殊处理,这些类型的节点不需要持有状态,只需要在渲染时根据状态来渲染即可。

举个例子,当实现超链接时,按照基本的拆离文本节点的方式来渲染,那么就会出现下面的情况。特别是,如果是加粗或者斜体等样式,那么就会出现拆离内容的情况,虽然并不会造成特别大的影响,但是体验上会稍显差一些,例如hover上去出现的下划线是一段段的而非整体。

html 复制代码
<b><a href="xx">part a</a></b>
<i><a href="xx">part b</a></i>

因此理论上而言,超链接的渲染需要特殊处理,a标签整个需要被渲染到一个容器中,而不是拆离文本节点的方式来渲染。当然,在实际输入的过程中,a标签在IME输入的时候,本身会破坏DOM结构,这部分内容可以参考本系列#8的包装节点部分。

html 复制代码
<a href="xx">
  <b>part a</b>
  <i>part b</i>
</a>

因此在React中,我们还需要实现一套渲染时的插件模式,也就是在渲染时根据状态来渲染插件。在这里之需要扩展Core核心服务中的插件模式,然后在React渲染组件中调度这部分模块。不过在此之前,还需要设计一个渲染包装模式的策略。

如果仅仅是单个key来实现渲染时嵌套并不是什么复杂问题,而同时存在多个key则变成了令人费解的问题。如下面的例子中,如果将34单独合并b,外层再包裹a似乎是合理的,但是将34先包裹a后再合并5b也是合理的,甚至有没有办法将67一并合并,因为其都存在b标签。

html 复制代码
1 2 3  4  5 6  7 8 9 0
a a ab ab b bc b c c c

这个问题比较复杂,本着简单可扩展的原则,最终想到了个简单的实现,对于需要wrapper的元素,如果其合并listkeyvalue全部相同的话,那么就作为同一个值来合并。那么这种情况下就变的简单了很多,我们将其认为是一个组合值,而不是单独的值,在大部分场景下是足够的。

html 复制代码
1 2 3  4  5 6  7 8 9 0
a a ab ab b bc b c c c
12 34 5 6 7 890

那么接下来就需要按照这部分模式来处理渲染,首先这是一套纯渲染模式,那么我们就需要实现一个Map来映射渲染的jsxstate。而为什么不是state映射jsx,则是为了兼容现有的elements - jsx返回值。

js 复制代码
const elements = useMemo(() => {
  const leaves = lineState.getLeaves();
  // 首先渲染所有非 EOL 的叶子节点
  const textLeaves = leaves.slice(0, -1);
  const nodes = textLeaves.map(n => {
    const node = <LeafModel key={n.key} editor={editor} leafState={n} />;
    JSX_TO_STATE.set(node, n);
    return node;
  });
  return nodes;
}, [editor, lineState]);

接下来,就根据elements的顺序来组合包装节点了,在这里之需要一个O(n)的遍历即可。我们需要为状态设置一个key值,以便于判断当前节点和二级的遍历节点是否需要合并,如何需要合并则进入合并逻辑。

js 复制代码
export const getWrapSymbol = (keys: string[], el: JSX.Element | undefined): string | null => {
  const attrs = state.op.attributes;
  const suite: string[] = [];
  for (const key of keys) {
    attrs[key] && suite.push(`${key}${attrs[key]}`);
  }
  const symbol = suite.join("");
  return symbol;
};

紧接着就可以遍历elements来组合包装节点了,每个节点都需要判断下一个节点是否需要合并。顺序进行二次迭代,当出现连续的symbol相等时,说明是需要合并的,这里特别注意如果下一个节点不能合并,则需要回退i,以便于外层主循环时重新检查。

js 复制代码
// 执行到此处说明需要包装相关节点(即使仅单个节点)
const nodes: JSX.Element[] = [element];
for (let k = i + 1; k < len; ++k) {
  const next = elements[k];
  const nextSymbol = getWrapSymbol(keys, next);
  if (!next || !nextSymbol || nextSymbol !== symbol) {
    // 回退到上一个值, 以便下次循环时重新检查
    i = k - 1;
    break;
  }
  nodes.push(next);
  i = k;
}

最后,我们之需要调度插件来渲染具体的React节点就可以了,这部分就是完全依靠React的渲染机制来实现,而其中key值目前则是直接使用了起始和结束的索引值。不过后续这个key值可能需要根据symbol来生成,以确保在合并时能够正确处理。

js 复制代码
// 通过插件渲染包装节点
let wrapper: React.ReactNode = nodes;
const op = line.op;
for (const plugin of plugins) {
  // 这里的状态以首个节点为准
  const context: ReactWrapLineContext = {
    lineState: line,
    children: wrapper,
  };
  if (plugin.match(line.op.attributes || {}, op) && plugin.wrapLine) {
    wrapper = plugin.wrapLine(context);
  }
}
const key = `${i - nodes.length + 1}-${i}`;
wrapped.push(<React.Fragment key={key}>{wrapper}</React.Fragment>);

Portal 外部节点挂载

在实现诸如Mention、划词改写等模块时,通常需要额外的辅助节点来渲染面板,例如Mention需要唤醒额外的面板来选择要at的对象,并且需要在此基础上实现诸如上下选择、回车等交互。

这种情况下,Mention面板通常是不会渲染在编辑器内部的,需要额外的节点来渲染这个面板。因此在实现编辑器模块时,是额外渲染了一个mount-dom作为辅助节点的容器,以此作为原始的DOM结构提供给ReactDOM来渲染。

js 复制代码
const onMountRef = (e: HTMLElement | null) => {
  e && MountNode.set(editor, e);
};

<BlockKit editor={editor} readonly={readonly}>
  <div className="block-kit-editable-container">
    <div className="block-kit-mount-dom" ref={onMountRef}></div>
    <Editable></Editable>
  </div>
</BlockKit>

ReactDOM.render来渲染节点时,是不能够直接将该节点作为容器的,因为调用时并非直接追加React节点到DOM节点,而是直接将React节点渲染到该节点上。因此这种情况下,若是存在多个需要挂载的辅助节点,是无法完成的。

js 复制代码
ReactDOM.render("string", document.getElementById("root"));

因此这里渲染辅助元素时,需要先将此节点作为容器,创建一个新的容器子节点,然后将该节点作为容器调用ReactDOM.render方法来渲染React节点。在最开始的时候,编辑器中的Mention面板是类似下面的实现:

js 复制代码
if (!this.mountSuggestNode) {
  this.mountSuggestNode = document.createElement("div");
  this.mountSuggestNode.dataset.type = "mention";
  MountNode.get(this.editor).appendChild(this.mountSuggestNode);
}
const top = this.rect.top;
const left = this.rect.left;
const dom = this.mountSuggestNode!;
this.isMountSuggest = true;
ReactDOM.render(<Suggest controller={this} top={top} left={left} text={text} />, dom);

然后我们需要思考一个问题,在我们使用ReactDOM.createPortal来传送到目标节点时,更加类似于追加节点的方式来实现,而不是需要向上述的方式一样先创建容器再渲染节点,并且此时还可以使用Context来传递编辑器的状态。

但是createPortal没有办法像render方法那样可以直接渲染节点,其只是创建了一个Portal节点,而不是实际进行了渲染行为。因此,最终还是无法避免需要一个实际渲染的行为,相互配合起来类似于下面的实现,这样就可以将元素实际创建到body上。

js 复制代码
const portal = ReactDOM.createPortal(
  <Suggest controller={this} top={top} left={left} text={text} />,
  document.body,
);
ReactDOM.render(portal, this.mountSuggestNode!);

那么如果类似于先前聊的Lexical的实现方式,独立控制一个Portals占位来渲染辅助节点,就可以避免使用render方法来渲染节点,并且可以直接在mount-dom追加节点而不需要再创建子容器,并且直接使用这种方法可以避免React 18createRoot方法Breaking Change

js 复制代码
const PortalView: FC<{ editor: Editor }> = props => {
  const [portals, setPortals] = useState<O.Map<ReactPortal>>({});
  EDITOR_TO_PORTAL.set(props.editor, setPortals);

  return (
    <Fragment key="block-kit-portal-model">
      {Object.entries(portals).map(([key, node]) => (
        <Fragment key={key}>{node}</Fragment>
      ))}
    </Fragment>
  );
};

总结

先前我们讨论了零宽字符、Embed节点、Void节点等,主要是可编辑节点的组件预设。在本文中则主要讨论的是非编辑节点内容渲染,也就是占位节点、只读模式、插件模式、外部节点挂载等,主要是实现编辑器的外部节点,例如占位符号、弹出层结构等。

那么至此我们实现的编辑器的React视图层适配已经完成了,以此可以复用React的生态组件,降低了开发视图层的成本。接下来我们需要再处理Core服务的核心模块,其共同处理了编辑器的交互逻辑,例如剪贴板Clipboard、历史记录History、状态管理State等等。

每日一题

参考

相关推荐
四千岁2 小时前
Ollama+OpenWebUI 最佳组合:本地大模型可视化交互方案
前端·javascript·后端
写不来代码的草莓熊2 小时前
el-date-picker ,自定义输入数字自动转换显示yyyy-mm-dd HH:mm:ss格式
前端·javascript·vue.js
ssshooter2 小时前
Tauri 应用苹果签名踩坑实录
前端·架构·全栈
DeSheng2 小时前
npm 从入门到精通(二):再理解,彻底搞懂 package.json、node_modules 和 package-lock
前端
用户69371750013842 小时前
XChat 为什么选择 Rust 语言开发
android·前端·ios
程序员柒叔2 小时前
OpenCode 一周动态-2026-W15
后端·github
局i2 小时前
从零搭建 Vite + React 项目:从环境准备到干净项目的完整指南
前端·react.js·前端框架
Wect2 小时前
LeetCode 149. 直线上最多的点数:题解深度剖析
前端·算法·typescript
Wect2 小时前
JS手撕:手写Koa中间件与Promise核心特性
前端·javascript·面试