sjdemartini / mui-tiptap

A Material UI (MUI) styled WYSIWYG rich text editor, using Tiptap
MIT License
316 stars 43 forks source link
material-ui mui react rich-text-editor rte tiptap wysiwyg

mui-tiptap logo

mui-tiptap: A customizable Material UI styled WYSIWYG rich text editor, using Tiptap.

npm mui-tiptap package npm type definitions GitHub Workflow Status project license

Features:

README Table of Contents - [Demo](#demo) - [Installation](#installation) - [Get started](#get-started) - [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) - [mui-tiptap extensions and components](#mui-tiptap-extensions-and-components) - [Tiptap extensions](#tiptap-extensions) - [`HeadingWithAnchor`](#headingwithanchor) - [`FontSize`](#fontsize) - [`LinkBubbleMenuHandler`](#linkbubblemenuhandler) - [`ResizableImage`](#resizableimage) - [`TableImproved`](#tableimproved) - [Components](#components) - [Controls components](#controls-components) - [Localization](#localization) - [Tips and suggestions](#tips-and-suggestions) - [Choosing your editor `extensions`](#choosing-your-editor-extensions) - [Extension precedence and ordering](#extension-precedence-and-ordering) - [Other extension tips](#other-extension-tips) - [Drag-and-drop and paste for images](#drag-and-drop-and-paste-for-images) - [Re-rendering `RichTextEditor` when `content` changes](#re-rendering-richtexteditor-when-content-changes) - [Contributing](#contributing)

Demo

Try it yourself in this CodeSandbox live demo!

mui-tiptap demo

Installation

npm install mui-tiptap

or

yarn add mui-tiptap

There are peer dependencies on @mui/material and @mui/icons-material (and their @emotion/ peers), and @tiptap/ packages. These should be installed automatically by default if you’re using npm 7+ or pnpm. Otherwise, if your project doesn’t already use those, you can install them with:

npm install @mui/material @mui/icons-material @emotion/react @emotion/styled @tiptap/react @tiptap/extension-heading @tiptap/extension-image @tiptap/extension-table @tiptap/pm @tiptap/core

or

yarn add @mui/material @mui/icons-material @emotion/react @emotion/styled @tiptap/react @tiptap/extension-heading @tiptap/extension-image @tiptap/extension-table @tiptap/pm @tiptap/core

Get started

Use the all-in-one component

The simplest way to render a rich text editor is to use the RichTextEditor component:

import { Button } from "@mui/material";
import StarterKit from "@tiptap/starter-kit";
import {
  MenuButtonBold,
  MenuButtonItalic,
  MenuControlsContainer,
  MenuDivider,
  MenuSelectHeading,
  RichTextEditor,
  type RichTextEditorRef,
} from "mui-tiptap";
import { useRef } from "react";

function App() {
  const rteRef = useRef<RichTextEditorRef>(null);

  return (
    <div>
      <RichTextEditor
        ref={rteRef}
        extensions={[StarterKit]} // Or any Tiptap extensions you wish!
        content="<p>Hello world</p>" // Initial content for the editor
        // Optionally include `renderControls` for a menu-bar atop the editor:
        renderControls={() => (
          <MenuControlsContainer>
            <MenuSelectHeading />
            <MenuDivider />
            <MenuButtonBold />
            <MenuButtonItalic />
            {/* Add more controls of your choosing here */}
          </MenuControlsContainer>
        )}
      />

      <Button onClick={() => console.log(rteRef.current?.editor?.getHTML())}>
        Log HTML
      </Button>
    </div>
  );
}

Check out 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 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, 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:

import { useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import {
  MenuButtonBold,
  MenuButtonItalic,
  MenuControlsContainer,
  MenuDivider,
  MenuSelectHeading,
  RichTextEditorProvider,
  RichTextField,
} from "mui-tiptap";

function App() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: "<p>Hello <b>world</b>!</p>",
  });
  return (
    <RichTextEditorProvider editor={editor}>
      <RichTextField
        controls={
          <MenuControlsContainer>
            <MenuSelectHeading />
            <MenuDivider />
            <MenuButtonBold />
            <MenuButtonItalic />
            {/* Add more controls of your choosing here */}
          </MenuControlsContainer>
        }
      />
    </RichTextEditorProvider>
  );
}

