import Lock from "@mui/icons-material/Lock";
import LockOpen from "@mui/icons-material/LockOpen";
import TextFields from "@mui/icons-material/TextFields";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { EditorOptions } from "@tiptap/core";
import { useCallback, useRef, useState } from "react";
import {
LinkBubbleMenu,
MenuButton,
RichTextEditor,
RichTextReadOnly,
TableBubbleMenu,
insertImages,
type RichTextEditorRef,
} from "../";
import EditorMenuControls from "./EditorMenuControls";
import useExtensions from "./useExtensions";
const exampleContent =
'
Hey there đź‘‹
This is a basic example of mui-tiptap, which combines Tiptap with customizable MUI (Material-UI) styles, plus a suite of additional components and extensions! Sure, there are all kinds of textformatting options you’d probably expect from a rich text editor. But wait until you see the @Axl Rose mentions and lists:
That’s a bullet list with one …
… or two list items.
Isn’t that great? And all of that is editable. But wait, there’s more! Let’s try a code block:
body {\n display: none;\n}
That’s only the tip of the iceberg. Feel free to add and resize images:
Organize information in tables:
Name
Role
Team
Alice
PM
Internal tools
Bob
Software
Infrastructure
Or write down your groceries:
Milk
Eggs
Sriracha
Wow, that’s amazing. Good work! 👏 — Mom
Give it a try and click around!
';
function fileListToImageFiles(fileList: FileList): File[] {
// You may want to use a package like attr-accept
// (https://www.npmjs.com/package/attr-accept) to restrict to certain file
// types.
return Array.from(fileList).filter((file) => {
const mimeType = (file.type || "").toLowerCase();
return mimeType.startsWith("image/");
});
}
type Props = {
disableStickyMenuBar?: boolean;
};
export default function Editor({ disableStickyMenuBar }: Props) {
const extensions = useExtensions({
placeholder: "Add your own content here...",
});
const rteRef = useRef(null);
const [isEditable, setIsEditable] = useState(true);
const [showMenuBar, setShowMenuBar] = useState(true);
const handleNewImageFiles = useCallback(
(files: File[], insertPosition?: number): void => {
if (!rteRef.current?.editor) {
return;
}
// For the sake of a demo, we don't have a server to upload the files to,
// so we'll instead convert each one to a local "temporary" object URL.
// This will not persist properly in a production setting. You should
// instead upload the image files to your server, or perhaps convert the
// images to bas64 if you would like to encode the image data directly
// into the editor content, though that can make the editor content very
// large. You will probably want to use the same upload function here as
// for the MenuButtonImageUpload `onUploadFiles` prop.
const attributesForImageFiles = files.map((file) => ({
src: URL.createObjectURL(file),
alt: file.name,
}));
insertImages({
images: attributesForImageFiles,
editor: rteRef.current.editor,
position: insertPosition,
});
},
[],
);
// Allow for dropping images into the editor
const handleDrop: NonNullable =
useCallback(
(view, event, _slice, _moved) => {
if (!(event instanceof DragEvent) || !event.dataTransfer) {
return false;
}
const imageFiles = fileListToImageFiles(event.dataTransfer.files);
if (imageFiles.length > 0) {
const insertPosition = view.posAtCoords({
left: event.clientX,
top: event.clientY,
})?.pos;
handleNewImageFiles(imageFiles, insertPosition);
// Return true to treat the event as handled. We call preventDefault
// ourselves for good measure.
event.preventDefault();
return true;
}
return false;
},
[handleNewImageFiles],
);
// Allow for pasting images
const handlePaste: NonNullable =
useCallback(
(_view, event, _slice) => {
if (!event.clipboardData) {
return false;
}
const pastedImageFiles = fileListToImageFiles(
event.clipboardData.files,
);
if (pastedImageFiles.length > 0) {
handleNewImageFiles(pastedImageFiles);
// Return true to mark the paste event as handled. This can for
// instance prevent redundant copies of the same image showing up,
// like if you right-click and copy an image from within the editor
// (in which case it will be added to the clipboard both as a file and
// as HTML, which Tiptap would otherwise separately parse.)
return true;
}
// We return false here to allow the standard paste-handler to run.
return false;
},
[handleNewImageFiles],
);
const [submittedContent, setSubmittedContent] = useState("");
return (
<>
}
RichTextFieldProps={{
// The "outlined" variant is the default (shown here only as
// example), but can be changed to "standard" to remove the outlined
// field border from the editor
variant: "outlined",
MenuBarProps: {
hide: !showMenuBar,
disableSticky: disableStickyMenuBar,
},
// Below is an example of adding a toggle within the outlined field
// for showing/hiding the editor menu bar, and a "submit" button for
// saving/viewing the HTML content
footer: (
theme.palette.divider,
py: 1,
px: 1.5,
}}
>
{
setShowMenuBar((currentState) => !currentState);
}}
selected={showMenuBar}
IconComponent={TextFields}
/>
{
setIsEditable((currentState) => !currentState);
}}
selected={!isEditable}
IconComponent={isEditable ? Lock : LockOpen}
/>
),
}}
sx={{
// An example of how editor styles can be overridden. In this case,
// setting where the scroll anchors to when jumping to headings. The
// scroll margin isn't built in since it will likely vary depending on
// where the editor itself is rendered (e.g. if there's a sticky nav
// bar on your site).
"& .ProseMirror": {
"& h1, & h2, & h3, & h4, & h5, & h6": {
scrollMarginTop: showMenuBar ? 50 : 0,
},
},
}}
>
{() => (
<>
>
)}
Saved result:
{submittedContent ? (
<>
{submittedContent}
Read-only saved snapshot:
>
) : (
<>
Press “Save” above to show the HTML markup for the editor content.
Typically you’d use a similar editor.getHTML() approach
to save your data in a form.
>
)}
>
);
}