React Non-Editable Node Content Rendering

Previously we discussed editable node component presets, including zero-width characters, Embed nodes, Void nodes, etc. Next, we need to discuss non-editable node content rendering, which includes placeholder nodes, read-only mode, plugin mode, external node mounting, etc. These node types are common external nodes in editor design, such as placeholder symbols, popup layers, etc.

  • [Feeling Unskilled, Preparing to Try Building a Rich Text Editor from Scratch](./Building a Rich Text Editor from Scratch.md)
  • [Building a Rich Text Editor from Scratch #2 - Editor Architecture Design Based on MVC Pattern](./MVC-based Editor Architecture Design.md)
  • [Building a Rich Text Editor from Scratch #3 - Linear Data Structure Model Based on Delta](./Delta-based Linear Data Structure Model.md)
  • [Building a Rich Text Editor from Scratch #4 - Core Interaction Strategy of Browser Selection Model](./Core Interaction Strategy of Browser Selection Model.md)
  • [Building a Rich Text Editor from Scratch #5 - State Structure Expression of Editor Selection Model](./State Structure Expression of Editor Selection Model.md)
  • [Building a Rich Text Editor from Scratch #6 - Synchronization Between Browser Selection and Editor Selection Model](./Synchronization Between Browser and Editor Selection Models.md)
  • [Building a Rich Text Editor from Scratch #7 - Semi-Controlled Input Mode Based on Composition Events](./Semi-Controlled Input Mode Based on Composition Events.md)
  • [Building a Rich Text Editor from Scratch #8 - Uncontrolled DOM Behavior of Browser Input Mode](./Uncontrolled DOM Behavior of Browser Input Mode.md)
  • [Building a Rich Text Editor from Scratch #9 - Controlled Processing of Editor Text Structure Changes](./Controlled Processing of Editor Text Structure Changes.md)
  • [Building a Rich Text Editor from Scratch #10 - Pattern Extension of React View Layer Adapter](./Pattern Extension of React View Layer Adapter.md)
  • [Building a Rich Text Editor from Scratch #11 - Immutable State Maintenance and Incremental Rendering](./Immutable State Maintenance and Incremental Rendering.md)
  • [Building a Rich Text Editor from Scratch #12 - React Editable Node Component Presets](./React Editable Node Component Presets.md)
  • [Building a Rich Text Editor from Scratch #13 - React Non-Editable Node Content Rendering](./React Non-Editable Node Content Rendering.md)

Placeholder Nodes

In editors, when the content is empty, it's usually necessary to render a placeholder node to prompt users to input content. In browser input and textarea elements, there are native placeholder implementations. However, in editors, this placeholder functionality needs to be implemented manually, as browsers don't provide native placeholder nodes in ContentEditable mode.

In open-source editors, both quill and slate provide placeholder implementations, which are quite typical. quill's placeholder is implemented using CSS pseudo-elements. The advantage of using pseudo-elements is that they don't affect the browser's DOM structure at all, thus not affecting selection models and other designs. The overall structure looks like this:

<div data-placeholder="Please enter content">
  ::before
  <div data-node><span data-leaf>&ZeroWidthSpace;</span></div>
</div>
.block-kit-x-editable div[data-block][data-placeholder]::before {
  color: #bbbfc4;
  content: attr(data-placeholder);
  height: 0;
  pointer-events: none;
  position: absolute;
}

Here, content can directly render the attribute value from the DOM to the placeholder node, i.e., the data-placeholder attribute value. This allows controlling the attribute value through JavaScript to handle placeholder content. absolute positioning is mainly to make it leave the DOM document flow without affecting selection positioning, and pointer-events is to avoid event interactions.

The most important point of using pseudo-elements is that in ContentEditable mode, the browser won't allow users to edit content generated by ::before or ::after pseudo-elements. We cannot select pseudo-elements, and they don't participate in cursor or selection calculations because pseudo-elements don't belong to the DOM tree, and ContentEditable only works on real DOM nodes and their text content.

In contrast, slate's implementation has two special design aspects. First, it renders the placeholder node directly into the Editable editing area, allowing reuse of React rendering nodes as the entire placeholder. Second, the placeholder node is rendered within the leaf area, meaning the editor's text styles will also apply to the placeholder node.

For React placeholder node rendering, theoretically it only needs to be rendered as a parameter into the Editable editing area. But we need to implement something similar to the pseudo-element approach to ensure placeholder content cannot be edited by users, which requires CSS control using position + user-select + pointer-events.

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

Next is the issue of applying text styles. The difference mainly lies in the placement position of text nodes. Similar to the pseudo-element implementation above, if placed directly under the container's direct child elements, the set styles naturally won't apply to the placeholder node. But if placed within the leaf area, styles can be applied to the placeholder node.

<div>
  <span>Please enter content</span> <!-- Placeholder content that cannot apply styles -->
  <div data-node>
    <span data-leaf>&ZeroWidthSpace;</span>
    <span>Please enter content</span> <!-- Placeholder content that can apply styles -->
  </div>
</div>

Additionally, there's a particularly important point to note: when IME is performing Composing, placeholder nodes theoretically shouldn't be displayed. If we directly listen to composing events in the editing area, it would cause the selection model to recalculate, leading to selection model abnormalities when inputting content. Therefore, we need to extract this into an independent component to avoid upper-level layout effect.

/**
 * Placeholder Component
 * - Main goal of component extraction is to avoid parent component's LayoutEffect execution
 */
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 Mode

In our editor, editing mode mainly depends on the ContentEditable attribute value. In read-only mode, we only need to set the ContentEditable attribute value to false. Theoretically, this is purely a view layer behavior, requiring only DOM attribute control in React.

<div
  {...{ [EDITOR_KEY]: true }}
  contentEditable={!readonly}
>
  <BlockModel></BlockModel>
</div>

In addition, in modules like toolbars, images, Mention, etc., additional control panels are usually needed to edit related content. In read-only mode, these need to perceive state changes. In React, we can directly perceive state changes through Context, thus enabling state change awareness.

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

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

const { readonly } = useReadonly();

Theoretically, changes in the editor's read-only state need to be perceived; otherwise, it could lead to inconsistent editor states. However, in practical applications, there's no current need for such scenarios, so this hasn't been implemented yet. Currently, after the view's read-only state changes, we set the editor's read-only state without triggering related events.

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

Plugin Rendering Mode

In the Core core service, we've already implemented a plugin rendering pattern. This plugin pattern works fine for basic style types. However, when implementing plugins that require combined types like hyperlinks, blockquotes, etc., special handling is needed. These node types don't need to hold state; they only need to render based on state during rendering.

For example, when implementing hyperlinks, if we render by splitting text nodes in the basic way, we'd get the following situation. Particularly, if there are styles like bold or italic, the content would be split, which, while not causing major issues, would result in a poorer experience, such as underlines appearing in segments rather than as a whole when hovering.

<b><a href="xx">part a</a></b>
<i><a href="xx">part b</a></i>

Therefore, theoretically, hyperlink rendering needs special handling - the a tag needs to be rendered into a container as a whole, rather than splitting text nodes. Of course, during actual input, a tags can disrupt the DOM structure during IME input; this content can be referenced in the wrapper node section of series #8.

<a href="xx">
  <b>part a</b>
  <i>part b</i>
</a>

Therefore, in React, we also need to implement a rendering-time plugin pattern, which means rendering plugins based on state during rendering. Here, we only need to extend the plugin pattern in the Core core service and then schedule these modules in React rendering components. But before that, we need to design a rendering wrapper pattern strategy.

If it were just a single key implementing rendering-time nesting, it wouldn't be a complex problem. But when multiple keys exist simultaneously, it becomes a perplexing problem. In the example below, if we merge 34 separately with b, then wrap with a, it seems reasonable. But merging 34 with a first, then merging with 5's b is also reasonable. There might even be a way to merge 67 together since they all have b tags.

1 2 3  4  5 6  7 8 9 0
a a ab ab b bc b c c c

This problem is quite complex. Following the principle of simplicity and extensibility, we eventually came up with a simple implementation: for elements that need wrapper, if their merged list's key and value are all identical, then merge them as the same value. This makes the situation much simpler - we consider it a combined value rather than individual values, which is sufficient for most scenarios.

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

Next, we need to handle rendering according to this pattern. First, since this is a pure rendering pattern, we need to implement a Map to map rendered jsx to state. The reason for not mapping state to jsx is to maintain compatibility with existing elements - jsx return values.

const elements = useMemo(() => {
  const leaves = lineState.getLeaves();
  // First render all non-EOL leaf nodes
  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]);

Next, we combine wrapper nodes according to the order of elements, requiring only an O(n) traversal. We need to set a key value for the state to determine whether the current node and secondary traversal nodes need merging. If merging is needed, we enter the merging logic.

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;
};

Then we can traverse elements to combine wrapper nodes. Each node needs to check if the next node needs merging. We perform a secondary iteration sequentially; when consecutive symbol values are equal, it indicates they need merging. Special attention is needed here: if the next node cannot be merged, we need to roll back i so the outer main loop can recheck.

// Reaching here means related nodes need wrapping (even if only a single node)
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) {
    // Roll back to previous value for next loop recheck
    i = k - 1;
    break;
  }
  nodes.push(next);
  i = k;
}

Finally, we only need to schedule plugins to render specific React nodes. This part relies entirely on React's rendering mechanism, with the key value currently using the start and end indices directly. However, this key value might need to be generated based on symbol in the future to ensure proper handling during merging.

// Render wrapper nodes through plugins
let wrapper: React.ReactNode = nodes;
const op = line.op;
for (const plugin of plugins) {
  // State here is based on the first node
  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 External Node Mounting

When implementing modules like Mention, text selection rewriting, etc., additional auxiliary nodes are usually needed to render panels. For example, Mention needs to activate additional panels to select objects to @, and implement interactions like up/down selection, enter key, etc.

In such cases, Mention panels are typically not rendered inside the editor; additional nodes are needed to render these panels. Therefore, when implementing editor modules, an additional mount-dom is rendered as a container for auxiliary nodes, providing the original DOM structure for ReactDOM to render.

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>

When using ReactDOM.render to render nodes, we cannot directly use this node as a container because calling it doesn't directly append React nodes to DOM nodes; instead, it directly renders React nodes onto that node. Therefore, in this case, if multiple auxiliary nodes need mounting, it cannot be accomplished.

ReactDOM.render("string", document.getElementById("root"));

Therefore, when rendering auxiliary elements, we first need to use this node as a container, create a new container child node, then use that node as a container to call the ReactDOM.render method to render React nodes. Initially, the editor's Mention panel was implemented like this:

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);

Then we need to consider a question: when we use ReactDOM.createPortal to transport to target nodes, it's more similar to an append node approach rather than needing to create containers first and then render nodes as above. Additionally, we can use Context to pass editor state at this time.

But createPortal cannot directly render nodes like the render method; it only creates a Portal node rather than actually performing rendering behavior. Therefore, we ultimately cannot avoid needing an actual rendering behavior, working together like the following implementation, which allows creating elements actually on the body.

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

If we use an approach similar to Lexical's implementation discussed earlier, independently controlling a Portals placeholder to render auxiliary nodes, we can avoid using the render method to render nodes. Additionally, we can directly append nodes in mount-dom without creating child containers, and this method can avoid React 18's createRoot method Breaking Change.

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>
  );
};

Summary

Previously we discussed zero-width characters, Embed nodes, Void nodes, etc., mainly focusing on editable node component presets. This article mainly discusses non-editable node content rendering, including placeholder nodes, read-only mode, plugin mode, external node mounting, etc., primarily implementing editor external nodes like placeholder symbols, popup layer structures, etc.

With this, our editor's React view layer adaptation is complete, allowing reuse of React's ecosystem components and reducing view layer development costs. Next, we need to handle the core modules of the Core service, which collectively handle editor interaction logic like clipboard Clipboard, history History, state management State, etc.

Daily Question

References