Or if you want full control over the UI, instead of RichTextField, you can build the editor area yourself and then just use the <RichTextContent /> component where you want the (styled) editable rich text content to appear. RichTextContent is the MUI-themed version of Tiptap's EditorContent component.

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:

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

Alternatively, you can set the RichTextEditor editable prop (or useEditor editable option) to false for a more configurable read-only option. Use RichTextReadOnly when:

mui-tiptap extensions and components

Tiptap extensions

HeadingWithAnchor

A modified version of Tiptap’s Heading extension, with dynamic GitHub-like anchor links for every heading you add. An anchor link button will appear to the left of a heading when hovering over it, when the editor has editable set to false. This allows users to share links and jump to specific headings within your rendered editor content.

FontSize

Sets text font size. This extension requires the @tiptap/extension-text-style package to be installed and its TextStyle mark to be included in your extensions.

Can be controlled with the MenuSelectFontSize component.

Commands

LinkBubbleMenuHandler

To be used in conjunction with the LinkBubbleMenu component, as this extension provides editor commands to control the state of the link bubble menu.

Commands

ResizableImage

A modified version of Tiptap’s Image extension, which adds the ability to resize images directly in the editor. A drag handle appears in the bottom right when clicking on an image, so users can interactively change the size.

TableImproved

A modified version of Tiptap’s Table extension that fixes problems related to column-resizing and editable state.

Namely, this version of the extension, coupled with the mui-tiptap CSS styles ensures that:

  1. Columns respect their resized widths even when the editor has editable=false
  2. Column resizing is possible regardless of initial editor state, when toggling from editable=false to editable=true

(Resolves these reported Tiptap issues: 1, 2, 3.)

Components

Component Description
RichTextEditor An all-in-one component to directly render a MUI-styled Tiptap rich text editor field. Utilizes many of the below components internally. See the "Get started" notes on usage above. In brief: <RichTextEditor ref={rteRef} content="<p>Hello world</p>" extensions={[...]} />
RichTextReadOnly An all-in-one component to directly render read-only Tiptap editor content. While 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., does not instantiate a toolbar, bubble menu, etc. that you probably wouldn’t want in a read-only context, and it skips instantiating the editor at all if there's no content to display).
RichTextEditorProvider Uses React context to make the Tiptap editor available to any nested components so that the editor does not need to be manually passed in at every level. Required as a parent for most mui-tiptap components besides the all-in-one RichTextEditor and RichTextReadOnly. Utilize the provided editor in your own components via the useRichTextEditorContext() hook.
RichTextField Renders the Tiptap rich text editor content and a controls menu bar. With the "outlined" variant, renders a bordered UI similar to the Material UI TextField. The "standard" variant does not have an outline/border.
MenuBar A collapsible, optionally-sticky container for showing editor controls atop the editor content. (This component is used to contain RichTextEditor’s renderControls and RichTextField’s controls, but can be used directly if you’re doing something more custom.)
RichTextContent Renders a Material UI styled version of Tiptap rich text editor content. Applies all CSS rules for formatting, as a styled alternative to Tiptap’s <EditorContent /> component. (Used automatically within RichTextEditor and RichTextField.)
LinkBubbleMenu Renders a bubble menu when viewing, creating, or editing a link. Requires the Tiptap Link extension (@tiptap/extension-link) and the mui-tiptap LinkBubbleMenuHandler extension. Pairs well with the <MenuButtonEditLink /> component.

