Skip to content

Commit

Permalink
Don't setContent on RichTextEditor content updates, and add docs
Browse files Browse the repository at this point in the history
This is related to the discussion here
#91, and should make the
component more generally flexible, with the option for users to
customize when/how they want to `setContent`.
  • Loading branch information
sjdemartini committed Jul 15, 2023
1 parent 18427ba commit cebec1f
Show file tree
Hide file tree
Showing 3 changed files with 81 additions and 55 deletions.
66 changes: 55 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
- [Use the all-in-one component](#use-the-all-in-one-component)
- [Create and provide the `editor` yourself](#create-and-provide-the-editor-yourself)
- [Render read-only rich text content](#render-read-only-rich-text-content)
- [Extensions and components](#extensions-and-components)
- [mui-tiptap extensions and components](#mui-tiptap-extensions-and-components)
- [Tiptap extensions](#tiptap-extensions)
- [`HeadingWithAnchor`](#headingwithanchor)
- [`FontSize`](#fontsize)
Expand All @@ -62,6 +62,7 @@
- [Choosing your editor `extensions`](#choosing-your-editor-extensions)
- [Extension precedence and ordering](#extension-precedence-and-ordering)
- [Other extension tips](#other-extension-tips)
- [Re-rendering `RichTextEditor` when `content` changes](#re-rendering-richtexteditor-when-content-changes)
- [Contributing](#contributing)

</details>
Expand Down Expand Up @@ -100,7 +101,7 @@ yarn add @mui/material @mui/icons-material @emotion/react @emotion/styled react-

### Use the all-in-one component

The simplest way to render a rich text editor is to use the `<RichTextEditor />` component:
The simplest way to render a rich text editor is to use the `RichTextEditor` component:

```tsx
import { Button } from "@mui/material";
Expand All @@ -116,8 +117,8 @@ function App() {
<RichTextEditor
ref={rteRef}
extensions={[StarterKit]} // Or any Tiptap extensions you wish!
content="<p>Hello world</p>"
// Optionally include `renderControls` for a menu-bar atop the editor
content="<p>Hello world</p>" // Initial content for the editor
// Optionally include `renderControls` for a menu-bar atop the editor:
renderControls={() => (
<MenuControlsContainer>
<MenuSelectHeading />
Expand All @@ -137,15 +138,15 @@ function App() {
}
```

Check out [Extensions and components](#extensions-and-components) below to learn about extra Tiptap extensions and components (like more to include in `renderControls`) that you can use. See [`src/demo/Editor.tsx`](./src/demo/Editor.tsx) for a more thorough example of using `<RichTextEditor />`.
Check out [mui-tiptap extensions and components](#mui-tiptap-extensions-and-components) below to learn about extra Tiptap extensions and components (like more to include in `renderControls`) that you can use. See [`src/demo/Editor.tsx`](./src/demo/Editor.tsx) for a more thorough example of using `RichTextEditor`.

### Create and provide the `editor` yourself

If you need more customization, you can instead define your editor using Tiptap’s `useEditor` hook, and lay out your UI using a selection of `mui-tiptap` components (and/or your own components).

Pass the `editor` to `mui-tiptap`’s `<RichTextEditorProvider>` component at the top of your component tree. From there, provide whatever children to the provider that fit your needs.
Pass the `editor` to `mui-tiptap`’s `RichTextEditorProvider` component at the top of your component tree. From there, render whatever children within the provider that fit your needs.

The easiest is option is the `<RichTextField />` component, which is what `RichTextEditor` uses under the hood:
The easiest is option is the `RichTextField` component, which is what `RichTextEditor` uses under the hood:

```tsx
import { useEditor } from "@tiptap/react";
Expand Down Expand Up @@ -187,15 +188,18 @@ Or if you want full control over the UI, instead of `RichTextField`, you can bui

### Render read-only rich text content

Use the `<RichTextReadOnly />` component and just pass in your HTML or ProseMirror JSON and your configured Tiptap extensions, like:
Use the `RichTextReadOnly` component and just pass in your HTML or ProseMirror JSON and your configured Tiptap extensions, like:

```tsx
<RichTextReadOnly content="<p>Hello world</p>" extensions={[StarterKit]} />
```

This component will skip creating the Tiptap `editor` if `content` is empty, which can help performance.
Alternatively, you can set the `RichTextEditor` `editable` prop (or `useEditor` `editable` option) to `false` for a more configurable read-only option. Use `RichTextReadOnly` when:

## Extensions and components
- You just want to efficiently render editor HTML/JSON content directly, without any outlined field styling, controls setup, extra listener logic, access to the `editor` object, etc. (This component also skips creating the Tiptap `editor` if `content` is empty, which can help performance.)
- You want a convenient way to render content that updates as the `content` prop changes. (`RichTextEditor` by contrast does not re-render automatically on `content` changes, as [described below](#re-rendering-richtexteditor-when-content-changes).)

## mui-tiptap extensions and components

### Tiptap extensions

Expand Down Expand Up @@ -333,12 +337,52 @@ Extensions that need to be higher precedence (for their keyboard shortcuts, etc.
});
```

- If youd prefer to be able to style your `Code` marks (e.g., make them bold, add links, change font size), you should extend the extension and override the `excludes` field, since by default it uses `"_"` to [make it mutually exclusive from all other marks](https://tiptap.dev/api/schema#excludes). For instance, to allow you to apply `Code` with any other inline mark, use `excludes: ""`, or to make it work with all except italics, use:
- If youd prefer to be able to style your inline [`Code`](https://tiptap.dev/api/marks/code) marks (e.g., make them bold, add links, change font size), you should extend the extension and override the `excludes` field, since by default it uses `"_"` to [make it mutually exclusive from all other marks](https://tiptap.dev/api/schema#excludes). For instance, to allow you to apply `Code` with any other inline mark, use `excludes: ""`, or to make it work with all except italics, use:

```ts
Code.extend({ excludes: "italic" });
```

### Re-rendering `RichTextEditor` when `content` changes

By default, `RichTextEditor` uses `content` the same way that Tiptaps `useEditor` does: it sets the initial content for the editor, and subsequent changes to the `content` variable will _not_ change what content is rendered. (Only the users editor interaction will.) This can avoid annoyances like overwriting the content while a user is actively typing or editing.

It is not efficient to use `RichTextEditor`/`useEditor` as a fully [“controlledcomponent](https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components) where you change `content` on each call to the editor’s `onUpdate`, due to the fact that editor content must be serialized to get the HTML string (`getHTML()`) or ProseMirror JSON (`getJSON()`) (see [Tiptap docs](https://tiptap.dev/guide/output#export) and [this discussion](https://github.com/sjdemartini/mui-tiptap/issues/91#issuecomment-1629911609)).

But if you need this behavior in certain situations, like you have changed the `content` external to the component and the users editor interaction, you can do something like the following in order to update the content via hook:

```ts
useEffect(() => {
// Use queueMicrotask per https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730
queueMicrotask(() => {
editor?.commands.setContent(content);
});
}, [content, editor]);
```

Or if you wanted to try to preserve the users current selection/caret, and only update when the editor is read-only or unfocused (to try to avoid losing any in-progress changes the user is making):

```ts
useEffect(() => {
if (!editor || editor.isDestroyed) {
return;
}
if (!editor.isFocused || !editor.isEditable) {
const currentSelection = editor.state.selection;
// Use queueMicrotask per https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730
queueMicrotask(() => {
editor
?.chain()
.setContent(content)
.setTextSelection(currentSelection)
.run();
});
}
}, [content, editor, editor?.isEditable, editor?.isFocused]);
```

You could also alternatively pass `content` as an editor dependency via `<RichTextEditor … editorDependencies={[content]} />` (or equivalently include it in your `useEditor` dependency array), and this will force-recreate the entire editor upon changes to the value. This is the least efficient option, and it can cause a visualflashas it is rebuilt.

## Contributing

Get started [here](./CONTRIBUTING.md).
41 changes: 9 additions & 32 deletions src/RichTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
type DependencyList,
} from "react";
import type { Except, SetRequired } from "type-fest";
Expand Down Expand Up @@ -38,7 +37,9 @@ export interface RichTextEditorProps
* you can access the editor via the parameter to this render prop, or in a
* child component via `useRichTextEditorContext()`. Useful for including
* plugins like mui-tiptap's LinkBubbleMenu and TableBubbleMenu, or other
* custom components (e.g. a menu that utilizes Tiptap's FloatingMenu).
* custom components (e.g. a menu that utilizes Tiptap's FloatingMenu). (This
* is a render prop rather than just a ReactNode for the same reason as
* `renderControls`; see above.)
*/
children?: (editor: Editor | null) => React.ReactNode;
/**
Expand All @@ -58,6 +59,12 @@ export type RichTextEditorRef = {
* An all-in-one component to directly render a MUI-styled Tiptap rich text
* editor field.
*
* NOTE: changes to `content` will not trigger re-rendering of the component.
* i.e., by default the `content` prop is essentially "initial content". To
* change content after rendering, you can use a hook and call
* `rteRef.current?.editor?.setContent(newContent)`. See README "Re-rendering
* `RichTextEditor` when `content` changes" for more details.
*
* Example:
* <RichTextEditor ref={rteRef} content="<p>Hello world</p>" extensions={[...]} />
*/
Expand All @@ -75,10 +82,6 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
}: RichTextEditorProps,
ref
) {
// TODO(Steven DeMartini): We should perhaps wrap the `onUpdate`, `onBlur`,
// etc. callbacks in refs that we pass in here, so that changes to those
// props will take effect. Note though that this is handled in tiptap 2.0.0
// itself thanks to https://github.com/ueberdosis/tiptap/pull/3811
const editor = useEditor(
{
editable: editable,
Expand All @@ -103,32 +106,6 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
queueMicrotask(() => editor.setEditable(editable));
}, [editable, editor]);

// Update content if/when it changes
const previousContent = useRef(editorProps.content);
useEffect(() => {
if (
!editor ||
editor.isDestroyed ||
editorProps.content === undefined ||
editorProps.content === previousContent.current
) {
return;
}
// We use queueMicrotask to avoid any flushSync console errors as
// mentioned here
// https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730
queueMicrotask(() => {
// Validate that editorProps.content isn't undefined again to appease TS
if (editorProps.content !== undefined) {
editor.commands.setContent(editorProps.content);
}
});
}, [editorProps.content, editor]);

useEffect(() => {
previousContent.current = editorProps.content;
}, [editorProps.content]);

return (
<RichTextEditorProvider editor={editor}>
<RichTextField
Expand Down
29 changes: 17 additions & 12 deletions src/RichTextReadOnly.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,28 @@ function RichTextReadOnlyInternal(props: RichTextReadOnlyProps) {
/**
* An all-in-one component to directly render read-only Tiptap editor content.
*
* While useEditor, RichTextEditorProvider, and RichTextContent can be used as
* read-only via the editor's `editable` prop, this is a simpler and more
* efficient version that only renders content and nothing more (e.g., does not
* instantiate a toolbar, bubble menu, etc. that can't/won't be used in a
* read-only context, and skips instantiating the editor at all if there's no
* content to display). It can be used directly without needing the provider or
* a separate useEditor invocation.
* When to use this component:
* - You just want to render editor HTML/JSON content directly, without any
* outlined field styling, menu bar, extra setup, etc.
* - You want a convenient way to render content that re-renders as the
* `content` prop changes.
*
* Though RichtextEditor (or useEditor, RichTextEditorProvider, and
* RichTextContent) can be used as read-only via the editor's `editable` prop,
* this is a simpler and more efficient version that only renders content and
* nothing more (e.g., skips instantiating the editor at all if there's no
* content to display, and does not contain additional rendering logic related
* to controls, outlined field UI state, etc.).
*
* Example:
* <RichTextReadOnly content="<p>Hello world</p>" extensions={[StarterKit]} />
*/
export default function RichTextReadOnly(editorProps: RichTextReadOnlyProps) {
if (!editorProps.content) {
// Don't bother instantiating an editor at all (for performance) if we have no
// content
export default function RichTextReadOnly(props: RichTextReadOnlyProps) {
if (!props.content) {
// Don't bother instantiating an editor at all (for performance) if we have
// no content
return null;
}

return <RichTextReadOnlyInternal {...editorProps} />;
return <RichTextReadOnlyInternal {...props} />;
}

0 comments on commit cebec1f

Please sign in to comment.