If you're using RichTextEditor, include this component via RichTextEditor’s children render-prop. Otherwise, include the LinkBubbleMenu as a child of the component where you call useEditor and render your RichTextField or RichTextContent. (The bubble menu itself will be positioned appropriately as long as it is re-rendered whenever the Tiptap editor forces an update, which will happen if it's a child of the component using useEditor). See src/demo/Editor.tsx for an example of this.
TableBubbleMenu Renders a bubble menu to manipulate the contents of a Table (add or delete columns or rows, merge cells, etc.), when the user's caret/selection is inside a Table. For use with mui-tiptap’s TableImproved extension or Tiptap’s @tiptap/extension-table extension.

If you're using RichTextEditor, include this component via RichTextEditor’s children render-prop. Otherwise, include the TableBubbleMenu as a child of the component where you call useEditor and render your RichTextField or RichTextContent. (The bubble menu itself will be positioned appropriately as long as it is re-rendered whenever the Tiptap editor forces an update, which will happen if it's a child of the component using useEditor). See src/demo/Editor.tsx for an example of this.
ControlledBubbleMenu General-purpose component for building your own custom bubble menus, solving some shortcomings of Tiptap’s BubbleMenu. This is what both LinkBubbleMenu and TableBubbleMenu use under the hood.
ColorPicker A color-picker that includes hue/saturation/alpha gradient selectors (via react-colorful), plus a text input to enter a color directly, and optional swatchColors for color presets. Used by MenuButtonColorPicker/MenuButtonTextColor/MenuButtonHighlightColor under the hood. Important props:
swatchColors: Array of colors to show as preset buttons.
colorToHex: Override the default implementation for converting a given CSS color string to a string in hex format (e.g. "#ff0000"). Should return null if the given color cannot be parsed as valid. See ColorPickerProps definition for more details, such as examples using more full-featured libraries like colord or tinycolor2.
disableAlpha: If true, disables the alpha/transparency slider.
value/onChange: controlled color value string (empty string if unset) and its change callback.
ColorSwatchButton Renders a button that shows and allows selecting a color preset. Utilized by ColorPicker for its swatchColors.

Controls components

These controls components help you quickly put together your menu bar, for each of the various Tiptap extensions you may want to use.

You can override all props for these components (e.g. to change the IconComponent, tooltipLabel, tooltipShortcutKeys for which shortcut is shown, onClick behavior, etc.). Or easily create controls for your own extensions and use-cases with the base MenuButton and MenuSelect components.

Extension mui-tiptap component(s)
@tiptap/extension-blockquote MenuButtonBlockquote
@tiptap/extension-bold MenuButtonBold
@tiptap/extension-bullet-list MenuButtonBulletedList
@tiptap/extension-color MenuButtonTextColor (Takes optional defaultTextColor prop. See MenuButtonColorPicker below for other customization details.)
@tiptap/extension-code MenuButtonCode
@tiptap/extension-code-block MenuButtonCodeBlock
@tiptap/extension-font-family MenuSelectFontFamily (use the options prop to specify which font families can be selected, like [{ label: "Monospace", value: "monospace" }, ...] )
mui-tiptap’s FontSize MenuSelectFontSize (use the options prop to override the default size options)
mui-tiptap’s HeadingWithAnchor
or @tiptap/extension-heading
MenuSelectHeading
@tiptap/extension-highlight MenuButtonHighlightColor: For Highlight’s multicolor: true mode. Takes optional defaultMarkColor. See MenuButtonColorPicker below for other customization details.
MenuButtonHighlightToggle: For Highlight’s default multicolor: false mode
@tiptap/extension-history MenuButtonRedo, MenuButtonUndo
@tiptap/extension-horizontal-rule MenuButtonHorizontalRule
mui-tiptap’s ResizableImage
or @tiptap/extension-image
MenuButtonAddImage: General purpose button. Provide your own onClick behavior (e.g. like this for a user to provide an image URL).
MenuButtonImageUpload: Upload an image. Provide onUploadFiles prop to handle uploading files and returning servable URLs.
See also mui-tiptap’s insertImages util for inserting images into the Tiptap editor content.
@tiptap/extension-italic MenuButtonItalic
@tiptap/extension-link MenuButtonEditLink (requires the mui-tiptap LinkBubbleMenuHandler extension and <LinkBubbleMenu /> component)
@tiptap/extension-list-item MenuButtonIndent, MenuButtonUnindent
@tiptap/extension-ordered-list MenuButtonOrderedList
@tiptap/extension-paragraph MenuSelectHeading
@tiptap/extension-strike MenuButtonStrikethrough
@tiptap/extension-subscript MenuButtonSubscript
@tiptap/extension-superscript MenuButtonSuperscript
mui-tiptap’s TableImproved
or @tiptap/extension-table
• Insert new table: MenuButtonAddTable
• Edit a table (add columns, merge cells, etc.): TableBubbleMenu, or TableMenuControls if you need an alternative UI to the bubble menu
@tiptap/extension-task-list MenuButtonTaskList
@tiptap/extension-text-align MenuSelectTextAlign (all-in-one select)
or MenuButtonAlignLeft, MenuButtonAlignCenter, MenuButtonAlignRight, MenuButtonAlignJustify (individual buttons)
@tiptap/extension-underline MenuButtonUnderline

Other controls components:

Typically you will define your controls (for RichTextEditor’s renderControls or RichTextField’s controls) like:

<MenuControlsContainer>
  <MenuSelectHeading />
  <MenuDivider />
  <MenuButtonBold />
  <MenuButtonItalic />
  {/* Add more controls of your choosing here */}
</MenuControlsContainer>

Localization

All of the menu buttons, select components, and bubble menus allow you to override their default labels and content via props. Examples below.

Buttons In general, use `tooltipLabel`: ```tsx ``` The `MenuButtonTextColor` and `MenuButtonHighlightColor` components also have a `labels` prop for overriding content of the color picker popper. ```tsx ```
Selects ```tsx ``` ```tsx ``` ```tsx ``` ```tsx ```
Bubble menus ```tsx ``` ```tsx ```

Tips and suggestions

Choosing your editor extensions

Browse the official Tiptap extensions, and check out mui-tiptap’s additional extensions. The easiest way to get started is to install and use Tiptap’s StarterKit extension, which bundles several common Tiptap extensions.

To use an extension, you need to (1) install its package and (2) include the extension in the extensions array when instantiating your editor (either with <RichTextEditor extensions={[]} /> or useEditor({ extensions: [] })).

Extension precedence and ordering

Extensions that need to be higher precedence (for their keyboard shortcuts, etc.) should come later in your extensions array. (See Tiptap's general notes on extension plugin precedence and ordering here.) For example:

Other extension tips

Drag-and-drop and paste for images

You can provide editorProps to the RichTextEditor component or useEditor, and provide the handleDrop and handlePaste options to add support for drag-and-drop and paste of image files, respectively. Check out the mui-tiptap example of this in action. The mui-tiptap insertImages util is handy for this to take uploaded files and insert them into the editor content.

Re-rendering RichTextEditor when content changes

By default, RichTextEditor uses content the same way that Tiptap’s 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 user’s 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 “controlled” component 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 and this discussion).

But if you need this behavior in certain situations, like you have changed the content external to the component and separate from the user’s editor interaction, you can call editor.commands.setContent(content) (docs) within a hook to update the editor document.

For instance, you could use something like the following, which (1) only calls setContent when the editor is either read-only or unfocused (aiming to avoid losing any in-progress changes the user is making, though keep in mind that changes to isFocused itself do not cause re-rendering and so won't re-run the effect), and (2) tries to preserve the user’s current selection/caret:

const editor = rteRef.current?.editor;
useEffect(() => {
  if (!editor || editor.isDestroyed) {
    return;
  }
  if (!editor.isFocused || !editor.isEditable) {
    // Use queueMicrotask per https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730
    queueMicrotask(() => {
      const currentSelection = editor.state.selection;
      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 a much less efficient option, and it can cause a visual “flash” as the editor is rebuilt.

Note that if these content updates are coming from changes other users are making (e.g. saved to a database), it may be better to use collaborative editing functionality with Yjs, and not rely on content at all.

Contributing

Get started here.