├── .nvmrc
├── public
├── .gitkeep
└── logo.svg
├── example
├── public
│ └── .gitkeep
├── .npmrc
├── src
│ ├── vite-env.d.ts
│ ├── main.tsx
│ ├── PageContentWithEditorSimple.tsx
│ └── App.tsx
├── .eslintrc.json
├── vite.config.ts
├── tsconfig.node.json
├── README.md
├── index.html
├── .gitignore
├── tsconfig.json
└── package.json
├── .npmrc
├── .npmignore
├── src
├── vite-env.d.ts
├── demo
│ ├── README.md
│ ├── index.tsx
│ ├── App.tsx
│ ├── mentionSuggestionOptions.ts
│ ├── SuggestionList.tsx
│ └── EditorMenuControls.tsx
├── hooks
│ ├── index.ts
│ ├── useKeyDown.ts
│ ├── useForceUpdate.ts
│ ├── useDebouncedFocus.ts
│ ├── useEditorOnEditableUpdate.ts
│ └── useDebouncedFunction.ts
├── utils
│ ├── truncateMiddle.ts
│ ├── index.ts
│ ├── keymapPluginFactory.ts
│ ├── slugify.ts
│ ├── getAttributesForNodes.ts
│ ├── color.ts
│ ├── getAttributesForMarks.ts
│ ├── platform.ts
│ ├── getAttributesForEachSelected.ts
│ ├── images.ts
│ └── DebounceRender.tsx
├── icons
│ ├── LayoutRowFill.tsx
│ ├── LayoutColumnFill.tsx
│ ├── Table.tsx
│ ├── SplitCellsHorizontal.tsx
│ ├── CodeBlock.tsx
│ ├── DeleteRow.tsx
│ ├── MergeCellsHorizontal.tsx
│ ├── DeleteColumn.tsx
│ ├── InsertRowTop.tsx
│ ├── InsertColumnLeft.tsx
│ ├── InsertRowBottom.tsx
│ ├── InsertColumnRight.tsx
│ ├── FormatColorTextNoBar.tsx
│ ├── BorderColorNoBar.tsx
│ ├── FormatColorBar.tsx
│ ├── FormatInkHighlighter.tsx
│ ├── FormatInkHighlighterNoBar.tsx
│ ├── FormatColorFillNoBar.tsx
│ └── index.ts
├── extensions
│ ├── index.ts
│ ├── FontSize.ts
│ ├── TableImproved.ts
│ ├── ResizableImageResizer.tsx
│ └── HeadingWithAnchor.ts
├── context.ts
├── __tests__
│ └── utils
│ │ ├── truncateMiddle.test.ts
│ │ └── slugify.test.ts
├── MenuDivider.tsx
├── controls
│ ├── MenuButtonUndo.tsx
│ ├── MenuButtonRedo.tsx
│ ├── MenuButtonIndent.tsx
│ ├── MenuButtonUnindent.tsx
│ ├── MenuButtonAddTable.tsx
│ ├── MenuButtonCode.tsx
│ ├── MenuButtonBold.tsx
│ ├── MenuButtonHorizontalRule.tsx
│ ├── MenuButtonItalic.tsx
│ ├── MenuButtonCodeBlock.tsx
│ ├── MenuButtonSubscript.tsx
│ ├── MenuButtonTaskList.tsx
│ ├── MenuButtonBlockquote.tsx
│ ├── MenuButtonUnderline.tsx
│ ├── MenuButtonSuperscript.tsx
│ ├── MenuButtonStrikethrough.tsx
│ ├── MenuButtonAlignLeft.tsx
│ ├── MenuButtonAlignRight.tsx
│ ├── MenuButtonBulletedList.tsx
│ ├── MenuButtonOrderedList.tsx
│ ├── MenuButtonRemoveFormatting.tsx
│ ├── MenuButtonAlignCenter.tsx
│ ├── MenuButtonAlignJustify.tsx
│ ├── MenuButtonEditLink.tsx
│ ├── MenuButtonHighlightToggle.tsx
│ ├── MenuButtonAddImage.tsx
│ ├── MenuControlsContainer.tsx
│ ├── MenuButtonHighlightColor.tsx
│ ├── MenuButton.tsx
│ ├── MenuButtonImageUpload.tsx
│ ├── ColorSwatchButton.tsx
│ ├── MenuButtonTextColor.tsx
│ ├── MenuButtonTooltip.tsx
│ ├── MenuSelect.tsx
│ ├── index.ts
│ ├── TableMenuControls.tsx
│ ├── MenuButtonColorPicker.tsx
│ └── ColorPickerPopper.tsx
├── RichTextEditorProvider.tsx
├── index.ts
├── RichTextContent.tsx
├── RichTextReadOnly.tsx
├── LinkBubbleMenu
│ ├── ViewLinkMenuContent.tsx
│ └── index.tsx
├── MenuBar.tsx
├── FieldContainer.tsx
└── RichTextEditor.tsx
├── .github
├── ISSUE_TEMPLATE
│ ├── config.yml
│ ├── feature_request.md
│ └── bug_report.md
└── workflows
│ ├── release.yml
│ └── build-test.yml
├── .prettierignore
├── .husky
└── pre-commit
├── tsconfig.build.json
├── .markdown-link-check-config.json
├── tsconfig.build-esm.json
├── .prettierrc.cjs
├── .vscode
├── extensions.json
├── launch.json
└── settings.json
├── index.html
├── vite.config.ts
├── .cspell.json
├── tsconfig.json
├── LICENSE
├── .gitignore
├── CONTRIBUTING.md
└── .eslintrc.json
/.nvmrc:
--------------------------------------------------------------------------------
1 | v18
2 |
--------------------------------------------------------------------------------
/public/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/public/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | auto-install-peers=false
2 |
--------------------------------------------------------------------------------
/example/.npmrc:
--------------------------------------------------------------------------------
1 | auto-install-peers=false
2 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .nyc_output/
3 | coverage/
4 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 |
--------------------------------------------------------------------------------
/example/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | index.html
2 | pnpm-lock.yaml
3 | dist/
4 | .docusaurus/
5 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | pnpm lint-staged
5 |
--------------------------------------------------------------------------------
/src/demo/README.md:
--------------------------------------------------------------------------------
1 | Demo app that uses many features of `mui-tiptap` and is the Vite entry-point for local development.
2 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["./src"],
4 | "exclude": ["./src/demo", "./src/**/__tests__"]
5 | }
6 |
--------------------------------------------------------------------------------
/example/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "extends": "../.eslintrc.json",
4 | "parserOptions": {
5 | "project": "./tsconfig.json"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/.markdown-link-check-config.json:
--------------------------------------------------------------------------------
1 | {
2 | "ignorePatterns": [
3 | {
4 | "pattern": "^https://tiptap.dev/"
5 | }
6 | ],
7 | "timeout": "10s",
8 | "retryOn429": true
9 | }
10 |
--------------------------------------------------------------------------------
/tsconfig.build-esm.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.build.json",
3 | "compilerOptions": {
4 | "target": "ES2018",
5 | "module": "ES2020",
6 | "outDir": "./dist/esm"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | // https://github.com/simonhaenisch/prettier-plugin-organize-imports/issues/34#issuecomment-934676163
2 | module.exports = {
3 | plugins: [require.resolve("prettier-plugin-organize-imports")],
4 | };
5 |
--------------------------------------------------------------------------------
/example/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from "@vitejs/plugin-react-swc";
2 | import { defineConfig } from "vite";
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | });
8 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "esbenp.prettier-vscode",
5 | "streetsidesoftware.code-spell-checker",
6 | "yzhang.markdown-all-in-one"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as useDebouncedFocus,
3 | type UseDebouncedFocusOptions,
4 | } from "./useDebouncedFocus";
5 | export { default as useEditorOnEditableUpdate } from "./useEditorOnEditableUpdate";
6 | export { default as useKeyDown } from "./useKeyDown";
7 |
--------------------------------------------------------------------------------
/example/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | A simple "external" example application that installs and imports `mui-tiptap` (to test build/install nuances). For a more complete "kitchen sink" demo/example of `mui-tiptap`, see [`src/demo/`](../src/demo/) or the [CodeSandbox live demo](https://codesandbox.io/p/sandbox/mui-tiptap-demo-3zl2l6).
2 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | mui-tiptap Demo
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | mui-tiptap Demo
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/example/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/src/utils/truncateMiddle.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Truncate the middle of the given text, if it's longer than the given length.
3 | */
4 | export default function truncateMiddle(text: string, length = 20): string {
5 | if (text.length <= length) {
6 | return text;
7 | }
8 |
9 | const half = Math.floor(length / 2);
10 | return `${text.slice(0, half).trim()}…${text.slice(-half).trim()}`;
11 | }
12 |
--------------------------------------------------------------------------------
/src/icons/LayoutRowFill.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | const LayoutRowFill = createSvgIcon(
4 | // From https://remixicon.com/ (https://github.com/Remix-Design/RemixIcon)
5 | ,
6 | "LayoutRowFill"
7 | );
8 |
9 | export default LayoutRowFill;
10 |
--------------------------------------------------------------------------------
/src/icons/LayoutColumnFill.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | const LayoutColumnFill = createSvgIcon(
4 | // From https://remixicon.com/ (https://github.com/Remix-Design/RemixIcon)
5 | ,
6 | "LayoutColumnFill"
7 | );
8 |
9 | export default LayoutColumnFill;
10 |
--------------------------------------------------------------------------------
/src/icons/Table.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | const Table = createSvgIcon(
4 | // From https://boxicons.com/ (https://github.com/atisawd/boxicons)
5 | ,
6 | "Table"
7 | );
8 |
9 | export default Table;
10 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import react from "@vitejs/plugin-react-swc";
3 | import { defineConfig } from "vite";
4 | import { configDefaults } from "vitest/config";
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig({
8 | plugins: [react()],
9 | test: {
10 | exclude: [...configDefaults.exclude, "example/**"],
11 | coverage: {
12 | all: true,
13 | exclude: [...(configDefaults.coverage.exclude ?? []), "example/**"],
14 | },
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/src/icons/SplitCellsHorizontal.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | const SplitCellsHorizontal = createSvgIcon(
4 | // From https://remixicon.com/ (https://github.com/Remix-Design/RemixIcon)
5 | ,
6 | "SplitCellsHorizontal"
7 | );
8 |
9 | export default SplitCellsHorizontal;
10 |
--------------------------------------------------------------------------------
/src/demo/index.tsx:
--------------------------------------------------------------------------------
1 | import "@fontsource/roboto/300.css";
2 | import "@fontsource/roboto/400.css";
3 | import "@fontsource/roboto/500.css";
4 | import "@fontsource/roboto/700.css";
5 | import { StrictMode } from "react";
6 | import { createRoot } from "react-dom/client";
7 | import App from "./App";
8 |
9 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
10 | const container = document.getElementById("root")!;
11 | createRoot(container).render(
12 |
13 |
14 |
15 | );
16 |
--------------------------------------------------------------------------------
/example/src/main.tsx:
--------------------------------------------------------------------------------
1 | import "@fontsource/roboto/300.css";
2 | import "@fontsource/roboto/400.css";
3 | import "@fontsource/roboto/500.css";
4 | import "@fontsource/roboto/700.css";
5 | import { StrictMode } from "react";
6 | import { createRoot } from "react-dom/client";
7 | import App from "./App";
8 |
9 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
10 | const container = document.getElementById("root")!;
11 | createRoot(container).render(
12 |
13 |
14 |
15 | );
16 |
--------------------------------------------------------------------------------
/src/extensions/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as FontSize,
3 | type FontSizeAttrs,
4 | type FontSizeOptions,
5 | } from "./FontSize";
6 | export {
7 | default as HeadingWithAnchor,
8 | scrollToCurrentHeadingAnchor,
9 | type HeadingWithAnchorOptions,
10 | } from "./HeadingWithAnchor";
11 | export {
12 | default as LinkBubbleMenuHandler,
13 | type LinkBubbleMenuHandlerStorage,
14 | } from "./LinkBubbleMenuHandler";
15 | export { default as ResizableImage } from "./ResizableImage";
16 | export { default as TableImproved } from "./TableImproved";
17 |
--------------------------------------------------------------------------------
/src/icons/CodeBlock.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | const CodeBlock = createSvgIcon(
4 | // From https://boxicons.com/ (https://github.com/atisawd/boxicons)
5 | <>
6 |
7 |
8 | >,
9 | "CodeBlock"
10 | );
11 |
12 | export default CodeBlock;
13 |
--------------------------------------------------------------------------------
/src/icons/DeleteRow.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | const DeleteRow = createSvgIcon(
4 | // From https://remixicon.com/ (https://github.com/Remix-Design/RemixIcon)
5 | ,
6 | "DeleteRow"
7 | );
8 |
9 | export default DeleteRow;
10 |
--------------------------------------------------------------------------------
/src/icons/MergeCellsHorizontal.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | const MergeCellsHorizontal = createSvgIcon(
4 | // From https://remixicon.com/ (https://github.com/Remix-Design/RemixIcon)
5 | ,
6 | "MergeCellsHorizontal"
7 | );
8 |
9 | export default MergeCellsHorizontal;
10 |
--------------------------------------------------------------------------------
/src/context.ts:
--------------------------------------------------------------------------------
1 | import type { Editor } from "@tiptap/react";
2 | import { createContext, useContext } from "react";
3 |
4 | export const RichTextEditorContext = createContext(
5 | undefined
6 | );
7 |
8 | export function useRichTextEditorContext(): Editor | null {
9 | const editor = useContext(RichTextEditorContext);
10 | if (editor === undefined) {
11 | throw new Error(
12 | "Tiptap editor not found in component context. Be sure to use !"
13 | );
14 | }
15 |
16 | return editor;
17 | }
18 |
--------------------------------------------------------------------------------
/src/icons/DeleteColumn.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | const DeleteColumn = createSvgIcon(
4 | // From https://remixicon.com/ (https://github.com/Remix-Design/RemixIcon)
5 | ,
6 | "DeleteColumn"
7 | );
8 |
9 | export default DeleteColumn;
10 |
--------------------------------------------------------------------------------
/src/icons/InsertRowTop.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | const InsertRowTop = createSvgIcon(
4 | // From https://remixicon.com/ (https://github.com/Remix-Design/RemixIcon)
5 | ,
6 | "InsertRowTop"
7 | );
8 |
9 | export default InsertRowTop;
10 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
3 | "version": "0.2.0",
4 | "configurations": [
5 | {
6 | "type": "pwa-node",
7 | "request": "launch",
8 | "name": "Debug Current Test File",
9 | "autoAttachChildProcesses": true,
10 | "skipFiles": ["/**", "**/node_modules/**"],
11 | "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
12 | "args": ["run", "${relativeFile}"],
13 | "smartStep": true,
14 | "console": "integratedTerminal"
15 | }
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/src/icons/InsertColumnLeft.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | const InsertColumnLeft = createSvgIcon(
4 | // From https://remixicon.com/ (https://github.com/Remix-Design/RemixIcon)
5 | ,
6 | "InsertColumnLeft"
7 | );
8 |
9 | export default InsertColumnLeft;
10 |
--------------------------------------------------------------------------------
/src/icons/InsertRowBottom.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | const InsertRowBottom = createSvgIcon(
4 | // From https://remixicon.com/ (https://github.com/Remix-Design/RemixIcon)
5 | ,
6 | "InsertRowBottom"
7 | );
8 |
9 | export default InsertRowBottom;
10 |
--------------------------------------------------------------------------------
/src/icons/InsertColumnRight.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | const InsertColumnRight = createSvgIcon(
4 | // From https://remixicon.com/ (https://github.com/Remix-Design/RemixIcon)
5 | ,
6 | "InsertColumnRight"
7 | );
8 |
9 | export default InsertColumnRight;
10 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export { default as DebounceRender } from "./DebounceRender";
2 | export * from "./color";
3 | export { getAttributesForEachSelected } from "./getAttributesForEachSelected";
4 | export { getAttributesForMarks } from "./getAttributesForMarks";
5 | export { getAttributesForNodes } from "./getAttributesForNodes";
6 | export * from "./images";
7 | export { default as keymapPluginFactory } from "./keymapPluginFactory";
8 | export { getModShortcutKey, isMac, isTouchDevice } from "./platform";
9 | export { default as slugify } from "./slugify";
10 | export { default as truncateMiddle } from "./truncateMiddle";
11 |
--------------------------------------------------------------------------------
/src/__tests__/utils/truncateMiddle.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import truncateMiddle from "../../utils/truncateMiddle";
3 |
4 | describe("truncateMiddle()", () => {
5 | it("returns an ellipsis and truncates from the middle of a string", () => {
6 | // When the string is shorter than the truncation length, it shouldn't be truncated
7 | expect(truncateMiddle("short string")).toBe("short string");
8 | expect(truncateMiddle("short string", 5)).toBe("sh…ng");
9 | expect(truncateMiddle("This is a string that will be truncated!")).toBe(
10 | "This is a…truncated!"
11 | );
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/.cspell.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2",
3 | "language": "en",
4 | "words": [
5 | "colord",
6 | "contenteditable",
7 | "dropcursor",
8 | "esbuild",
9 | "gapcursor",
10 | "keymap",
11 | "queueMicrotask",
12 | "mui-tiptap",
13 | "mui",
14 | "octocat",
15 | "premajor",
16 | "preminor",
17 | "prepatch",
18 | "prosemirror",
19 | "selectednode",
20 | "strikethrough",
21 | "tinycolor",
22 | "Tiptap"
23 | ],
24 | "flagWords": [],
25 | "ignorePaths": [
26 | "package.json",
27 | "package-lock.json",
28 | "yarn.lock",
29 | "tsconfig.json",
30 | "node_modules/**"
31 | ]
32 | }
33 |
--------------------------------------------------------------------------------
/src/icons/FormatColorTextNoBar.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | /**
4 | * A modified version of the FormatColorText icon from @mui/icons-material, with
5 | * the horizontal bar below the "A" letter removed.
6 | *
7 | * This allows us to control/render the color of the bar independently, via a
8 | * separate icon (mui-tipap's FormatColorBar).
9 | */
10 | const FormatColorTextNoBar = createSvgIcon(
11 | ,
12 | "FormatColorTextNoBar"
13 | );
14 |
15 | export default FormatColorTextNoBar;
16 |
--------------------------------------------------------------------------------
/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "module": "ESNext",
6 | "skipLibCheck": true,
7 |
8 | /* Bundler mode */
9 | "moduleResolution": "bundler",
10 | "allowImportingTsExtensions": true,
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "noEmit": true,
14 | "jsx": "react-jsx",
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true
21 | },
22 | "include": ["src"],
23 | "references": [{ "path": "./tsconfig.node.json" }]
24 | }
25 |
--------------------------------------------------------------------------------
/src/icons/BorderColorNoBar.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | /**
4 | * A modified version of the BorderColor icon from @mui/icons-material, with
5 | * the horizontal bar below the pencil removed.
6 | *
7 | * This allows us to control/render the color of the bar independently, via a
8 | * separate icon (mui-tipap's FormatColorBar).
9 | */
10 | const BorderColorNoBar = createSvgIcon(
11 | ,
12 | "BorderColorNoBar"
13 | );
14 |
15 | export default BorderColorNoBar;
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2015",
4 | "lib": ["ES2015", "DOM", "DOM.Iterable"],
5 | "module": "CommonJS",
6 | "skipLibCheck": true,
7 |
8 | // Build options
9 | "moduleResolution": "node",
10 | "resolveJsonModule": true,
11 | "isolatedModules": true,
12 | "jsx": "react-jsx",
13 | "outDir": "./dist",
14 | "sourceMap": false,
15 | "esModuleInterop": true,
16 |
17 | // Linting
18 | "strict": true,
19 | "forceConsistentCasingInFileNames": true,
20 | "declaration": true,
21 | "allowJs": false,
22 | "noFallthroughCasesInSwitch": true
23 | },
24 | "include": ["./src", "vite.config.ts"]
25 | }
26 |
--------------------------------------------------------------------------------
/src/icons/FormatColorBar.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | /**
4 | * A horizontal bar that is used as a color indicator, matching position and
5 | * appearance of the bar in icons like FormatColorText, BorderColor, etc. from
6 | * @mui/icons-material.
7 | *
8 | * This allows for rendering the color indication separately from the other
9 | * portion of the icon (the text letter, highlighter, etc.) when used with icons
10 | * like FormatColorTextNoBar, FormatInkHighlighterNoBar, and BorderColorNoBar.
11 | */
12 | const FormatColorBar = createSvgIcon(
13 | ,
14 | "FormatColorBar"
15 | );
16 |
17 | export default FormatColorBar;
18 |
--------------------------------------------------------------------------------
/src/icons/FormatInkHighlighter.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | const FormatInkHighlighter = createSvgIcon(
4 | // Part of Material Symbols (and so unfortunately not in @mui/icons-material),
5 | // this SVG was downloaded from https://iconbuddy.app, and a similar source
6 | // version can be found here
7 | // https://fonts.google.com/icons?icon.query=highlight&icon.set=Material+Symbols.
8 | ,
9 | "FormatInkHighlighter"
10 | );
11 |
12 | export default FormatInkHighlighter;
13 |
--------------------------------------------------------------------------------
/src/MenuDivider.tsx:
--------------------------------------------------------------------------------
1 | import { Divider, type DividerProps } from "@mui/material";
2 | import { makeStyles } from "tss-react/mui";
3 |
4 | // The orientation of our menu dividers will always be vertical
5 | export type MenuDividerProps = Omit;
6 |
7 | const useStyles = makeStyles({ name: { MenuDivider } })((theme) => ({
8 | root: {
9 | height: 18,
10 | margin: theme.spacing(0, 0.5),
11 | },
12 | }));
13 |
14 | export default function MenuDivider(props: MenuDividerProps) {
15 | const { classes, cx } = useStyles();
16 | return (
17 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ""
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | ### Is your feature request related to a problem? Please describe.
10 |
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | ### Describe the solution you'd like
14 |
15 | A clear and concise description of what you want to happen.
16 |
17 | ### Describe alternatives you've considered
18 |
19 | A clear and concise description of any alternative solutions or features you've considered.
20 |
21 | ### Additional context
22 |
23 | Add any other context or screenshots about the feature request here.
24 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonUndo.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import UndoIcon from "@mui/icons-material/Undo";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonUndoProps = Partial;
7 |
8 | export default function MenuButtonUndo(props: MenuButtonUndoProps) {
9 | const editor = useRichTextEditorContext();
10 | return (
11 | editor?.chain().focus().undo().run()}
17 | {...props}
18 | />
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/icons/FormatInkHighlighterNoBar.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | /**
4 | * A modified version of the FormatInkHighlighter icon, with the horizontal bar
5 | * below the highlighter removed.
6 | *
7 | * This allows us to control/render the color of the bar independently, via a
8 | * separate icon (mui-tipap's FormatColorBar).
9 | */
10 | const FormatInkHighlighterNoBar = createSvgIcon(
11 | ,
12 | "FormatInkHighlighterNoBar"
13 | );
14 |
15 | export default FormatInkHighlighterNoBar;
16 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonRedo.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import RedoIcon from "@mui/icons-material/Redo";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonRedoProps = Partial;
7 |
8 | export default function MenuButtonRedo(props: MenuButtonRedoProps) {
9 | const editor = useRichTextEditorContext();
10 | return (
11 | editor?.chain().focus().redo().run()}
17 | {...props}
18 | />
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonIndent.tsx:
--------------------------------------------------------------------------------
1 | import FormatIndentIncrease from "@mui/icons-material/FormatIndentIncrease";
2 | import { useRichTextEditorContext } from "../context";
3 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
4 |
5 | export type MenuButtonIndentProps = Partial;
6 |
7 | export default function MenuButtonIndent(props: MenuButtonIndentProps) {
8 | const editor = useRichTextEditorContext();
9 | return (
10 | editor?.chain().focus().sinkListItem("listItem").run()}
16 | {...props}
17 | />
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonUnindent.tsx:
--------------------------------------------------------------------------------
1 | import FormatIndentDecrease from "@mui/icons-material/FormatIndentDecrease";
2 | import { useRichTextEditorContext } from "../context";
3 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
4 |
5 | export type MenuButtonUnindentProps = Partial;
6 |
7 | export default function MenuButtonUnindent(props: MenuButtonUnindentProps) {
8 | const editor = useRichTextEditorContext();
9 | return (
10 | editor?.chain().focus().liftListItem("listItem").run()}
16 | {...props}
17 | />
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonAddTable.tsx:
--------------------------------------------------------------------------------
1 | import { useRichTextEditorContext } from "../context";
2 | import { Table } from "../icons";
3 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
4 |
5 | export type MenuButtonAddTableProps = Partial;
6 |
7 | export default function MenuButtonAddTable(props: MenuButtonAddTableProps) {
8 | const editor = useRichTextEditorContext();
9 | return (
10 |
15 | editor
16 | ?.chain()
17 | .focus()
18 | .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
19 | .run()
20 | }
21 | {...props}
22 | />
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/hooks/useKeyDown.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 |
3 | /** When the given key is pressed down, execute the given callback. */
4 | export default function useKeyDown(
5 | key: string,
6 | callback: (event: KeyboardEvent) => void
7 | ): void {
8 | // Use a ref in case `callback` isn't memoized
9 | const callbackRef = useRef(callback);
10 | useEffect(() => {
11 | callbackRef.current = callback;
12 | }, [callback]);
13 |
14 | useEffect(() => {
15 | function handleKeyDown(event: KeyboardEvent) {
16 | if (key === event.key) {
17 | callbackRef.current(event);
18 | }
19 | }
20 |
21 | document.addEventListener("keydown", handleKeyDown);
22 | return () => {
23 | document.removeEventListener("keydown", handleKeyDown);
24 | };
25 | }, [key]);
26 | }
27 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonCode.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import Code from "@mui/icons-material/Code";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonCodeProps = Partial;
7 |
8 | export default function MenuButtonCode(props: MenuButtonCodeProps) {
9 | const editor = useRichTextEditorContext();
10 | return (
11 | editor?.chain().focus().toggleCode().run()}
18 | {...props}
19 | />
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonBold.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import FormatBold from "@mui/icons-material/FormatBold";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonBoldProps = Partial;
7 |
8 | export default function MenuButtonBold(props: MenuButtonBoldProps) {
9 | const editor = useRichTextEditorContext();
10 | return (
11 | editor?.chain().focus().toggleBold().run()}
18 | {...props}
19 | />
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/icons/FormatColorFillNoBar.tsx:
--------------------------------------------------------------------------------
1 | import { createSvgIcon } from "@mui/material";
2 |
3 | /**
4 | * A modified version of the FormatColorFill icon from @mui/icons-material, with
5 | * the horizontal bar below the "fill bucket" removed.
6 | *
7 | * This allows us to control/render the color of the bar independently, via a
8 | * separate icon (mui-tipap's FormatColorBar).
9 | */
10 | const FormatColorFillNoBar = createSvgIcon(
11 | ,
12 | "FormatColorFillNoBar"
13 | );
14 |
15 | export default FormatColorFillNoBar;
16 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonHorizontalRule.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import HorizontalRuleIcon from "@mui/icons-material/HorizontalRule";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonHorizontalRuleProps = Partial;
7 |
8 | export default function MenuButtonHorizontalRule(
9 | props: MenuButtonHorizontalRuleProps
10 | ) {
11 | const editor = useRichTextEditorContext();
12 | return (
13 | editor?.chain().focus().setHorizontalRule().run()}
18 | {...props}
19 | />
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonItalic.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import FormatItalic from "@mui/icons-material/FormatItalic";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonItalicProps = Partial;
7 |
8 | export default function MenuButtonItalic(props: MenuButtonItalicProps) {
9 | const editor = useRichTextEditorContext();
10 | return (
11 | editor?.chain().focus().toggleItalic().run()}
18 | {...props}
19 | />
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/RichTextEditorProvider.tsx:
--------------------------------------------------------------------------------
1 | import type { Editor } from "@tiptap/react";
2 | import { RichTextEditorContext } from "./context";
3 |
4 | export type RichTextEditorProviderProps = {
5 | editor: Editor | null;
6 | children: React.ReactNode;
7 | };
8 |
9 | /**
10 | * Makes the Tiptap `editor` available to any nested components, via the
11 | * `useRichTextEditorContext()` hook so that the `editor` does not need to be
12 | * manually passed in at every level.
13 | *
14 | * Required as a parent for most mui-tiptap components besides the all-in-one
15 | * `RichTextEditor` and `RichTextReadOnly`.
16 | */
17 | export default function RichTextEditorProvider({
18 | editor,
19 | children,
20 | }: RichTextEditorProviderProps) {
21 | return (
22 |
23 | {children}
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonCodeBlock.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import { useRichTextEditorContext } from "../context";
3 | import { CodeBlock } from "../icons";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonCodeBlockProps = Partial;
7 |
8 | export default function MenuButtonCodeBlock(props: MenuButtonCodeBlockProps) {
9 | const editor = useRichTextEditorContext();
10 | return (
11 | editor?.chain().focus().toggleCodeBlock().run()}
18 | {...props}
19 | />
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonSubscript.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import Subscript from "@mui/icons-material/Subscript";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonSubscriptProps = Partial;
7 |
8 | export default function MenuButtonSubscript(props: MenuButtonSubscriptProps) {
9 | const editor = useRichTextEditorContext();
10 | return (
11 | editor?.chain().focus().toggleSubscript().run()}
18 | {...props}
19 | />
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonTaskList.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import Checklist from "@mui/icons-material/Checklist";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonTaskListProps = Partial;
7 |
8 | export default function MenuButtonTaskList(props: MenuButtonTaskListProps) {
9 | const editor = useRichTextEditorContext();
10 | return (
11 | editor?.chain().focus().toggleTaskList().run()}
18 | {...props}
19 | />
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonBlockquote.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import FormatQuote from "@mui/icons-material/FormatQuote";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonBlockquoteProps = Partial;
7 |
8 | export default function MenuButtonBlockquote(props: MenuButtonBlockquoteProps) {
9 | const editor = useRichTextEditorContext();
10 | return (
11 | editor?.chain().focus().toggleBlockquote().run()}
18 | {...props}
19 | />
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonUnderline.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import FormatUnderlinedIcon from "@mui/icons-material/FormatUnderlined";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonUnderlineProps = Partial;
7 |
8 | export default function MenuButtonUnderline(props: MenuButtonUnderlineProps) {
9 | const editor = useRichTextEditorContext();
10 | return (
11 | editor?.chain().focus().toggleUnderline().run()}
18 | {...props}
19 | />
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonSuperscript.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import Superscript from "@mui/icons-material/Superscript";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonSuperscriptProps = Partial;
7 |
8 | export default function MenuButtonSuperscript(
9 | props: MenuButtonSuperscriptProps
10 | ) {
11 | const editor = useRichTextEditorContext();
12 | return (
13 | editor?.chain().focus().toggleSuperscript().run()}
20 | {...props}
21 | />
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonStrikethrough.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import StrikethroughS from "@mui/icons-material/StrikethroughS";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonStrikethroughProps = Partial;
7 |
8 | export default function MenuButtonStrikethrough(
9 | props: MenuButtonStrikethroughProps
10 | ) {
11 | const editor = useRichTextEditorContext();
12 | return (
13 | editor?.chain().focus().toggleStrike().run()}
20 | {...props}
21 | />
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonAlignLeft.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import FormatAlignLeftIcon from "@mui/icons-material/FormatAlignLeft";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonAlignLeftProps = Partial;
7 |
8 | export default function MenuButtonAlignLeft(props: MenuButtonAlignLeftProps) {
9 | const editor = useRichTextEditorContext();
10 | return (
11 | editor?.chain().focus().setTextAlign("left").run()}
18 | {...props}
19 | />
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/hooks/useForceUpdate.ts:
--------------------------------------------------------------------------------
1 | import { useReducer } from "react";
2 |
3 | export type UseForceUpdateResult = () => void;
4 |
5 | /**
6 | * A hook that returns a function to call which will perform a force re-render
7 | * of the given component.
8 | *
9 | * This should be used very sparingly! It's typically only needed in situations
10 | * where the underlying Tiptap editor has updated some state external to React
11 | * and there are no alternatives to update, like Tiptap's useEditor hook itself
12 | * does
13 | * https://github.com/ueberdosis/tiptap/blob/b0198eb14b98db5ca691bd9bfe698ffaddbc4ded/packages/react/src/useEditor.ts#L105-L113.
14 | *
15 | * Implementation taken from
16 | * https://legacy.reactjs.org/docs/hooks-faq.html#is-there-something-like-forceupdate
17 | */
18 | export default function useForceUpdate(): UseForceUpdateResult {
19 | const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
20 | return forceUpdate;
21 | }
22 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonAlignRight.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import FormatAlignRightIcon from "@mui/icons-material/FormatAlignRight";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonAlignRightProps = Partial;
7 |
8 | export default function MenuButtonAlignRight(props: MenuButtonAlignRightProps) {
9 | const editor = useRichTextEditorContext();
10 | return (
11 | editor?.chain().focus().setTextAlign("right").run()}
18 | {...props}
19 | />
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonBulletedList.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import FormatListBulleted from "@mui/icons-material/FormatListBulleted";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonBulletedListProps = Partial;
7 |
8 | export default function MenuButtonBulletedList(
9 | props: MenuButtonBulletedListProps
10 | ) {
11 | const editor = useRichTextEditorContext();
12 | return (
13 | editor?.chain().focus().toggleBulletList().run()}
20 | {...props}
21 | />
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonOrderedList.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import FormatListNumbered from "@mui/icons-material/FormatListNumbered";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonOrderedListProps = Partial;
7 |
8 | export default function MenuButtonOrderedList(
9 | props: MenuButtonOrderedListProps
10 | ) {
11 | const editor = useRichTextEditorContext();
12 | return (
13 | editor?.chain().focus().toggleOrderedList().run()}
20 | {...props}
21 | />
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonRemoveFormatting.tsx:
--------------------------------------------------------------------------------
1 | import FormatClear from "@mui/icons-material/FormatClear";
2 | import { useRichTextEditorContext } from "../context";
3 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
4 |
5 | export type MenuButtonRemoveFormattingProps = Partial;
6 |
7 | /**
8 | * A control button removes all inline formatting of marks by calling Tiptap’s
9 | * unsetAllMarks command (https://tiptap.dev/api/commands/unset-all-marks).
10 | */
11 | export default function MenuButtonRemoveFormatting(
12 | props: MenuButtonRemoveFormattingProps
13 | ) {
14 | const editor = useRichTextEditorContext();
15 | return (
16 | editor?.chain().focus().unsetAllMarks().run()}
21 | {...props}
22 | />
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonAlignCenter.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import FormatAlignCenterIcon from "@mui/icons-material/FormatAlignCenter";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonAlignCenterProps = Partial;
7 |
8 | export default function MenuButtonAlignCenter(
9 | props: MenuButtonAlignCenterProps
10 | ) {
11 | const editor = useRichTextEditorContext();
12 | return (
13 | editor?.chain().focus().setTextAlign("center").run()}
20 | {...props}
21 | />
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonAlignJustify.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import FormatAlignJustifyIcon from "@mui/icons-material/FormatAlignJustify";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonAlignJustifyProps = Partial;
7 |
8 | export default function MenuButtonAlignJustify(
9 | props: MenuButtonAlignJustifyProps
10 | ) {
11 | const editor = useRichTextEditorContext();
12 | return (
13 | editor?.chain().focus().setTextAlign("justify").run()}
20 | {...props}
21 | />
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | // only use words from .cspell.json
3 | "cSpell.userWords": [],
4 | "cSpell.enabled": true,
5 | "editor.defaultFormatter": "esbenp.prettier-vscode",
6 | "editor.formatOnSave": true,
7 | "editor.insertSpaces": true,
8 | "files.exclude": {
9 | "**/node_modules/**": true,
10 | "htmlcov/": true,
11 | ".coverage": true,
12 | "coverage.xml": true,
13 | "**/.eslintcache": true,
14 | "yarn-error.log": true
15 | },
16 | "files.trimTrailingWhitespace": true,
17 | "files.trimFinalNewlines": true,
18 | "files.insertFinalNewline": true,
19 | "[json]": {
20 | "editor.tabSize": 2
21 | },
22 | "[typescript][typescriptreact][javascript][javascriptreact]": {
23 | "editor.codeActionsOnSave": {
24 | "source.fixAll.eslint": "explicit"
25 | },
26 | "editor.tabSize": 2
27 | },
28 | "eslint.workingDirectories": [{ "mode": "auto" }],
29 | "typescript.tsdk": "node_modules/typescript/lib",
30 | "typescript.enablePromptUseWorkspaceTsdk": true
31 | }
32 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonEditLink.tsx:
--------------------------------------------------------------------------------
1 | import Link from "@mui/icons-material/Link";
2 | import { useRef } from "react";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonEditLinkProps = Partial;
7 |
8 | export default function MenuButtonEditLink(props: MenuButtonEditLinkProps) {
9 | const editor = useRichTextEditorContext();
10 | const buttonRef = useRef(null);
11 | return (
12 |
20 | // When clicking the button to open the bubble menu, we'll place the
21 | // menu below the button
22 | editor?.commands.openLinkBubbleMenu({
23 | anchorEl: buttonRef.current,
24 | placement: "bottom",
25 | })
26 | }
27 | {...props}
28 | />
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 sjdemartini
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/utils/keymapPluginFactory.ts:
--------------------------------------------------------------------------------
1 | import { keydownHandler } from "@tiptap/pm/keymap";
2 | import { Plugin, type Command, type PluginKey } from "@tiptap/pm/state";
3 |
4 | /**
5 | * Create a `keymap` prosemirror plugin for keyboard shortcut use in Tiptap.
6 | *
7 | * This is an alternative to the `prosemirror-keymap` `keymap` factory function
8 | * (https://github.com/ProseMirror/prosemirror-keymap/blob/bcc8280e38900edeb6ed946e496ad7dbc0c17f95/src/keymap.js#L72-L74)
9 | * and follows identical logic, but because (a) Tiptap will only unregister
10 | * plugins properly if they have unique string names or `PluginKeys`
11 | * (https://github.com/ueberdosis/tiptap/blob/5daa870b0906f0387fe07041681bc6f5b3774617/packages/core/src/Editor.ts#L217-L220),
12 | * and (b) the original `keymap` function doesn't allow us to specify/define our
13 | * own key, we have to use our own factory function that allows us to specify
14 | * one.
15 | */
16 | export default function keymapPluginFactory(
17 | bindings: Record,
18 | key: PluginKey
19 | ): Plugin {
20 | return new Plugin({
21 | key,
22 | props: {
23 | handleKeyDown: keydownHandler(bindings),
24 | },
25 | });
26 | }
27 |
--------------------------------------------------------------------------------
/example/src/PageContentWithEditorSimple.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@mui/material";
2 | import { StarterKit } from "@tiptap/starter-kit";
3 | import {
4 | MenuButtonBold,
5 | MenuButtonItalic,
6 | MenuControlsContainer,
7 | MenuDivider,
8 | MenuSelectHeading,
9 | RichTextEditor,
10 | type RichTextEditorRef,
11 | } from "mui-tiptap";
12 | import { useRef } from "react";
13 |
14 | export default function PageContentWithEditorSimple() {
15 | const rteRef = useRef(null);
16 |
17 | return (
18 |
19 | (
25 |
26 |
27 |
28 |
29 |
30 | {/* Add more controls of your choosing here */}
31 |
32 | )}
33 | />
34 |
35 | console.log(rteRef.current?.editor?.getHTML())}>
36 | Log HTML
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/utils/slugify.ts:
--------------------------------------------------------------------------------
1 | /** Convert a string to a URL slug. */
2 | export default function slugify(text: string): string {
3 | // This is nearly a direct port of Django's slugify, minus the unicode
4 | // handling, since regex in JS doesn't seem to handle unicode chars as a part
5 | // of \w (see here https://mathiasbynens.be/notes/es6-unicode-regex for more
6 | // details).
7 | // https://docs.djangoproject.com/en/4.0/ref/utils/#django.utils.text.slugify
8 | // https://github.com/django/django/blob/7119f40c9881666b6f9b5cf7df09ee1d21cc8344/django/utils/text.py#L399-L417
9 | // Copyright (c) Django Software Foundation and individual contributors.
10 | // All rights reserved.
11 | return (
12 | text
13 | .toLowerCase()
14 | // Convert to nearest compatible ascii chars
15 | // (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize)
16 | .normalize("NFKD")
17 | // Remove characters that aren’t alphanumerics, underscores, hyphens, or
18 | // whitespace
19 | .replace(/[^\w\s-]+/g, "")
20 | // Replace any whitespace or repeated dashes with single dashes
21 | .replace(/[-\s]+/g, "-")
22 | // Remove leading and trailing whitespace, dashes, and underscores
23 | .replace(/^[\s-_]+|[\s-_]+$/g, "")
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/utils/getAttributesForNodes.ts:
--------------------------------------------------------------------------------
1 | import { getNodeType } from "@tiptap/core";
2 | import type { Node, NodeType } from "@tiptap/pm/model";
3 | import type { EditorState } from "@tiptap/pm/state";
4 |
5 | /**
6 | * Get the attributes of all currently selected nodes of the given type or
7 | * name.
8 | *
9 | * Returns an array of Records, with an entry for each matching node that is
10 | * currently selected.
11 | *
12 | * Based directly on Tiptap's getNodeAttributes
13 | * (https://github.com/ueberdosis/tiptap/blob/f387ad3dd4c2b30eaea33fb0ba0b42e0cd39263b/packages/core/src/helpers/getNodeAttributes.ts),
14 | * but returns results for each of the matching nodes, rather than just the
15 | * first. See related: https://github.com/ueberdosis/tiptap/issues/3481
16 | */
17 | export function getAttributesForNodes(
18 | state: EditorState,
19 | typeOrName: string | NodeType
20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
21 | ): Record[] {
22 | const type = getNodeType(typeOrName, state.schema);
23 | const { from, to } = state.selection;
24 | const nodes: Node[] = [];
25 |
26 | state.doc.nodesBetween(from, to, (node) => {
27 | nodes.push(node);
28 | });
29 |
30 | return nodes
31 | .reverse()
32 | .filter((nodeItem) => nodeItem.type.name === type.name)
33 | .map((node) => ({ ...node.attrs }));
34 | }
35 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonHighlightToggle.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import { useRichTextEditorContext } from "../context";
3 | import { FormatInkHighlighter } from "../icons";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | export type MenuButtonHighlightToggleProps = Partial;
7 |
8 | /**
9 | * Control for a user to toggle text highlighting with the
10 | * @tiptap/extension-highlight, just using the default ``
11 | * background-color.
12 | *
13 | * This is typically useful when using the default Highlight extension
14 | * configuration (*not* configuring with `mulitcolor: true`). See
15 | * MenuButtonHighlightColor for a multicolor-oriented color-selection highlight
16 | * control.
17 | */
18 | export default function MenuButtonHighlightToggle({
19 | ...menuButtonProps
20 | }: MenuButtonHighlightToggleProps) {
21 | const editor = useRichTextEditorContext();
22 | return (
23 | editor?.chain().focus().toggleHighlight().run()}
30 | {...menuButtonProps}
31 | />
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | default as ControlledBubbleMenu,
3 | type ControlledBubbleMenuProps,
4 | } from "./ControlledBubbleMenu";
5 | export {
6 | default as LinkBubbleMenu,
7 | type LinkBubbleMenuProps,
8 | } from "./LinkBubbleMenu";
9 | export { default as MenuBar, type MenuBarProps } from "./MenuBar";
10 | export { default as MenuDivider, type MenuDividerProps } from "./MenuDivider";
11 | export {
12 | default as RichTextContent,
13 | type RichTextContentProps,
14 | } from "./RichTextContent";
15 | export {
16 | default as RichTextEditor,
17 | type RichTextEditorProps,
18 | type RichTextEditorRef,
19 | } from "./RichTextEditor";
20 | export {
21 | default as RichTextEditorProvider,
22 | type RichTextEditorProviderProps,
23 | } from "./RichTextEditorProvider";
24 | export {
25 | default as RichTextField,
26 | type RichTextFieldProps,
27 | } from "./RichTextField";
28 | export {
29 | default as RichTextReadOnly,
30 | type RichTextReadOnlyProps,
31 | } from "./RichTextReadOnly";
32 | export {
33 | default as TableBubbleMenu,
34 | type TableBubbleMenuProps,
35 | } from "./TableBubbleMenu";
36 | export { RichTextEditorContext, useRichTextEditorContext } from "./context";
37 | export * from "./controls";
38 | export * from "./extensions";
39 | export * from "./hooks";
40 | export {
41 | Z_INDEXES,
42 | getEditorStyles,
43 | getImageBackgroundColorStyles,
44 | } from "./styles";
45 | export * from "./utils";
46 |
--------------------------------------------------------------------------------
/src/utils/color.ts:
--------------------------------------------------------------------------------
1 | import { rgbToHex } from "@mui/material";
2 |
3 | /**
4 | * Convert a color string to a color in hex string format (e.g. "#ff0000"), or
5 | * return null if the given string cannot be parsed as a valid color.
6 | *
7 | * Examples:
8 | * "rgb(169, 79, 211)" -> "#a94fd3"
9 | * "#a94fd3" -> "#a94fd3"
10 | * "not a color" -> null
11 | *
12 | * Uses @mui/material's `rgbToHex` function, which supports input strings in
13 | * these formats: hex like #000 and #00000000, rgb(), rgba(), hsl(), hsla(), and
14 | * color(). See
15 | * https://github.com/mui/material-ui/blob/e00a4d857fb2ea1b181afc35d0fd1ffc5631f0fe/packages/mui-system/src/colorManipulator.js#L54
16 | *
17 | * Separate third party libraries could be used instead of this function to
18 | * offer more full-featured parsing (e.g. handling CSS color name keywords,
19 | * cmyk, etc.), such as colord (https://www.npmjs.com/package/colord) and
20 | * tinycolor2 (https://www.npmjs.com/package/@ctrl/tinycolor), which have a
21 | * relatively small footprint. They are not used directly by mui-tiptap to keep
22 | * dependencies as lean as possible.
23 | */
24 | export function colorToHex(color: string): string | null {
25 | try {
26 | // Though this function is named `rgbToHex`, it supports colors in various
27 | // formats (rgba, hex, hsl, etc.) as well
28 | return rgbToHex(color);
29 | } catch (err) {
30 | return null;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/__tests__/utils/slugify.test.ts:
--------------------------------------------------------------------------------
1 | /* cSpell:disable */
2 | import { describe, expect, it } from "vitest";
3 | import slugify from "../../utils/slugify";
4 |
5 | describe("slugify()", () => {
6 | // Test cases are modeled after Django's slugify, modified based on unicode
7 | // regex not being supported equivalently in JS
8 | // https://github.com/django/django/blob/7119f40c9881666b6f9b5cf7df09ee1d21cc8344/tests/utils_tests/test_text.py#L225
9 | // Copyright (c) Django Software Foundation and individual contributors.
10 | // All rights reserved.
11 | const items: [string, string][] = [
12 | // given, expected, allowUnicode
13 | ["Hello, World!", "hello-world"],
14 | ["spam & eggs", "spam-eggs"],
15 | [" multiple---dash and space ", "multiple-dash-and-space"],
16 | ["\t whitespace-in-value \n", "whitespace-in-value"],
17 | ["underscore_in-value", "underscore_in-value"],
18 | ["__strip__underscore-value___", "strip__underscore-value"],
19 | ["--strip-dash-value---", "strip-dash-value"],
20 | ["__strip-mixed-value---", "strip-mixed-value"],
21 | ["_ -strip-mixed-value _-", "strip-mixed-value"],
22 | ["spam & ıçüş", "spam-cus"],
23 | ["foo ıç bar", "foo-c-bar"],
24 | [" foo ıç bar", "foo-c-bar"],
25 | ["yes-你好", "yes"],
26 | ["İstanbul", "istanbul"],
27 | ];
28 |
29 | items.forEach(([given, expected]) => {
30 | it(`handles ${given}`, () => {
31 | expect(slugify(given)).toBe(expected);
32 | });
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/src/icons/index.ts:
--------------------------------------------------------------------------------
1 | // These icons fill some gaps in the @mui/icons-material icon set. We include
2 | // them directly here rather than importing from an external package to reduce
3 | // install size and external dependencies (see
4 | // https://github.com/sjdemartini/mui-tiptap/issues/119).
5 | export { default as BorderColorNoBar } from "./BorderColorNoBar";
6 | export { default as CodeBlock } from "./CodeBlock";
7 | export { default as DeleteColumn } from "./DeleteColumn";
8 | export { default as DeleteRow } from "./DeleteRow";
9 | export { default as FormatColorBar } from "./FormatColorBar";
10 | export { default as FormatColorFillNoBar } from "./FormatColorFillNoBar";
11 | export { default as FormatColorTextNoBar } from "./FormatColorTextNoBar";
12 | export { default as FormatInkHighlighter } from "./FormatInkHighlighter";
13 | export { default as FormatInkHighlighterNoBar } from "./FormatInkHighlighterNoBar";
14 | export { default as InsertColumnLeft } from "./InsertColumnLeft";
15 | export { default as InsertColumnRight } from "./InsertColumnRight";
16 | export { default as InsertRowBottom } from "./InsertRowBottom";
17 | export { default as InsertRowTop } from "./InsertRowTop";
18 | export { default as LayoutColumnFill } from "./LayoutColumnFill";
19 | export { default as LayoutRowFill } from "./LayoutRowFill";
20 | export { default as MergeCellsHorizontal } from "./MergeCellsHorizontal";
21 | export { default as SplitCellsHorizontal } from "./SplitCellsHorizontal";
22 | export { default as Table } from "./Table";
23 |
--------------------------------------------------------------------------------
/src/utils/getAttributesForMarks.ts:
--------------------------------------------------------------------------------
1 | import { getMarkType } from "@tiptap/core";
2 | import type { Mark, MarkType } from "@tiptap/pm/model";
3 | import type { EditorState } from "@tiptap/pm/state";
4 |
5 | /**
6 | * Get the attributes of all currently selected marks of the given type or
7 | * name.
8 | *
9 | * Returns an array of Records, with an entry for each matching mark that is
10 | * currently selected.
11 | *
12 | * Based directly on Tiptap's getMarkAttributes
13 | * (https://github.com/ueberdosis/tiptap/blob/f387ad3dd4c2b30eaea33fb0ba0b42e0cd39263b/packages/core/src/helpers/getMarkAttributes.ts),
14 | * but returns results for each of the matching marks, rather than just the
15 | * first. See related: https://github.com/ueberdosis/tiptap/issues/3481
16 | */
17 | export function getAttributesForMarks(
18 | state: EditorState,
19 | typeOrName: string | MarkType
20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
21 | ): Record[] {
22 | const type = getMarkType(typeOrName, state.schema);
23 | const { from, to, empty } = state.selection;
24 | const marks: Mark[] = [];
25 |
26 | if (empty) {
27 | if (state.storedMarks) {
28 | marks.push(...state.storedMarks);
29 | }
30 |
31 | marks.push(...state.selection.$head.marks());
32 | } else {
33 | state.doc.nodesBetween(from, to, (node) => {
34 | marks.push(...node.marks);
35 | });
36 | }
37 |
38 | return marks
39 | .filter((markItem) => markItem.type.name === type.name)
40 | .map((mark) => ({ ...mark.attrs }));
41 | }
42 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ""
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | ### Describe the bug
10 |
11 | A clear and concise description of what the bug is.
12 |
13 | Before filing a bug, please confirm that you have:
14 |
15 | 1. Installed the necessary Tiptap extensions for whatever functionality you are trying to use (e.g. `Color` extension for using the `MenuButtonTextColor`).
16 | 2. Passed the extensions you need to the `extensions` field of `RichTextEditor`/`useEditor`
17 |
18 | See [README info here](https://github.com/sjdemartini/mui-tiptap#choosing-your-editor-extensions) for more details.
19 |
20 | ### To Reproduce
21 |
22 | Please include a CodeSandbox demo of the problem if possible. (You can fork [this CodeSandbox](https://codesandbox.io/p/sandbox/mui-tiptap-demo-3zl2l6).)
23 |
24 | Steps to reproduce the behavior:
25 |
26 | 1.
27 | 2.
28 | 3.
29 |
30 | ### Expected behavior
31 |
32 | A clear and concise description of what you expected to happen.
33 |
34 | ### Screenshots
35 |
36 | If applicable, add screenshots to help explain your problem.
37 |
38 | ### System (please complete the following information)
39 |
40 | - mui-tiptap version: [e.g. 1.1.0]
41 | - tiptap version: [e.g. 2.0.0]
42 | - Browser: [e.g. Chrome, Firefox]
43 | - Node version: [e.g 16.4.2]
44 | - OS: [e.g. Ubuntu 22.04, macOS 11.4]
45 | - Copy-paste your `extensions` array used for the editor:
46 |
47 | ```tsx
48 |
49 | ```
50 |
51 | ### Additional context
52 |
53 | Add any other context about the problem here.
54 |
--------------------------------------------------------------------------------
/src/utils/platform.ts:
--------------------------------------------------------------------------------
1 | // We'll cache the result of isMac() and isTouchDevice(), since they shouldn't
2 | // change during a session. That way repeated calls don't require any logic and
3 | // are rapid.
4 | let isMacResult: boolean | undefined;
5 | let isTouchDeviceResult: boolean | undefined;
6 |
7 | /**
8 | * Return true if the user is using a Mac (as opposed to Windows, etc.) device.
9 | */
10 | export function isMac(): boolean {
11 | if (isMacResult === undefined) {
12 | isMacResult = navigator.platform.includes("Mac");
13 | }
14 | return isMacResult;
15 | }
16 |
17 | /**
18 | * Return a human-readable version of which modifier key should be used for
19 | * keyboard shortcuts depending on Mac vs non-Mac platforms. Useful for visually
20 | * indicating which key to press.
21 | */
22 | export function getModShortcutKey(): string {
23 | return isMac() ? "⌘" : "Ctrl";
24 | }
25 |
26 | /** Return true if the user is using a touch-based device. */
27 | export function isTouchDevice(): boolean {
28 | if (isTouchDeviceResult === undefined) {
29 | // This technique is taken from
30 | // https://hacks.mozilla.org/2013/04/detecting-touch-its-the-why-not-the-how/
31 | // (and https://stackoverflow.com/a/4819886/4543977)
32 | isTouchDeviceResult =
33 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
34 | (window && "ontouchstart" in window) ||
35 | navigator.maxTouchPoints > 0 ||
36 | // @ts-expect-error: msMaxTouchPoints is IE-specific, so needs to be ignored
37 | navigator.msMaxTouchPoints > 0;
38 | }
39 |
40 | return isTouchDeviceResult;
41 | }
42 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonAddImage.tsx:
--------------------------------------------------------------------------------
1 | import AddPhotoAlternate from "@mui/icons-material/AddPhotoAlternate";
2 | import type { SetRequired } from "type-fest";
3 | import { useRichTextEditorContext } from "../context";
4 | import MenuButton, { type MenuButtonProps } from "./MenuButton";
5 |
6 | /**
7 | * You must provide your own `onClick` handler.
8 | */
9 | export type MenuButtonAddImageProps = SetRequired<
10 | Partial,
11 | "onClick"
12 | >;
13 |
14 | /**
15 | * Render a button for adding an image to the editor content. You must provide
16 | * your own `onClick` prop in order to specify *how* the image is added. For
17 | * instance, you might open a popup for the user to provide an image URL, or you
18 | * might trigger a file upload via file input dialog.
19 | *
20 | * Once the image URL is ready (after the user has filled it out or after an
21 | * upload has completed), you can typically use something like:
22 | *
23 | * editor.chain().focus().setImage({ src: url }).run()
24 | *
25 | * See Tiptap's example here https://tiptap.dev/api/nodes/image.
26 | */
27 | export default function MenuButtonAddImage({
28 | ...props
29 | }: MenuButtonAddImageProps) {
30 | const editor = useRichTextEditorContext();
31 |
32 | return (
33 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/demo/App.tsx:
--------------------------------------------------------------------------------
1 | import Brightness4Icon from "@mui/icons-material/Brightness4";
2 | import Brightness7Icon from "@mui/icons-material/Brightness7";
3 | import {
4 | AppBar,
5 | Box,
6 | CssBaseline,
7 | IconButton,
8 | ThemeProvider,
9 | Toolbar,
10 | Typography,
11 | createTheme,
12 | useMediaQuery,
13 | type PaletteMode,
14 | } from "@mui/material";
15 | import { useCallback, useMemo, useState } from "react";
16 | import Editor from "./Editor";
17 |
18 | export default function App() {
19 | const systemSettingsPrefersDarkMode = useMediaQuery(
20 | "(prefers-color-scheme: dark)"
21 | );
22 | const [paletteMode, setPaletteMode] = useState(
23 | systemSettingsPrefersDarkMode ? "dark" : "light"
24 | );
25 | const togglePaletteMode = useCallback(
26 | () =>
27 | setPaletteMode((prevMode) => (prevMode === "light" ? "dark" : "light")),
28 | []
29 | );
30 | const theme = useMemo(
31 | () =>
32 | createTheme({
33 | palette: {
34 | mode: paletteMode,
35 | secondary: {
36 | main: "#42B81A",
37 | },
38 | },
39 | }),
40 | [paletteMode]
41 | );
42 |
43 | return (
44 |
45 |
46 |
47 |
48 |
49 |
50 | mui-tiptap
51 |
52 |
53 |
54 | {theme.palette.mode === "dark" ? (
55 |
56 | ) : (
57 |
58 | )}
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/hooks/useDebouncedFocus.ts:
--------------------------------------------------------------------------------
1 | import type { Editor } from "@tiptap/core";
2 | import debounce from "lodash/debounce";
3 | import { useEffect, useMemo, useState } from "react";
4 |
5 | export type UseDebouncedFocusOptions = {
6 | editor: Editor | null;
7 | /**
8 | * The debounce wait timeout in ms for updating focused state. By default 250.
9 | */
10 | wait?: number;
11 | };
12 |
13 | /**
14 | * A hook for getting the Tiptap editor focused state, but debounced to prevent
15 | * "flashing" for brief blur/refocus moments, like when interacting with the
16 | * menu bar buttons.
17 | *
18 | * This is useful for showing the focus state visually, as with the `focused`
19 | * prop of .
20 | */
21 | export default function useDebouncedFocus({
22 | editor,
23 | wait = 250,
24 | }: UseDebouncedFocusOptions): boolean {
25 | const [isFocusedDebounced, setIsFocusedDebounced] = useState(
26 | !!editor?.isFocused
27 | );
28 |
29 | const updateIsFocusedDebounced = useMemo(
30 | () => debounce((focused: boolean) => setIsFocusedDebounced(focused), wait),
31 | [wait]
32 | );
33 |
34 | useEffect(() => {
35 | const isFocused = !!editor?.isFocused;
36 | updateIsFocusedDebounced(isFocused);
37 |
38 | // We'll immediately "flush" to update the focused state of the outlined field when
39 | // the editor *becomes* focused (e.g. when a user first clicks into it), but we'll
40 | // debounce otherwise, since the editor can lose focus as a user interacts with the
41 | // menu bar, for instance. It feels fine to have a visual delay losing the focus
42 | // outline, but awkward to have delay in gaining the focus outline.
43 | if (isFocused) {
44 | updateIsFocusedDebounced.flush();
45 | }
46 |
47 | return () => {
48 | updateIsFocusedDebounced.cancel();
49 | };
50 | }, [editor?.isFocused, updateIsFocusedDebounced]);
51 |
52 | return isFocusedDebounced;
53 | }
54 |
--------------------------------------------------------------------------------
/src/utils/getAttributesForEachSelected.ts:
--------------------------------------------------------------------------------
1 | import { getSchemaTypeNameByName } from "@tiptap/core";
2 | import type { MarkType, NodeType } from "@tiptap/pm/model";
3 | import type { EditorState } from "@tiptap/pm/state";
4 | import { getAttributesForMarks } from "./getAttributesForMarks";
5 | import { getAttributesForNodes } from "./getAttributesForNodes";
6 |
7 | /**
8 | * Get the attributes of all currently selected marks and nodes of the given
9 | * type or name.
10 | *
11 | * Returns an array of Records, with an entry for each matching mark/node that
12 | * is currently selected.
13 | *
14 | * NOTE: This function will omit any non-matching nodes/marks in the result
15 | * array. It may be useful to run `editor.isActive(typeOrName)` separately if
16 | * you want to guarantee that all selected content is of the given type/name.
17 | *
18 | * Based directly on Tiptap's getAttributes
19 | * (https://github.com/ueberdosis/tiptap/blob/f387ad3dd4c2b30eaea33fb0ba0b42e0cd39263b/packages/core/src/helpers/getAttributes.ts),
20 | * but returns results for each of the matching marks and nodes, rather than
21 | * just the first. This enables us to handle situations where there are multiple
22 | * different attributes set for the different marks/nodes. See related issue
23 | * here: https://github.com/ueberdosis/tiptap/issues/3481
24 | */
25 | export function getAttributesForEachSelected(
26 | state: EditorState,
27 | typeOrName: string | NodeType | MarkType
28 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
29 | ): Record[] {
30 | const schemaType = getSchemaTypeNameByName(
31 | typeof typeOrName === "string" ? typeOrName : typeOrName.name,
32 | state.schema
33 | );
34 |
35 | if (schemaType === "node") {
36 | return getAttributesForNodes(state, typeOrName as NodeType);
37 | }
38 |
39 | if (schemaType === "mark") {
40 | return getAttributesForMarks(state, typeOrName as MarkType);
41 | }
42 |
43 | return [];
44 | }
45 |
--------------------------------------------------------------------------------
/example/src/App.tsx:
--------------------------------------------------------------------------------
1 | import Brightness4Icon from "@mui/icons-material/Brightness4";
2 | import Brightness7Icon from "@mui/icons-material/Brightness7";
3 | import {
4 | AppBar,
5 | Box,
6 | CssBaseline,
7 | IconButton,
8 | ThemeProvider,
9 | Toolbar,
10 | Typography,
11 | createTheme,
12 | useMediaQuery,
13 | type PaletteMode,
14 | } from "@mui/material";
15 | import { useCallback, useMemo, useState } from "react";
16 | import PageContentWithEditor from "./PageContentWithEditor";
17 |
18 | export default function App() {
19 | const systemSettingsPrefersDarkMode = useMediaQuery(
20 | "(prefers-color-scheme: dark)"
21 | );
22 | const [paletteMode, setPaletteMode] = useState(
23 | systemSettingsPrefersDarkMode ? "dark" : "light"
24 | );
25 | const togglePaletteMode = useCallback(
26 | () =>
27 | setPaletteMode((prevMode) => (prevMode === "light" ? "dark" : "light")),
28 | []
29 | );
30 | const theme = useMemo(
31 | () =>
32 | createTheme({
33 | palette: {
34 | mode: paletteMode,
35 | },
36 | }),
37 | [paletteMode]
38 | );
39 |
40 | return (
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | Example app using mui-tiptap
49 |
50 |
51 |
52 | {theme.palette.mode === "dark" ? (
53 |
54 | ) : (
55 |
56 | )}
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Publish Package to npmjs
2 | on:
3 | push:
4 | tags:
5 | - "v*.*.*"
6 | jobs:
7 | # Re-run the usual tests and build steps to ensure things are stable for release
8 | build-test:
9 | uses: ./.github/workflows/build-test.yml # use the callable built-test to run tests
10 |
11 | # Then release to npm
12 | release:
13 | runs-on: ubuntu-latest
14 | permissions:
15 | contents: read
16 | id-token: write # Needed for https://docs.npmjs.com/generating-provenance-statements
17 | strategy:
18 | matrix:
19 | node-version: [18]
20 |
21 | needs: [build-test] # Require standard CI steps to pass before publishing
22 |
23 | steps:
24 | - name: Checkout repo
25 | uses: actions/checkout@v3
26 |
27 | # Set up .npmrc file to publish to npm. This also allows NODE_AUTH_TOKEN
28 | # to work below.
29 | - uses: actions/setup-node@v3
30 | with:
31 | node-version: "18.x"
32 | registry-url: "https://registry.npmjs.org"
33 |
34 | - name: Setup pnpm
35 | uses: pnpm/action-setup@v2
36 | with:
37 | version: 8
38 |
39 | - run: pnpm install
40 |
41 | - run: pnpm run build
42 |
43 | # TODO(Steven DeMartini): If pre-releases are used in the future (after a
44 | # non-alpha release has been published), we'll probably want to update
45 | # this command to pass in `--tag` with next/alpha/beta as appropriate, to
46 | # avoid updating the default `latest` tag. We could presumably parse the
47 | # package version using something like
48 | # `cat package.json | jq -r '.version'`
49 | # (https://gist.github.com/DarrenN/8c6a5b969481725a4413?permalink_comment_id=4156395#gistcomment-4156395)
50 | # to parse the version, then could regex for whether it's a pre-release or
51 | # not.
52 | - run: npm publish --provenance
53 | env:
54 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
55 |
--------------------------------------------------------------------------------
/src/controls/MenuControlsContainer.tsx:
--------------------------------------------------------------------------------
1 | import { makeStyles } from "tss-react/mui";
2 | import type { Except } from "type-fest";
3 | import DebounceRender, {
4 | type DebounceRenderProps,
5 | } from "../utils/DebounceRender";
6 |
7 | export type MenuControlsContainerProps = {
8 | /** The set of controls (buttons, etc) to include in the menu bar. */
9 | children?: React.ReactNode;
10 | className?: string;
11 | /**
12 | * If true, the rendering of the children content here will be debounced, as a
13 | * way to improve performance. If this component is rendered in the same
14 | * context as Tiptap's `useEditor` and *not* debounced, then upon every editor
15 | * interaction (caret movement, character typed, etc.), the entire controls
16 | * content will re-render, which can bog down the editor, so debouncing is
17 | * usually recommended. Controls are often expensive to render since they need
18 | * to check a lot of editor state, with `editor.can()` commands and whatnot.
19 | */
20 | debounced?: boolean;
21 | /**
22 | * Override the props/options used with debounce rendering such as the wait
23 | * interval, if `debounced` is true.
24 | */
25 | DebounceProps?: Except;
26 | };
27 |
28 | const useStyles = makeStyles({
29 | name: { MenuControlsContainer: MenuControlsContainer },
30 | })((theme) => {
31 | return {
32 | root: {
33 | display: "flex",
34 | rowGap: theme.spacing(0.3),
35 | columnGap: theme.spacing(0.3),
36 | alignItems: "center",
37 | flexWrap: "wrap",
38 | },
39 | };
40 | });
41 |
42 | /** Provides consistent spacing between different editor controls components. */
43 | export default function MenuControlsContainer({
44 | children,
45 | className,
46 | debounced,
47 | DebounceProps,
48 | }: MenuControlsContainerProps) {
49 | const { classes, cx } = useStyles();
50 | const content = {children}
;
51 | return debounced ? (
52 | {content}
53 | ) : (
54 | content
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/utils/images.ts:
--------------------------------------------------------------------------------
1 | import type { Editor, JSONContent } from "@tiptap/core";
2 |
3 | // See
4 | // https://github.com/ueberdosis/tiptap/blob/6cbc2d423391c950558721510c1b4c8614feb534/packages/extension-image/src/image.ts#L48-L58
5 | export type ImageNodeAttributes = {
6 | /** The URL at which this image can be served. Used as `src`. */
7 | src: string;
8 | /** Alt text for the image. */
9 | alt?: string;
10 | /** The `title` attribute when we render the image element. */
11 | title?: string;
12 | };
13 |
14 | /**
15 | * Insert the given array of images into the Tiptap editor document content.
16 | *
17 | * Optionally specify a given position at which to insert the images into the
18 | * editor content. If not given, the user's current selection (if there is any)
19 | * will be replaced by the newly inserted images.
20 | *
21 | * @param options.images The attributes of each image to insert
22 | * @param options.editor The Tiptap editor in which to insert
23 | * @param options.position The position at which to insert into the editor
24 | * content. If not given, uses the current editor caret/selection position.
25 | */
26 | export function insertImages({
27 | images,
28 | editor,
29 | position,
30 | }: {
31 | images: ImageNodeAttributes[];
32 | editor: Editor | null;
33 | position?: number;
34 | }): void {
35 | if (!editor || editor.isDestroyed || images.length === 0) {
36 | return;
37 | }
38 |
39 | const imageContentToInsert: JSONContent[] = images
40 | .filter((imageAttrs) => !!imageAttrs.src)
41 | .map((imageAttrs) => ({
42 | type: editor.schema.nodes.image.name,
43 | attrs: imageAttrs,
44 | }));
45 |
46 | editor
47 | .chain()
48 | .command(({ commands }) => {
49 | if (position == null) {
50 | // We'll insert at and replace the user's current selection if there
51 | // wasn't a specific insert position given
52 | return commands.insertContent(imageContentToInsert);
53 | } else {
54 | return commands.insertContentAt(position, imageContentToInsert);
55 | }
56 | })
57 | .focus()
58 | .run();
59 | }
60 |
--------------------------------------------------------------------------------
/src/hooks/useEditorOnEditableUpdate.ts:
--------------------------------------------------------------------------------
1 | import type { Editor, EditorEvents } from "@tiptap/core";
2 | import { useEffect, useRef } from "react";
3 |
4 | export type UseEditorOnEditableUpdateOptions = {
5 | editor: Editor | null;
6 | /**
7 | * The function that will be called when editor.isEditable is changed. Set to
8 | * null or undefined to turn off the listener.
9 | */
10 | callback?: ((props: EditorEvents["update"]) => void) | null | undefined;
11 | };
12 |
13 | /**
14 | * A hook for listening to changes in the Tiptap editor isEditable state, via
15 | * "update" event.
16 | *
17 | * This can be useful inside of ReactNodeViews that depend on editor isEditable
18 | * state. As described here https://github.com/ueberdosis/tiptap/issues/3775,
19 | * updates to editor isEditable do not trigger re-rendering of node views. Even
20 | * editor state changes external to a given ReactNodeView component will not
21 | * trigger re-render (which is probably a good thing most of the time, in terms
22 | * of performance). As such, this hook can listen for editor.isEditable changes
23 | * and can be used to force a re-render, update state, etc.
24 | */
25 | export default function useEditorOnEditableUpdate({
26 | editor,
27 | callback,
28 | }: UseEditorOnEditableUpdateOptions): void {
29 | const callbackRef = useRef(callback);
30 | const isEditableRef = useRef(editor?.isEditable);
31 |
32 | useEffect(() => {
33 | callbackRef.current = callback;
34 | }, [callback]);
35 |
36 | const hasCallback = !!callback;
37 | useEffect(() => {
38 | if (!editor || editor.isDestroyed || !hasCallback) {
39 | return;
40 | }
41 |
42 | isEditableRef.current = editor.isEditable;
43 |
44 | function handleUpdate(props: EditorEvents["update"]) {
45 | if (
46 | !editor ||
47 | editor.isDestroyed ||
48 | editor.isEditable === isEditableRef.current
49 | ) {
50 | return;
51 | }
52 |
53 | // The editable state has changed!
54 | isEditableRef.current = editor.isEditable;
55 | callbackRef.current?.(props);
56 | }
57 |
58 | editor.on("update", handleUpdate);
59 |
60 | return () => {
61 | editor.off("update", handleUpdate);
62 | };
63 | }, [editor, hasCallback]);
64 | }
65 |
--------------------------------------------------------------------------------
/.github/workflows/build-test.yml:
--------------------------------------------------------------------------------
1 | name: build-test
2 |
3 | # Run this for pushes to the main branch and for pull requests, and allow this
4 | # to be called from other workflows
5 | on:
6 | push:
7 | branches: ["main"]
8 | pull_request:
9 | workflow_call:
10 |
11 | jobs:
12 | build_and_test:
13 | runs-on: ubuntu-latest
14 | strategy:
15 | matrix:
16 | node-version: [18]
17 |
18 | steps:
19 | - name: Checkout repo
20 | uses: actions/checkout@v3
21 |
22 | - name: Setup pnpm
23 | uses: pnpm/action-setup@v2
24 | with:
25 | version: 8
26 |
27 | - run: pnpm install
28 |
29 | - name: Dependencies audit
30 | run: pnpm audit --audit-level=high
31 |
32 | - name: Check for duplicate dependencies
33 | run: pnpm dedupe --check
34 |
35 | - name: Build
36 | run: pnpm run build
37 |
38 | - name: Type check
39 | # Though this largely happens as part of `pnpm run build`, there are
40 | # some files that are not part of the publishable build but which we'd
41 | # also like to type-check.
42 | run: pnpm run type:check
43 |
44 | - name: Format check
45 | run: pnpm run format:check
46 |
47 | - name: Lint check
48 | run: pnpm run lint:check
49 |
50 | - name: Spell check
51 | run: pnpm run spell:check
52 | # We run the spell check so we can see the output in CI if/when we like,
53 | # but we won't enforce it, since there are too many technical/product
54 | # words used that are outside the standard dictionary.
55 | continue-on-error: true
56 |
57 | - name: Validate markdown links
58 | # Since most of our documentation is in README.md, notably with lots of
59 | # internal anchor links, validate that they're formed properly. As the
60 | # README and documentation is updated, we want to ensure that links
61 | # remain correct. (This also checks external links, though that's a
62 | # secondary concern.)
63 | run: pnpm run md-link:check
64 |
65 | - name: Test
66 | # Run the tests and print out the coverage information. In the future,
67 | # we could integrate with Codecov or something.
68 | run: pnpm run test:coverage
69 |
--------------------------------------------------------------------------------
/src/hooks/useDebouncedFunction.ts:
--------------------------------------------------------------------------------
1 | import type { DebouncedFunc, DebounceSettings } from "lodash";
2 | import debounce from "lodash/debounce";
3 | import { useEffect, useMemo, useRef } from "react";
4 |
5 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument */
6 | /**
7 | * A hook for creating a stable debounced version of the given function.
8 | *
9 | * The approach here ensures we use a `ref` for the `func`, with a stable return
10 | * value, somewhat similar to
11 | * https://www.developerway.com/posts/debouncing-in-react. It also provides
12 | * effectively the same API as the lodash function itself.
13 | *
14 | * @param func The function to debounce.
15 | * @param wait ms to wait between calls.
16 | * @param options lodash debounce options.
17 | * @returns debounced version of `func`.
18 | */
19 | export default function useDebouncedFunction any>(
20 | func: T | undefined,
21 | wait: number,
22 | options?: DebounceSettings
23 | ): DebouncedFunc {
24 | const funcRef = useRef(func);
25 |
26 | useEffect(() => {
27 | funcRef.current = func;
28 | }, [func]);
29 |
30 | const debouncedCallback = useMemo(() => {
31 | const funcWrapped = (...args: any) => funcRef.current?.(...args);
32 | /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument */
33 |
34 | return debounce(funcWrapped, wait, {
35 | // We have to refer to each of the `options` individually in order to ensure our
36 | // useMemo dependencies are correctly/explicit, satisfying the rules of hooks. We
37 | // don't want to use the `options` object in the dependency array, since it's
38 | // likely to be a new object on each render.
39 | ...(options?.leading !== undefined && { leading: options.leading }),
40 | ...(options?.maxWait !== undefined && { maxWait: options.maxWait }),
41 | ...(options?.trailing !== undefined && { trailing: options.trailing }),
42 | });
43 | }, [wait, options?.leading, options?.maxWait, options?.trailing]);
44 |
45 | // When we unmount or the user changes the debouncing wait/options, we'll cancel past
46 | // invocations
47 | useEffect(
48 | () => () => {
49 | debouncedCallback.cancel();
50 | },
51 | [debouncedCallback]
52 | );
53 |
54 | return debouncedCallback;
55 | }
56 |
--------------------------------------------------------------------------------
/src/extensions/FontSize.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { Extension } from "@tiptap/core";
3 |
4 | export type FontSizeAttrs = {
5 | fontSize?: string | null;
6 | };
7 |
8 | export type FontSizeOptions = {
9 | /**
10 | * What types of marks this applies to. By default just "textStyle".
11 | * (https://tiptap.dev/api/marks/text-style).
12 | */
13 | types: string[];
14 | };
15 |
16 | declare module "@tiptap/core" {
17 | interface Commands {
18 | fontSize: {
19 | /**
20 | * Set the text font size. ex: "12px", "2em", or "small". Must be a valid
21 | * CSS font-size
22 | * (https://developer.mozilla.org/en-US/docs/Web/CSS/font-size).
23 | */
24 | setFontSize: (fontSize: string) => ReturnType;
25 | /**
26 | * Unset the text font size.
27 | */
28 | unsetFontSize: () => ReturnType;
29 | };
30 | }
31 | }
32 |
33 | /**
34 | * Allow for setting the font size of text. Requires the TextStyle extension
35 | * https://tiptap.dev/api/marks/text-style, as Tiptap suggests.
36 | */
37 | const FontSize = Extension.create({
38 | name: "fontSize",
39 |
40 | addOptions() {
41 | return {
42 | types: ["textStyle"],
43 | };
44 | },
45 |
46 | addGlobalAttributes() {
47 | return [
48 | {
49 | types: this.options.types,
50 | attributes: {
51 | fontSize: {
52 | default: null,
53 | parseHTML: (element) =>
54 | element.style.fontSize.replace(/['"]+/g, ""),
55 | renderHTML: (attributes: FontSizeAttrs) => {
56 | if (!attributes.fontSize) {
57 | return {};
58 | }
59 |
60 | return {
61 | style: `font-size: ${attributes.fontSize}`,
62 | };
63 | },
64 | },
65 | },
66 | },
67 | ];
68 | },
69 |
70 | addCommands() {
71 | return {
72 | setFontSize:
73 | (fontSize) =>
74 | ({ chain }) => {
75 | return chain().setMark("textStyle", { fontSize }).run();
76 | },
77 | unsetFontSize:
78 | () =>
79 | ({ chain }) => {
80 | return chain()
81 | .setMark("textStyle", { fontSize: null })
82 | .removeEmptyTextStyle()
83 | .run();
84 | },
85 | };
86 | },
87 | });
88 |
89 | export default FontSize;
90 |
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/extensions/TableImproved.ts:
--------------------------------------------------------------------------------
1 | import { Table } from "@tiptap/extension-table";
2 | import { columnResizing, tableEditing } from "@tiptap/pm/tables";
3 |
4 | /**
5 | * Extend the standard Table extension, but ensures that columns maintain their
6 | * previously set widths even when `editable=false`, and irrespective of the
7 | * initial `editable` state when the `editor` is created.
8 | */
9 | const TableImproved = Table.extend({
10 | // This function is taken directly from
11 | // https://github.com/ueberdosis/tiptap/blob/31c3a9aad9eb37f445eadcd27135611291178ca6/packages/extension-table/src/table.ts#L229-L245,
12 | // except overridden to always include `columnResizing`, even if `editable` is
13 | // false. We update our RichTextContent styles so that the table resizing
14 | // controls are not visible when `editable` is false, and since the editor
15 | // itself has contenteditable=false, the table will remain read-only. By doing
16 | // this, we can ensure that column widths are preserved when editable is false
17 | // (otherwise any dragged column widths are ignored when editable is false, as
18 | // reported here https://github.com/ueberdosis/tiptap/issues/2041). Moreover,
19 | // we do not need any hacky workarounds to ensure that the necessary table
20 | // extensions are reset when the editable state changes (since the resizable
21 | // extension will be omitted if not initially editable, or wouldn't be removed
22 | // if initially not editable if we relied on it being removed, as reported
23 | // here https://github.com/ueberdosis/tiptap/issues/2301, which was not
24 | // resolved despite what the OP there later said).
25 | addProseMirrorPlugins() {
26 | const isResizable = this.options.resizable;
27 |
28 | return [
29 | ...(isResizable
30 | ? [
31 | columnResizing({
32 | handleWidth: this.options.handleWidth,
33 | cellMinWidth: this.options.cellMinWidth,
34 | // @ts-expect-error incorrect type https://github.com/ueberdosis/tiptap/blob/b0198eb14b98db5ca691bd9bfe698ffaddbc4ded/packages/extension-table/src/table.ts#L253
35 | View: this.options.View,
36 | lastColumnResizable: this.options.lastColumnResizable,
37 | }),
38 | ]
39 | : []),
40 |
41 | tableEditing({
42 | allowTableNodeSelection: this.options.allowTableNodeSelection,
43 | }),
44 | ];
45 | },
46 | });
47 |
48 | export default TableImproved;
49 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mui-tiptap-demo-example",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@emotion/react": "^11.11.0",
14 | "@emotion/styled": "^11.11.0",
15 | "@fontsource/roboto": "^4.5.8",
16 | "@mui/icons-material": "^5.11.16",
17 | "@mui/material": "^5.12.3",
18 | "@tiptap/core": "2.0.3",
19 | "@tiptap/extension-blockquote": "2.0.3",
20 | "@tiptap/extension-bold": "2.0.3",
21 | "@tiptap/extension-bubble-menu": "2.0.3",
22 | "@tiptap/extension-bullet-list": "2.0.3",
23 | "@tiptap/extension-code": "2.0.3",
24 | "@tiptap/extension-code-block": "2.0.3",
25 | "@tiptap/extension-document": "2.0.3",
26 | "@tiptap/extension-dropcursor": "2.0.3",
27 | "@tiptap/extension-floating-menu": "2.0.3",
28 | "@tiptap/extension-gapcursor": "2.0.3",
29 | "@tiptap/extension-hard-break": "2.0.3",
30 | "@tiptap/extension-heading": "2.0.3",
31 | "@tiptap/extension-history": "2.0.3",
32 | "@tiptap/extension-image": "2.0.3",
33 | "@tiptap/extension-italic": "2.0.3",
34 | "@tiptap/extension-link": "2.0.3",
35 | "@tiptap/extension-list-item": "2.0.3",
36 | "@tiptap/extension-ordered-list": "2.0.3",
37 | "@tiptap/extension-paragraph": "2.0.3",
38 | "@tiptap/extension-placeholder": "2.0.3",
39 | "@tiptap/extension-strike": "2.0.3",
40 | "@tiptap/extension-subscript": "2.0.3",
41 | "@tiptap/extension-superscript": "2.0.3",
42 | "@tiptap/extension-table": "2.0.3",
43 | "@tiptap/extension-table-cell": "2.0.3",
44 | "@tiptap/extension-table-header": "2.0.3",
45 | "@tiptap/extension-table-row": "2.0.3",
46 | "@tiptap/extension-task-item": "2.0.3",
47 | "@tiptap/extension-task-list": "2.0.3",
48 | "@tiptap/extension-text": "2.0.3",
49 | "@tiptap/pm": "^2.0.3",
50 | "@tiptap/react": "2.0.3",
51 | "@tiptap/starter-kit": "^2.0.3",
52 | "mui-tiptap": "file:../",
53 | "react": "^18.2.0",
54 | "react-dom": "^18.2.0"
55 | },
56 | "dependenciesMeta": {
57 | "mui-tiptap": {
58 | "injected": true
59 | }
60 | },
61 | "devDependencies": {
62 | "@types/react": "^18.0.28",
63 | "@types/react-dom": "^18.0.11",
64 | "@vitejs/plugin-react-swc": "^3.4.0",
65 | "typescript": "^5.1.3",
66 | "vite": "^4.5.2"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/extensions/ResizableImageResizer.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from "react";
2 | import { makeStyles } from "tss-react/mui";
3 |
4 | type ResizableImageResizerProps = {
5 | className?: string;
6 | onResize: (event: MouseEvent) => void;
7 | };
8 |
9 | const useStyles = makeStyles({ name: { ResizableImageResizer } })((theme) => ({
10 | root: {
11 | position: "absolute",
12 | // The `outline` styles of the selected image add 3px to the edges, so we'll
13 | // position this offset by 3px outside to the bottom right
14 | bottom: -3,
15 | right: -3,
16 | width: 12,
17 | height: 12,
18 | background: theme.palette.primary.main,
19 | cursor: "nwse-resize",
20 | },
21 | }));
22 |
23 | export function ResizableImageResizer({
24 | onResize,
25 | className,
26 | }: ResizableImageResizerProps) {
27 | const { classes, cx } = useStyles();
28 | const [mouseDown, setMouseDown] = useState(false);
29 |
30 | useEffect(() => {
31 | const handleMouseMove = (event: MouseEvent) => {
32 | onResize(event);
33 | };
34 |
35 | if (mouseDown) {
36 | // If the user is currently holding down the resize handle, we'll have mouse
37 | // movements fire the onResize callback (since the user would be "dragging" the
38 | // handle)
39 | window.addEventListener("mousemove", handleMouseMove);
40 | }
41 |
42 | return () => {
43 | window.removeEventListener("mousemove", handleMouseMove);
44 | };
45 | }, [mouseDown, onResize]);
46 |
47 | useEffect(() => {
48 | const handleMouseUp = () => setMouseDown(false);
49 |
50 | window.addEventListener("mouseup", handleMouseUp);
51 |
52 | return () => {
53 | window.removeEventListener("mouseup", handleMouseUp);
54 | };
55 | }, []);
56 |
57 | const handleMouseDown = useCallback((_event: React.MouseEvent) => {
58 | setMouseDown(true);
59 | }, []);
60 |
61 | return (
62 | // There isn't a great role to use here (perhaps role="separator" is the
63 | // closest, as described here https://stackoverflow.com/a/43022983/4543977,
64 | // but we don't do keyboard-based resizing at this time so it doesn't make
65 | // sense to have it keyboard focusable)
66 | // eslint-disable-next-line jsx-a11y/no-static-element-interactions
67 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/src/RichTextContent.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from "@mui/material";
2 | import { EditorContent } from "@tiptap/react";
3 | import { useMemo } from "react";
4 | import type { CSSObject } from "tss-react";
5 | import { makeStyles } from "tss-react/mui";
6 | import { useRichTextEditorContext } from "./context";
7 | import { getEditorStyles, getUtilityClasses } from "./styles";
8 |
9 | export type RichTextContentClasses = ReturnType["classes"];
10 |
11 | export type RichTextContentProps = {
12 | /** Optional additional className to provide to the root element. */
13 | className?: string;
14 | /** Override or extend existing styles. */
15 | classes?: Partial;
16 | };
17 |
18 | const richTextContentClasses: RichTextContentClasses = getUtilityClasses(
19 | "RichTextContent",
20 | ["root", "readonly", "editable"]
21 | );
22 |
23 | const useStyles = makeStyles({ name: { RichTextContent } })((theme) => {
24 | return {
25 | root: {
26 | // We add `as CSSObject` to get around typing issues with our editor
27 | // styles function. For future reference, this old issue and its solution
28 | // are related, though not quite right
29 | // https://github.com/garronej/tss-react/issues/2
30 | // https://github.com/garronej/tss-react/commit/9dc3f6f9f70b6df0bd83cd5689c3313467fb4f06
31 | "& .ProseMirror": {
32 | ...getEditorStyles(theme),
33 | } as CSSObject,
34 | },
35 |
36 | // Styles applied when the editor is in read-only mode (editable=false)
37 | readonly: {},
38 |
39 | // Styles applied when the editor is editable (editable=true)
40 | editable: {},
41 | };
42 | });
43 |
44 | /**
45 | * A component for rendering a MUI-styled version of Tiptap rich text editor
46 | * content.
47 | *
48 | * Must be a child of the RichTextEditorProvider so that the `editor` context is
49 | * available.
50 | */
51 | export default function RichTextContent({
52 | className,
53 | classes: overrideClasses = {},
54 | }: RichTextContentProps) {
55 | const { classes, cx } = useStyles(undefined, {
56 | props: { classes: overrideClasses },
57 | });
58 | const editor = useRichTextEditorContext();
59 | const editorClasses = useMemo(
60 | () =>
61 | cx(
62 | richTextContentClasses.root,
63 | className,
64 | classes.root,
65 | editor?.isEditable
66 | ? [richTextContentClasses.editable, classes.editable]
67 | : [richTextContentClasses.readonly, classes.readonly]
68 | ),
69 | [className, classes, cx, editor?.isEditable]
70 | );
71 |
72 | return (
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build/
2 | .DS_Store
3 |
4 | # Logs
5 | logs
6 | *.log
7 | npm-debug.log*
8 | yarn-debug.log*
9 | yarn-error.log*
10 | lerna-debug.log*
11 | .pnpm-debug.log*
12 |
13 | # Diagnostic reports (https://nodejs.org/api/report.html)
14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
15 |
16 | # Runtime data
17 | pids
18 | *.pid
19 | *.seed
20 | *.pid.lock
21 |
22 | # Directory for instrumented libs generated by jscoverage/JSCover
23 | lib-cov
24 |
25 | # Coverage directory used by tools like istanbul
26 | coverage
27 | *.lcov
28 |
29 | # nyc test coverage
30 | .nyc_output
31 |
32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
33 | .grunt
34 |
35 | # Bower dependency directory (https://bower.io/)
36 | bower_components
37 |
38 | # node-waf configuration
39 | .lock-wscript
40 |
41 | # Compiled binary addons (https://nodejs.org/api/addons.html)
42 | build/Release
43 |
44 | # Dependency directories
45 | node_modules/
46 | jspm_packages/
47 |
48 | # Snowpack dependency directory (https://snowpack.dev/)
49 | web_modules/
50 |
51 | # TypeScript cache
52 | *.tsbuildinfo
53 |
54 | # Optional npm cache directory
55 | .npm
56 |
57 | # Optional eslint cache
58 | .eslintcache
59 |
60 | # Optional stylelint cache
61 | .stylelintcache
62 |
63 | # Microbundle cache
64 | .rpt2_cache/
65 | .rts2_cache_cjs/
66 | .rts2_cache_es/
67 | .rts2_cache_umd/
68 |
69 | # Optional REPL history
70 | .node_repl_history
71 |
72 | # Output of 'npm pack'
73 | *.tgz
74 |
75 | # Yarn Integrity file
76 | .yarn-integrity
77 |
78 | # dotenv environment variable files
79 | .env
80 | .env.development.local
81 | .env.test.local
82 | .env.production.local
83 | .env.local
84 |
85 | # parcel-bundler cache (https://parceljs.org/)
86 | .cache
87 | .parcel-cache
88 |
89 | # Next.js build output
90 | .next
91 | out
92 |
93 | # Nuxt.js build / generate output
94 | .nuxt
95 | dist
96 |
97 | # Gatsby files
98 | .cache/
99 | # Comment in the public line in if your project uses Gatsby and not Next.js
100 | # https://nextjs.org/blog/next-9-1#public-directory-support
101 | # public
102 |
103 | # vuepress build output
104 | .vuepress/dist
105 |
106 | # vuepress v2.x temp and cache directory
107 | .temp
108 | .cache
109 |
110 | # Docusaurus cache and generated files
111 | .docusaurus
112 |
113 | # Serverless directories
114 | .serverless/
115 |
116 | # FuseBox cache
117 | .fusebox/
118 |
119 | # DynamoDB Local files
120 | .dynamodb/
121 |
122 | # TernJS port file
123 | .tern-port
124 |
125 | # Stores VSCode versions used for testing VSCode extensions
126 | .vscode-test
127 |
128 | # yarn v2
129 | .yarn/cache
130 | .yarn/unplugged
131 | .yarn/build-state.yml
132 | .yarn/install-state.gz
133 | .pnp.*
134 |
135 | # VSCode workspace file
136 | *.code-workspace
137 |
--------------------------------------------------------------------------------
/src/RichTextReadOnly.tsx:
--------------------------------------------------------------------------------
1 | import { useEditor, type EditorOptions } from "@tiptap/react";
2 | import { useEffect, useRef } from "react";
3 | import type { Except, SetRequired } from "type-fest";
4 | import RichTextContent from "./RichTextContent";
5 | import RichTextEditorProvider from "./RichTextEditorProvider";
6 |
7 | export type RichTextReadOnlyProps = SetRequired<
8 | Partial>,
9 | "extensions"
10 | >;
11 |
12 | function RichTextReadOnlyInternal(props: RichTextReadOnlyProps) {
13 | const editor = useEditor({
14 | ...props,
15 | editable: false,
16 | });
17 |
18 | // Update content if/when it changes
19 | const previousContent = useRef(props.content);
20 | useEffect(() => {
21 | if (
22 | !editor ||
23 | editor.isDestroyed ||
24 | props.content === undefined ||
25 | props.content === previousContent.current
26 | ) {
27 | return;
28 | }
29 | // We use queueMicrotask to avoid any flushSync console errors as
30 | // mentioned here
31 | // https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730
32 | queueMicrotask(() => {
33 | // Validate that props.content isn't undefined again to appease TS
34 | if (props.content !== undefined) {
35 | editor.commands.setContent(props.content);
36 | }
37 | });
38 | }, [props.content, editor]);
39 |
40 | useEffect(() => {
41 | previousContent.current = props.content;
42 | }, [props.content]);
43 |
44 | return (
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | /**
52 | * An all-in-one component to directly render read-only Tiptap editor content.
53 | *
54 | * When to use this component:
55 | * - You just want to render editor HTML/JSON content directly, without any
56 | * outlined field styling, menu bar, extra setup, etc.
57 | * - You want a convenient way to render content that re-renders as the
58 | * `content` prop changes.
59 | *
60 | * Though RichtextEditor (or useEditor, RichTextEditorProvider, and
61 | * RichTextContent) can be used as read-only via the editor's `editable` prop,
62 | * this is a simpler and more efficient version that only renders content and
63 | * nothing more (e.g., skips instantiating the editor at all if there's no
64 | * content to display, and does not contain additional rendering logic related
65 | * to controls, outlined field UI state, etc.).
66 | *
67 | * Example:
68 | *
69 | */
70 | export default function RichTextReadOnly(props: RichTextReadOnlyProps) {
71 | if (!props.content) {
72 | // Don't bother instantiating an editor at all (for performance) if we have
73 | // no content
74 | return null;
75 | }
76 |
77 | return ;
78 | }
79 |
--------------------------------------------------------------------------------
/src/LinkBubbleMenu/ViewLinkMenuContent.tsx:
--------------------------------------------------------------------------------
1 | import { Button, DialogActions, Link } from "@mui/material";
2 | import { getMarkRange, getMarkType, type Editor } from "@tiptap/core";
3 | import truncate from "lodash/truncate";
4 | import type { ReactNode } from "react";
5 | import { makeStyles } from "tss-react/mui";
6 | import useKeyDown from "../hooks/useKeyDown";
7 | import truncateMiddle from "../utils/truncateMiddle";
8 |
9 | export type ViewLinkMenuContentProps = {
10 | editor: Editor;
11 | onCancel: () => void;
12 | onEdit: () => void;
13 | onRemove: () => void;
14 | /** Override default text content/labels used within the component. */
15 | labels?: {
16 | /** Content shown in the button used to start editing the link. */
17 | viewLinkEditButtonLabel?: ReactNode;
18 | /** Content shown in the button used to remove the link. */
19 | viewLinkRemoveButtonLabel?: ReactNode;
20 | };
21 | };
22 |
23 | const useStyles = makeStyles({ name: { ViewLinkMenuContent } })({
24 | linkPreviewText: {
25 | overflowWrap: "anywhere",
26 | },
27 | });
28 |
29 | /** Shown when a user is viewing the details of an existing Link for Tiptap. */
30 | export default function ViewLinkMenuContent({
31 | editor,
32 | onCancel,
33 | onEdit,
34 | onRemove,
35 | labels,
36 | }: ViewLinkMenuContentProps) {
37 | const { classes } = useStyles();
38 | const linkRange = getMarkRange(
39 | editor.state.selection.$to,
40 | getMarkType("link", editor.schema)
41 | );
42 | const linkText = linkRange
43 | ? editor.state.doc.textBetween(linkRange.from, linkRange.to)
44 | : "";
45 |
46 | const currentHref =
47 | (editor.getAttributes("link").href as string | undefined) ?? "";
48 |
49 | // If the user presses escape, we should cancel
50 | useKeyDown("Escape", onCancel);
51 |
52 | return (
53 | <>
54 |
55 | {truncate(linkText, {
56 | length: 50,
57 | omission: "…",
58 | })}
59 |
60 |
61 |
62 |
63 | {/* We truncate in the middle, since the beginning and end of a URL are often the most
64 | important parts */}
65 | {truncateMiddle(currentHref, 50)}
66 |
67 |
68 |
69 |
70 |
76 | {labels?.viewLinkEditButtonLabel ?? "Edit"}
77 |
78 |
84 | {labels?.viewLinkRemoveButtonLabel ?? "Remove"}
85 |
86 |
87 | >
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonHighlightColor.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import { useRichTextEditorContext } from "../context";
3 | import { FormatInkHighlighterNoBar } from "../icons";
4 | import {
5 | MenuButtonColorPicker,
6 | type MenuButtonColorPickerProps,
7 | } from "./MenuButtonColorPicker";
8 |
9 | export interface MenuButtonHighlightColorProps
10 | extends Partial {
11 | /**
12 | * Shows this as the current highlight color (in the color picker) if a
13 | * highlight is active for the selected editor text but no specific color was
14 | * specified for it.
15 | *
16 | * The Tiptap Highlight extension uses HTML `` elements, so this default
17 | * color should be chosen based on any styling applied to mark
18 | * background-color on your page.
19 | *
20 | * This prop is set to "#ffff00" (yellow) by default, as this is what most
21 | * browsers will show, per the W3 spec defaults
22 | * https://stackoverflow.com/a/34969133/4543977.
23 | */
24 | defaultMarkColor?: string;
25 | }
26 |
27 | /**
28 | * Control for a user to choose a text highlight color, for the
29 | * @tiptap/extension-highlight when it's configured with
30 | * `Highlight.configure({ multicolor: true })`.
31 | *
32 | * See also MenuButtonHighlightToggle for a simple "on off" highlight toggle
33 | * control, for use with the Highlight extension when not using multicolor.
34 | */
35 | export default function MenuButtonHighlightColor({
36 | defaultMarkColor = "#ffff00",
37 | ...menuButtonProps
38 | }: MenuButtonHighlightColorProps) {
39 | const editor = useRichTextEditorContext();
40 | const currentHighlightColor = editor?.isActive("highlight")
41 | ? // If there's no color set for the highlight (as can happen with the
42 | // highlight keyboard shortcut, toggleHighlight/setHighlight when no
43 | // explicit color is provided, and the "==thing==" syntax), fall back to
44 | // the provided defaultMarkColor
45 | // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
46 | (editor.getAttributes("highlight").color as string | null | undefined) ||
47 | defaultMarkColor
48 | : "";
49 | return (
50 | {
56 | if (newColor) {
57 | editor?.chain().focus().setHighlight({ color: newColor }).run();
58 | } else {
59 | editor?.chain().focus().unsetHighlight().run();
60 | }
61 | }}
62 | disabled={!editor?.isEditable || !editor.can().toggleHighlight()}
63 | {...menuButtonProps}
64 | labels={{
65 | removeColorButton: "None",
66 | removeColorButtonTooltipTitle: "Remove highlighting from this text",
67 | ...menuButtonProps.labels,
68 | }}
69 | />
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/src/MenuBar.tsx:
--------------------------------------------------------------------------------
1 | import { Collapse } from "@mui/material";
2 | import { makeStyles } from "tss-react/mui";
3 | import { Z_INDEXES, getUtilityClasses } from "./styles";
4 |
5 | export type MenuBarClasses = ReturnType["classes"];
6 |
7 | export type MenuBarProps = {
8 | /**
9 | * Whether to hide the menu bar. When changing between false/true, uses the
10 | * collapse animation. By default false
11 | */
12 | hide?: boolean;
13 | /**
14 | * If true, the menu bar will not "stick" above the editor content on the
15 | * page as you scroll down past where it normally sits.
16 | */
17 | disableSticky?: boolean;
18 | /**
19 | * The menu bar's sticky `top` offset, when `disableSticky=false`.
20 | *
21 | * Useful if there's other fixed/sticky content above the editor (like an app
22 | * navigation toolbar). By default 0.
23 | */
24 | stickyOffset?: number;
25 | /** The set of controls (buttons, etc) to include in the menu bar. */
26 | children?: React.ReactNode;
27 | /** Class applied to the outermost `root` element. */
28 | className?: string;
29 | /** Override or extend existing styles. */
30 | classes?: Partial;
31 | };
32 |
33 | const menuBarClasses: MenuBarClasses = getUtilityClasses("MenuBar", [
34 | "root",
35 | "sticky",
36 | "nonSticky",
37 | "content",
38 | ]);
39 |
40 | const useStyles = makeStyles<{ stickyOffset?: number }>({
41 | name: { MenuBar },
42 | })((theme, { stickyOffset }) => {
43 | return {
44 | root: {
45 | borderBottomColor: theme.palette.divider,
46 | borderBottomStyle: "solid",
47 | borderBottomWidth: 1,
48 | },
49 |
50 | sticky: {
51 | position: "sticky",
52 | top: stickyOffset ?? 0,
53 | zIndex: Z_INDEXES.MENU_BAR,
54 | background: theme.palette.background.default,
55 | },
56 |
57 | nonSticky: {},
58 |
59 | content: {},
60 | };
61 | });
62 |
63 | /**
64 | * A collapsible, optionally-sticky container for showing editor controls atop
65 | * the editor content.
66 | */
67 | export default function MenuBar({
68 | hide,
69 | disableSticky,
70 | stickyOffset,
71 | children,
72 | className,
73 | classes: overrideClasses,
74 | }: MenuBarProps) {
75 | const { classes, cx } = useStyles(
76 | { stickyOffset },
77 | {
78 | props: { classes: overrideClasses },
79 | }
80 | );
81 | return (
82 |
99 | {children}
100 |
101 | );
102 | }
103 |
--------------------------------------------------------------------------------
/src/utils/DebounceRender.tsx:
--------------------------------------------------------------------------------
1 | import type { DebounceSettings, DebouncedFunc } from "lodash";
2 | import debounce from "lodash/debounce";
3 | import { Component } from "react";
4 |
5 | export type DebounceRenderProps = {
6 | /**
7 | * The wait in ms for debouncing. Any changes to this prop after initial
8 | * render are ignored.
9 | */
10 | wait?: number;
11 | /**
12 | * Options to use for lodash's debounce call. Any changes to this prop after
13 | * initial render are ignored.
14 | */
15 | options?: DebounceSettings;
16 | /** Content to render at debounced intervals as props change */
17 | children: React.ReactNode;
18 | };
19 |
20 | /**
21 | * This component debounces the rendering of its children.
22 | *
23 | * WARNING: Use with caution! This component should *only* be used when there
24 | * are updates triggered via "force-update" (like via Tiptap's `useEditor` hook
25 | * which updates upon ProseMirror editor changes to the selection, content,
26 | * etc.). For ordinary React components, traditional memoization techniques
27 | * around props and state (like useCallback, useMemo, memo, etc.) should be used
28 | * instead.
29 | *
30 | * This component is provided for a very narrow use-case: with our menu
31 | * controls, without debouncing the controls would re-render per editor state
32 | * change (e.g. for every character typed or for caret movement), which can bog
33 | * things down a bit, like when holding down backspace or typing very quickly.
34 | * (This is due to the way that Tiptap's useEditor re-renders upon changes in
35 | * the ProseMirror state, which is to force-update
36 | * https://github.com/ueberdosis/tiptap/blob/b0198eb14b98db5ca691bd9bfe698ffaddbc4ded/packages/react/src/useEditor.ts#L105-L113,
37 | * rather than in response to prop changes. Because of the force re-render, and
38 | * since we *do* want to watch editor updates, we have to debounce rendering a
39 | * bit less conventionally, rather than using callbacks, memo, etc.). We do
40 | * want/need the menu controls to update very frequently, since we need them to
41 | * reflect the state of the current cursor position and editor nodes/marks,
42 | * etc., but we want rendering to stay performant, so the `wait` and `options`
43 | * defaults below are a reasonable enough balance.
44 | */
45 | export default class DebounceRender extends Component {
46 | // Similar to the approach from
47 | // https://github.com/podefr/react-debounce-render, except as a component
48 | // instead of an HOC.
49 | public updateDebounced: DebouncedFunc<() => void>;
50 |
51 | constructor(props: DebounceRenderProps) {
52 | super(props);
53 | this.updateDebounced = debounce(
54 | // eslint-disable-next-line @typescript-eslint/unbound-method
55 | this.forceUpdate,
56 | props.wait ?? 170,
57 | props.options ?? {
58 | leading: true,
59 | trailing: true,
60 | maxWait: 300,
61 | }
62 | );
63 | }
64 |
65 | shouldComponentUpdate() {
66 | this.updateDebounced();
67 | return false;
68 | }
69 |
70 | componentWillUnmount() {
71 | this.updateDebounced.cancel();
72 | }
73 |
74 | render() {
75 | return this.props.children;
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/controls/MenuButton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ToggleButton,
3 | toggleButtonClasses,
4 | type ToggleButtonProps,
5 | } from "@mui/material";
6 | import type { ReactNode, RefObject } from "react";
7 | import { makeStyles } from "tss-react/mui";
8 | import type { Except, SetOptional } from "type-fest";
9 | import MenuButtonTooltip, {
10 | type MenuButtonTooltipProps,
11 | } from "./MenuButtonTooltip";
12 |
13 | export interface MenuButtonProps
14 | extends SetOptional, "value"> {
15 | /**
16 | * The label that will be displayed in a tooltip when hovering. Also used as
17 | * the underlying ToggleButton `value` if a separate `value` prop is not
18 | * included.
19 | */
20 | tooltipLabel: MenuButtonTooltipProps["label"];
21 | /**
22 | * (Optional) An array of the keyboard shortcut keys that trigger this action
23 | * will be displayed in a tooltip when hovering. If empty, no keyboard
24 | * shortcut is displayed.
25 | *
26 | * Use the literal string "mod" to represent Cmd on Mac and Ctrl on Windows
27 | * and Linux.
28 | *
29 | * Example: ["mod", "Shift", "7"] is the array that should be provided as the
30 | * combination for toggling an ordered list.
31 | *
32 | * For the list of pre-configured Tiptap shortcuts, see
33 | * https://tiptap.dev/api/keyboard-shortcuts.
34 | */
35 | tooltipShortcutKeys?: MenuButtonTooltipProps["shortcutKeys"];
36 | /**
37 | * The icon component to use for the button, rendered as button `children` if
38 | * provided. Must accept a className.
39 | */
40 | IconComponent?: React.ElementType<{ className: string }>;
41 | /**
42 | * Override the default button content instead of displaying the
43 | * .
44 | */
45 | children?: ReactNode;
46 | /** Attaches a `ref` to the ToggleButton's root button element. */
47 | buttonRef?: RefObject;
48 | }
49 |
50 | export const MENU_BUTTON_FONT_SIZE_DEFAULT = "1.25rem";
51 |
52 | const useStyles = makeStyles({ name: { MenuButton } })({
53 | root: {
54 | // Use && for additional specificity, since MUI's conditional "disabled"
55 | // styles also set the border
56 | [`&& .${toggleButtonClasses.root}`]: {
57 | border: "none",
58 | padding: 5,
59 | },
60 | },
61 |
62 | menuButtonIcon: {
63 | fontSize: MENU_BUTTON_FONT_SIZE_DEFAULT,
64 | },
65 | });
66 |
67 | /**
68 | * A general-purpose base component for showing an editor control for use in a
69 | * menu.
70 | */
71 | export default function MenuButton({
72 | tooltipLabel,
73 | tooltipShortcutKeys,
74 | IconComponent,
75 | buttonRef,
76 | children,
77 | ...toggleButtonProps
78 | }: MenuButtonProps) {
79 | const { classes } = useStyles();
80 | return (
81 |
82 |
86 |
92 | {children ??
93 | (IconComponent && (
94 |
95 | ))}
96 |
97 |
98 |
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonImageUpload.tsx:
--------------------------------------------------------------------------------
1 | import type { Editor } from "@tiptap/core";
2 | import { useRef, type ComponentPropsWithoutRef } from "react";
3 | import type { SetOptional } from "type-fest";
4 | import { useRichTextEditorContext } from "../context";
5 | import { insertImages, type ImageNodeAttributes } from "../utils";
6 | import MenuButtonAddImage, {
7 | type MenuButtonAddImageProps,
8 | } from "./MenuButtonAddImage";
9 |
10 | export interface MenuButtonImageUploadProps
11 | extends SetOptional {
12 | /**
13 | * Take an array of user-selected files to upload, and return an array of
14 | * image node attributes. Typically will be an async function (i.e. will
15 | * return a promise) used to upload the files to a server and return URLs at
16 | * which the image files can be viewed subsequently.
17 | */
18 | onUploadFiles: (
19 | files: File[]
20 | ) => ImageNodeAttributes[] | Promise;
21 | /**
22 | * Handler called with the result from `onUploadFiles`, taking the uploaded
23 | * files and inserting them into the Tiptap content. If not provided, by
24 | * default uses mui-tiptap's `insertImages` utility, which inserts the images
25 | * at the user's current caret position (replacing selected content if there
26 | * is selected content).
27 | */
28 | insertImages?: ({
29 | images,
30 | editor,
31 | }: {
32 | images: ImageNodeAttributes[];
33 | editor: Editor | null;
34 | }) => void;
35 | /**
36 | * Override props used for the hidden file input. For instance, to restrict to
37 | * single file uploads with `multiple={false}`. Use `accept` to customize
38 | * which file types are accepted (by default "image/*" to restrict to standard
39 | * image formats; see
40 | * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept).
41 | */
42 | inputProps?: Partial>;
43 | }
44 |
45 | /**
46 | * Render a button for uploading one or more images to insert into the editor
47 | * content. You must provide the `onUploadFiles` prop in order to specify how to
48 | * handle the user-selected files, like uploading them to a server which returns
49 | * a servable image URL, or converting the image files into a local object URL
50 | * or base64-encoded image URL.
51 | */
52 | export default function MenuButtonImageUpload({
53 | onUploadFiles,
54 | inputProps,
55 | ...props
56 | }: MenuButtonImageUploadProps) {
57 | const editor = useRichTextEditorContext();
58 |
59 | const fileInput = useRef(null);
60 |
61 | const handleAndInsertNewFiles = async (files: FileList) => {
62 | if (!editor || editor.isDestroyed || files.length === 0) {
63 | return;
64 | }
65 | const attributesForImages = await onUploadFiles(Array.from(files));
66 | insertImages({
67 | editor,
68 | images: attributesForImages,
69 | });
70 | };
71 |
72 | return (
73 | <>
74 | fileInput.current?.click()}
77 | {...props}
78 | />
79 | {
85 | if (event.target.files) {
86 | await handleAndInsertNewFiles(event.target.files);
87 | }
88 | }}
89 | style={{ display: "none" }} // Hide this input
90 | {...inputProps}
91 | />
92 | >
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Guidelines
2 |
3 | _Pull requests, bug reports, and all other forms of contribution are welcomed and highly encouraged!_ :octocat:
4 |
5 | ## How can I contribute?
6 |
7 | ### GitHub issues
8 |
9 | If you encounter a problem with this library or if you have a new feature you'd like to see in this project, please create [a new issue](https://github.com/sjdemartini/mui-tiptap/issues/new/choose).
10 |
11 | ### GitHub pull requests
12 |
13 | Please leverage the repository's own tools to make sure the code is aligned with our standards. See the [Development setup](#development-setup) notes below. If you're using VSCode, it's easiest to use the recommended extensions (`.vscode/extensions.json`) to get integrated linting and autoformatting.
14 |
15 | It's recommended to run all check commands before submitting the PR (`type:check`, `format:check`, `lint:check`, `spell:check`, `test:coverage`).
16 |
17 | ## Development setup
18 |
19 | 1. Set up [pnpm](https://pnpm.io/installation)
20 | 2. Run `pnpm install`
21 | 3. Run `pnpm dev` and view the demo site at the printed localhost URL
22 |
23 | This package uses Vite with Hot Module Replacement (HMR), so file edits should reflect immediately in your browser during local development.
24 |
25 | To instead test a "built" version of this package which is installed into an "external" module, you can run `pnpm example`, which runs a server with the separate application in the `example/` directory.
26 |
27 | ### Adding a new icon
28 |
29 | When the `@mui/icons-material` icon set is insufficient, it can be helpful to browse a larger set of free open-source icons and add what’s needed directly to `mui-tiptap`. This also avoids any additional third-party JS dependencies.
30 |
31 | 1. Download an icon (e.g. from https://iconbuddy.app, which aggregates and organizes thousands of free icons from many separate icon libraries and sources)
32 | 2. Create a new tsx file in `src/icons/`
33 | 3. If icon edits or customizations are needed, https://yqnn.github.io/svg-path-editor/ and https://boxy-svg.com/app are handy free web-based tools. Typically you will want to work with and export in a "0 0 24 24" viewBox, since that is what MUI icons use by default.
34 | 4. Copy the `` from the downloaded SVG, and in the new TSX file, pass that as the argument to the `createSvgIcon` helper from `@mui/material`. (If there are multiple ``s, put them within a React Fragment.)
35 | 5. Export the icon component in that new file and in `src/icons/index.ts`.
36 |
37 | ## Releasing a new version (for maintainers)
38 |
39 | When a new version should be cut since some new changes have landed on the `main` branch, do the following to publish it:
40 |
41 | 1. Go to the `main` branch and pull in the latest changes.
42 | 2. Run `npm version `, depending on what's appropriate per semver conventions for the latest changes.
43 | - This will create a commit that updates the `version` in `package.json`, and add a git tag for that new commit.
44 | 3. Push the commit and tags (ex: `git push origin main` and `git push --tags`)
45 | 4. The `release.yml` GitHub Actions workflow will run and should publish to npm upon completion.
46 | 5. Once the new version has been successfully published to npm, create a "Release" in GitHub for to the newly pushed tag, per the steps [here](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository#creating-a-release), which can auto-generate release notes that can be edited and cleaned up for clarity and succinctness.
47 |
--------------------------------------------------------------------------------
/src/controls/ColorSwatchButton.tsx:
--------------------------------------------------------------------------------
1 | import Check from "@mui/icons-material/Check";
2 | import {
3 | forwardRef,
4 | type ComponentPropsWithoutRef,
5 | type ElementRef,
6 | } from "react";
7 | import { makeStyles } from "tss-react/mui";
8 | import type { Except } from "type-fest";
9 |
10 | export interface ColorSwatchButtonProps
11 | // Omit the default "color" prop so that it can't be confused for the `value`
12 | // prop
13 | extends Except, "color"> {
14 | /**
15 | * What color is shown with this swatch. If not provided, shows a checkerboard
16 | * pattern, typically used as "not set" or "transparent".
17 | */
18 | value?: string;
19 | /**
20 | * An optional human-friendly name for this color, used as an aria-label for
21 | * the button.
22 | */
23 | label?: string;
24 | /**
25 | * Whether this swatch color is the currently active color. If true, shows an
26 | * overlaid checkmark as a visual indicator.
27 | */
28 | active?: boolean;
29 | /** If given, sets the padding between the color and the border of the swatch. */
30 | padding?: string | number;
31 | }
32 |
33 | /**
34 | * Renders a button in the given color `value`, useful for showing and allowing
35 | * selecting a color preset.
36 | */
37 | export const ColorSwatchButton = forwardRef<
38 | ElementRef<"button">,
39 | ColorSwatchButtonProps
40 | >(({ value: colorValue, label, padding, active, ...buttonProps }, ref) => {
41 | const { classes, cx, theme } = useStyles();
42 | return (
43 |
56 | {active && (
57 |
66 | )}
67 |
68 | );
69 | });
70 |
71 | const useStyles = makeStyles({ name: { ColorSwatchButton } })((theme) => ({
72 | root: {
73 | height: theme.spacing(2.5),
74 | width: theme.spacing(2.5),
75 | minWidth: theme.spacing(2.5),
76 | borderRadius: theme.shape.borderRadius,
77 | borderColor:
78 | theme.palette.mode === "dark"
79 | ? theme.palette.grey[700]
80 | : theme.palette.grey[400],
81 | borderStyle: "solid",
82 | borderWidth: 1,
83 | cursor: "pointer",
84 | // Use background-clip with content-box so that if a `padding` is specified by the
85 | // user, it adds a gap between the color and the border.
86 | padding: 0,
87 | backgroundClip: "content-box",
88 | },
89 |
90 | activeIcon: {
91 | height: "100%",
92 | width: "80%",
93 | verticalAlign: "middle",
94 | },
95 |
96 | colorNotSet: {
97 | // To indicate that a color hasn't been chosen, we'll use a checkerboard pattern
98 | // (https://stackoverflow.com/a/65129916/4543977)
99 | background: `repeating-conic-gradient(
100 | ${theme.palette.grey[400]} 0% 25%, ${theme.palette.common.white} 0% 50%)
101 | 50% / 12px 12px`,
102 | backgroundClip: "content-box",
103 | },
104 | }));
105 |
106 | ColorSwatchButton.displayName = "ColorSwatchButton";
107 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonTextColor.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import type { Editor } from "@tiptap/core";
3 | import { useRichTextEditorContext } from "../context";
4 | import { FormatColorTextNoBar } from "../icons";
5 | import { getAttributesForEachSelected } from "../utils";
6 | import {
7 | MenuButtonColorPicker,
8 | type MenuButtonColorPickerProps,
9 | } from "./MenuButtonColorPicker";
10 |
11 | export interface MenuButtonTextColorProps
12 | extends Partial {
13 | /**
14 | * Used to indicate the default color of the text in the Tiptap editor, if no
15 | * color has been set with the color extension (or if color has been *unset*
16 | * with the extension). Typically should be set to the same value as the MUI
17 | * `theme.palette.text.primary`.
18 | */
19 | defaultTextColor?: string;
20 | }
21 |
22 | // Tiptap will return any textStyle attributes when calling
23 | // `getAttributes("textStyle")`, but here we care about `color`, so add more
24 | // explicit typing for that. Based on
25 | // https://github.com/ueberdosis/tiptap/blob/6cbc2d423391c950558721510c1b4c8614feb534/packages/extension-color/src/color.ts#L37-L51
26 | interface TextStyleAttrs extends ReturnType {
27 | color?: string | null;
28 | }
29 |
30 | export default function MenuButtonTextColor({
31 | IconComponent = FormatColorTextNoBar,
32 | tooltipLabel = "Text color",
33 | defaultTextColor = "",
34 | ...menuButtonProps
35 | }: MenuButtonTextColorProps) {
36 | const editor = useRichTextEditorContext();
37 |
38 | // Determine if all of the selected content shares the same set color.
39 | const allCurrentTextStyleAttrs: TextStyleAttrs[] = editor
40 | ? getAttributesForEachSelected(editor.state, "textStyle")
41 | : [];
42 | const isTextStyleAppliedToEntireSelection = !!editor?.isActive("textStyle");
43 | const currentColors: string[] = allCurrentTextStyleAttrs.map(
44 | // Treat any null/missing color as the default color
45 | // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
46 | (attrs) => attrs.color || defaultTextColor
47 | );
48 | if (!isTextStyleAppliedToEntireSelection) {
49 | // If there is some selected content that does not have textStyle, we can
50 | // treat it the same as a selected textStyle mark with color set to the
51 | // default
52 | currentColors.push(defaultTextColor);
53 | }
54 | const numUniqueCurrentColors = new Set(currentColors).size;
55 |
56 | let currentColor;
57 | if (numUniqueCurrentColors === 1) {
58 | // There's exactly one color in the selected content, so show that
59 | currentColor = currentColors[0];
60 | } else if (numUniqueCurrentColors > 1) {
61 | // There are multiple colors (either explicitly, or because some of the
62 | // selection has a color set and some does not and is using the default
63 | // color). Similar to other rich text editors like Google Docs, we'll treat
64 | // this as "unset" and not show a color indicator in the button or a
65 | // "current" color when interacting with the color picker.
66 | currentColor = "";
67 | } else {
68 | // Since no color was set anywhere in the selected content, we should show
69 | // the default color
70 | currentColor = defaultTextColor;
71 | }
72 |
73 | return (
74 | {
79 | editor?.chain().focus().setColor(newColor).run();
80 | }}
81 | disabled={!editor?.isEditable || !editor.can().setColor("#000")}
82 | {...menuButtonProps}
83 | labels={{ removeColorButton: "Reset", ...menuButtonProps.labels }}
84 | />
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonTooltip.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip, Typography, alpha, type TooltipProps } from "@mui/material";
2 | import { makeStyles } from "tss-react/mui";
3 | import { getModShortcutKey } from "../utils/platform";
4 |
5 | export type MenuButtonTooltipProps = {
6 | /**
7 | * Used to display what this button is responsible for. Ex: "Ordered list".
8 | */
9 | label: string;
10 | /**
11 | * An array representing the set of keys that should be pressed to trigger
12 | * this action (for its keyboard shortcut), so that this can be displayed to
13 | * the user. If empty, no keyboard shortcut is displayed.
14 | *
15 | * Use the literal string "mod" to represent Cmd on Mac and Ctrl on Windows
16 | * and Linux.
17 | *
18 | * Example: ["mod", "Shift", "7"] is the array that should be provided as the
19 | * combination for toggling an ordered list.
20 | *
21 | * For the list of pre-configured Tiptap shortcuts, see
22 | * https://tiptap.dev/api/keyboard-shortcuts.
23 | */
24 | shortcutKeys?: string[];
25 | /** Where the tooltip should be placed. By default "top" (above). */
26 | placement?: TooltipProps["placement"];
27 | /**
28 | * Class applied to the element that contains the children content. We add an
29 | * intermediary element since Tooltip requires a non-disabled child element in
30 | * order to render, and we want to allow tooltips to show up even when buttons
31 | * are disabled.
32 | */
33 | contentWrapperClassName?: string;
34 | /** The menu element for which we're showing a tooltip when hovering. */
35 | children: React.ReactNode;
36 | } & Pick;
37 |
38 | const useStyles = makeStyles({ name: { MenuButtonTooltip } })((theme) => ({
39 | titleContainer: {
40 | textAlign: "center",
41 | },
42 |
43 | label: {
44 | fontSize: theme.typography.pxToRem(13),
45 | },
46 |
47 | shortcutKey: {
48 | fontSize: theme.typography.pxToRem(12),
49 | border: `1px solid ${alpha(theme.palette.text.secondary, 0.2)}`,
50 | backgroundColor: alpha(theme.palette.background.paper, 0.3),
51 | height: "19px",
52 | lineHeight: "19px",
53 | padding: "0 4px",
54 | minWidth: 17,
55 | borderRadius: theme.shape.borderRadius,
56 | display: "inline-block",
57 |
58 | "&:not(:first-of-type)": {
59 | marginLeft: 1,
60 | },
61 | },
62 | }));
63 |
64 | export default function MenuButtonTooltip({
65 | label,
66 | shortcutKeys,
67 | placement = "top",
68 | contentWrapperClassName,
69 | children,
70 | ...otherTooltipProps
71 | }: MenuButtonTooltipProps) {
72 | const { classes } = useStyles();
73 | return (
74 | 0) ? (
77 |
78 |
{label}
79 |
80 | {shortcutKeys && shortcutKeys.length > 0 && (
81 |
82 | {shortcutKeys.map((shortcutKey, index) => (
83 |
84 | {shortcutKey === "mod" ? getModShortcutKey() : shortcutKey}
85 |
86 | ))}
87 |
88 | )}
89 |
90 | ) : (
91 | ""
92 | )
93 | }
94 | placement={placement}
95 | arrow
96 | {...otherTooltipProps}
97 | >
98 | {/* Use a span around the children so we show a tooltip even if the
99 | element inside is disabled */}
100 | {children}
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "env": {
4 | "es2021": true,
5 | "node": true
6 | },
7 | "parser": "@typescript-eslint/parser",
8 | "parserOptions": {
9 | "ecmaFeatures": {
10 | "jsx": true
11 | },
12 | "project": "./tsconfig.json"
13 | },
14 | "plugins": [
15 | "react",
16 | "import",
17 | "@typescript-eslint",
18 | "react-refresh",
19 | "tss-unused-classes"
20 | ],
21 | "extends": [
22 | "eslint:recommended",
23 | "plugin:eslint-comments/recommended",
24 | "plugin:react/recommended",
25 | "plugin:react/jsx-runtime", // Vite enables the automatic JSX runtime
26 | "plugin:react-hooks/recommended",
27 | "plugin:@typescript-eslint/eslint-recommended",
28 | "plugin:@typescript-eslint/recommended",
29 | "plugin:@typescript-eslint/recommended-requiring-type-checking",
30 | "plugin:@typescript-eslint/strict",
31 | "plugin:import/recommended",
32 | "plugin:import/typescript",
33 | "plugin:jsx-a11y/recommended",
34 | "prettier"
35 | ],
36 | "reportUnusedDisableDirectives": true,
37 | "settings": {
38 | "import/resolver": {
39 | "typescript": {
40 | "project": "tsconfig.json"
41 | }
42 | },
43 | "react": {
44 | "version": "detect"
45 | }
46 | },
47 | "rules": {
48 | "@typescript-eslint/ban-ts-comment": [
49 | "error",
50 | {
51 | "ts-expect-error": "allow-with-description",
52 | "minimumDescriptionLength": 10
53 | }
54 | ],
55 | "@typescript-eslint/consistent-type-definitions": "off",
56 | "@typescript-eslint/consistent-type-exports": "error",
57 | "@typescript-eslint/consistent-type-imports": [
58 | "warn",
59 | { "fixStyle": "inline-type-imports" }
60 | ],
61 | "@typescript-eslint/no-import-type-side-effects": "error",
62 | "@typescript-eslint/no-misused-promises": [
63 | "error",
64 | {
65 | "checksVoidReturn": false
66 | }
67 | ],
68 | "@typescript-eslint/no-unused-vars": [
69 | "warn",
70 | { "argsIgnorePattern": "^_" } // Ignore arguments that start with an underscore
71 | ],
72 | "import/no-extraneous-dependencies": [
73 | "error",
74 | {
75 | "devDependencies": [
76 | "./src/demo/**",
77 | "./src/**/__tests__/**",
78 | "vite.config.ts"
79 | ],
80 | "includeTypes": true
81 | }
82 | ],
83 | "import/no-mutable-exports": "error",
84 | "import/no-unused-modules": "error",
85 | // Note that no-duplicates prefer-inline doesn't yet handle duplicate type
86 | // imports properly
87 | // https://github.com/import-js/eslint-plugin-import/issues/2715
88 | // (improvements are unreleased in eslint-plugin-import)
89 | "import/no-duplicates": ["error", { "prefer-inline": true }],
90 | "no-console": [
91 | "warn",
92 | {
93 | "allow": ["warn", "error"]
94 | }
95 | ],
96 | "no-restricted-imports": "off",
97 | "@typescript-eslint/no-restricted-imports": [
98 | "error",
99 | {
100 | "paths": [
101 | {
102 | "name": "lodash",
103 | "message": "Please use specific module imports like \"lodash/foo\" instead of \"lodash\".",
104 | "allowTypeImports": true
105 | },
106 | {
107 | "name": "@mui/icons-material",
108 | "message": "Please use second-level default path imports rather than top-level named imports. See https://github.com/sjdemartini/mui-tiptap/issues/154"
109 | }
110 | ],
111 | // Disallow imports of third-level MUI "private" paths per
112 | // https://mui.com/material-ui/guides/minimizing-bundle-size/#option-one-use-path-imports
113 | "patterns": [
114 | {
115 | "group": ["@mui/*/*/*"],
116 | "message": "Imports of third-level MUI \"private\" paths are not allowed per https://mui.com/material-ui/guides/minimizing-bundle-size/#option-one-use-path-imports"
117 | }
118 | ]
119 | }
120 | ],
121 | "react/function-component-definition": "warn",
122 | "react/jsx-no-useless-fragment": ["warn", { "allowExpressions": true }],
123 | "react-hooks/exhaustive-deps": [
124 | "warn",
125 | {
126 | "additionalHooks": "(useUpdateEffect)"
127 | }
128 | ],
129 | "react-refresh/only-export-components": [
130 | "warn",
131 | { "allowConstantExport": true }
132 | ],
133 | "tss-unused-classes/unused-classes": "warn"
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/extensions/HeadingWithAnchor.ts:
--------------------------------------------------------------------------------
1 | import type { Editor } from "@tiptap/core";
2 | import { Heading, type HeadingOptions } from "@tiptap/extension-heading";
3 | import { ReactNodeViewRenderer } from "@tiptap/react";
4 | import HeadingWithAnchorComponent from "./HeadingWithAnchorComponent";
5 |
6 | export type HeadingWithAnchorOptions = HeadingOptions & {
7 | /**
8 | * If true, checks whether the current URL hash string indicates we should be
9 | * anchored to a specific heading, and if so, scrolls to that heading after
10 | * rendering editor content. Since Tiptap's editor does not add content to the
11 | * DOM on initial/server render, this must be set to true in order to scroll
12 | * after mounting.
13 | *
14 | * You may want to set this to `false` if you are using the Collaboration
15 | * extension and the Y.Doc hasn't yet synced, since the collaboration document
16 | * won't have content still on mount. In that case, you can handle scrolling
17 | * to an anchor separately via the collaboration sync callback, using the
18 | * exported scrollToCurrentAnchor function.
19 | */
20 | scrollToAnchorOnMount: boolean;
21 | };
22 |
23 | /**
24 | * A modified version of Tiptap’s `Heading` extension
25 | * (https://tiptap.dev/api/nodes/heading), with dynamic GitHub-like anchor links
26 | * for every heading you add. An anchor link button appears to the left of each
27 | * heading when you hovering over it, when the `editor` has `editable` set to
28 | * `false`. This allows users to share links and jump to specific headings
29 | * within your rendered editor content. It can also accommodate building a table
30 | * of contents or outline more easily.
31 | */
32 | const HeadingWithAnchor = Heading.extend({
33 | addOptions() {
34 | return {
35 | // Tiptap claims this.parent can be undefined, so disable this eslint rule
36 | // https://tiptap.dev/guide/custom-extensions/#extend-existing-attributes
37 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
38 | ...this.parent?.(),
39 | scrollToAnchorOnMount: true,
40 | };
41 | },
42 |
43 | onCreate() {
44 | // It seems that when `onCreate` is called via this extension `onCreate`
45 | // definition, our content and HeadingWithAnchor React node-views have yet
46 | // to be rendered. (Also notably, react renderers are async so don't appear
47 | // even when the rest of the document HTML first shows up, as mentioned in
48 | // https://github.com/ueberdosis/tiptap/issues/1527#issuecomment-888380206.)
49 | // Delaying until the end of the event loop with setTimeout should do the
50 | // trick.
51 | if (this.options.scrollToAnchorOnMount) {
52 | setTimeout(() => {
53 | scrollToCurrentHeadingAnchor(this.editor);
54 | });
55 | }
56 | },
57 |
58 | // Although we could render this using just HTML presumably (via `renderHTML`)
59 | // and don't need any fancy interaction with React, doing so allows us to use
60 | // a MUI SVG icon as well as MUI styling
61 | addNodeView() {
62 | return ReactNodeViewRenderer(HeadingWithAnchorComponent);
63 | },
64 | });
65 |
66 | export default HeadingWithAnchor;
67 |
68 | /**
69 | * If there's a URL hash string indicating we should be anchored to a specific
70 | * heading, this function scrolls to that heading.
71 | *
72 | * We have to do this manually/programmatically after first render using this
73 | * function, since when the page first loads, the editor content will not be
74 | * mounted/rendered, so the browser doesn't move to the anchor automatically
75 | * just from having the anchor in the URL. Note that we only want to do this
76 | * once on mount/create.
77 | */
78 | export function scrollToCurrentHeadingAnchor(editor: Editor) {
79 | if (editor.isDestroyed || !("heading" in editor.storage)) {
80 | // If the editor is already removed/destroyed, or the heading extension
81 | // isn't enabled, we can stop
82 | return;
83 | }
84 |
85 | const currentHash = window.location.hash;
86 | const elementId = currentHash.slice(1);
87 | if (!elementId) {
88 | return;
89 | }
90 |
91 | const elementForHash = window.document.getElementById(elementId);
92 |
93 | // We'll only scroll if the given hash points to an element that's part of our
94 | // editor content (i.e., ignore external anchors)
95 | if (elementForHash && editor.options.element.contains(elementForHash)) {
96 | elementForHash.scrollIntoView({
97 | behavior: "smooth",
98 | block: "start",
99 | inline: "nearest",
100 | });
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/demo/mentionSuggestionOptions.ts:
--------------------------------------------------------------------------------
1 | import type { MentionOptions } from "@tiptap/extension-mention";
2 | import { ReactRenderer } from "@tiptap/react";
3 | import tippy, { type Instance as TippyInstance } from "tippy.js";
4 | import SuggestionList, { type SuggestionListRef } from "./SuggestionList";
5 |
6 | export type MentionSuggestion = {
7 | id: string;
8 | mentionLabel: string;
9 | };
10 |
11 | /**
12 | * Workaround for the current typing incompatibility between Tippy.js and Tiptap
13 | * Suggestion utility.
14 | *
15 | * @see https://github.com/ueberdosis/tiptap/issues/2795#issuecomment-1160623792
16 | *
17 | * Adopted from
18 | * https://github.com/Doist/typist/blob/a1726a6be089e3e1452def641dfcfc622ac3e942/stories/typist-editor/constants/suggestions.ts#L169-L186
19 | */
20 | const DOM_RECT_FALLBACK: DOMRect = {
21 | bottom: 0,
22 | height: 0,
23 | left: 0,
24 | right: 0,
25 | top: 0,
26 | width: 0,
27 | x: 0,
28 | y: 0,
29 | toJSON() {
30 | return {};
31 | },
32 | };
33 |
34 | export const mentionSuggestionOptions: MentionOptions["suggestion"] = {
35 | // Replace this `items` code with a call to your API that returns suggestions
36 | // of whatever sort you like (including potentially additional data beyond
37 | // just an ID and a label). It need not be async but is written that way for
38 | // the sake of example.
39 | items: async ({ query }): Promise =>
40 | Promise.resolve(
41 | [
42 | "Lea Thompson",
43 | "Cyndi Lauper",
44 | "Tom Cruise",
45 | "Madonna",
46 | "Jerry Hall",
47 | "Joan Collins",
48 | "Winona Ryder",
49 | "Christina Applegate",
50 | "Alyssa Milano",
51 | "Molly Ringwald",
52 | "Ally Sheedy",
53 | "Debbie Harry",
54 | "Olivia Newton-John",
55 | "Elton John",
56 | "Michael J. Fox",
57 | "Axl Rose",
58 | "Emilio Estevez",
59 | "Ralph Macchio",
60 | "Rob Lowe",
61 | "Jennifer Grey",
62 | "Mickey Rourke",
63 | "John Cusack",
64 | "Matthew Broderick",
65 | "Justine Bateman",
66 | "Lisa Bonet",
67 | "Benicio Monserrate Rafael del Toro Sánchez",
68 | ]
69 | // Typically we'd be getting this data from an API where we'd have a
70 | // definitive "id" to use for each suggestion item, but for the sake of
71 | // example, we'll just set the index within this hardcoded list as the
72 | // ID of each item.
73 | .map((name, index) => ({ mentionLabel: name, id: index.toString() }))
74 | // Find matching entries based on what the user has typed so far (after
75 | // the @ symbol)
76 | .filter((item) =>
77 | item.mentionLabel.toLowerCase().startsWith(query.toLowerCase())
78 | )
79 | .slice(0, 5)
80 | ),
81 |
82 | render: () => {
83 | let component: ReactRenderer | undefined;
84 | let popup: TippyInstance | undefined;
85 |
86 | return {
87 | onStart: (props) => {
88 | component = new ReactRenderer(SuggestionList, {
89 | props,
90 | editor: props.editor,
91 | });
92 |
93 | popup = tippy("body", {
94 | getReferenceClientRect: () =>
95 | props.clientRect?.() ?? DOM_RECT_FALLBACK,
96 | appendTo: () => document.body,
97 | content: component.element,
98 | showOnCreate: true,
99 | interactive: true,
100 | trigger: "manual",
101 | placement: "bottom-start",
102 | })[0];
103 | },
104 |
105 | onUpdate(props) {
106 | component?.updateProps(props);
107 |
108 | popup?.setProps({
109 | getReferenceClientRect: () =>
110 | props.clientRect?.() ?? DOM_RECT_FALLBACK,
111 | });
112 | },
113 |
114 | onKeyDown(props) {
115 | if (props.event.key === "Escape") {
116 | popup?.hide();
117 | return true;
118 | }
119 |
120 | if (!component?.ref) {
121 | return false;
122 | }
123 |
124 | return component.ref.onKeyDown(props);
125 | },
126 |
127 | onExit() {
128 | popup?.destroy();
129 | component?.destroy();
130 |
131 | // Remove references to the old popup and component upon destruction/exit.
132 | // (This should prevent redundant calls to `popup.destroy()`, which Tippy
133 | // warns in the console is a sign of a memory leak, as the `suggestion`
134 | // plugin seems to call `onExit` both when a suggestion menu is closed after
135 | // a user chooses an option, *and* when the editor itself is destroyed.)
136 | popup = undefined;
137 | component = undefined;
138 | },
139 | };
140 | },
141 | };
142 |
--------------------------------------------------------------------------------
/src/controls/MenuSelect.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Select,
3 | outlinedInputClasses,
4 | selectClasses,
5 | svgIconClasses,
6 | type SelectProps,
7 | } from "@mui/material";
8 | import { useState } from "react";
9 | import { makeStyles } from "tss-react/mui";
10 | import MenuButtonTooltip from "./MenuButtonTooltip";
11 |
12 | export type MenuSelectProps = SelectProps & {
13 | /** An optional tooltip to show when hovering over this Select. */
14 | tooltipTitle?: string;
15 | };
16 |
17 | const useStyles = makeStyles({ name: { MenuSelect } })((theme) => {
18 | return {
19 | rootTooltipWrapper: {
20 | display: "inline-flex",
21 | },
22 |
23 | selectRoot: {
24 | // Don't show the default outline when not hovering or focused, for better
25 | // style consistency with the MenuButtons
26 | [`&:not(:hover):not(.${outlinedInputClasses.focused}) .${outlinedInputClasses.notchedOutline}`]:
27 | {
28 | borderWidth: 0,
29 | },
30 |
31 | [`& .${svgIconClasses.root}`]: {
32 | // Ensure that if an icon is used as the `renderValue` result, it uses
33 | // the same color as the default ToggleButton icon and the Select
34 | // dropdown arrow icon
35 | // https://github.com/mui/material-ui/blob/2cb9664b16d5a862a3796add7c8e3b088b47acb5/packages/mui-material/src/ToggleButton/ToggleButton.js#L60,
36 | // https://github.com/mui/material-ui/blob/0b7beb93c9015da6e35c2a31510f679126cf0de1/packages/mui-material/src/NativeSelect/NativeSelectInput.js#L96
37 | color: theme.palette.action.active,
38 | },
39 |
40 | [`&.${selectClasses.disabled} .${svgIconClasses.root}`]: {
41 | // Matching
42 | // https://github.com/mui/material-ui/blob/2cb9664b16d5a862a3796add7c8e3b088b47acb5/packages/mui-material/src/ToggleButton/ToggleButton.js#L65
43 | color: theme.palette.action.disabled,
44 | },
45 | },
46 |
47 | select: {
48 | // Increase specificity to override MUI's styles
49 | "&&&": {
50 | paddingLeft: theme.spacing(1),
51 | paddingRight: theme.spacing(3),
52 | },
53 | },
54 |
55 | selectDropdownIcon: {
56 | // Move the caret icon closer to the right than default so the button is
57 | // more compact
58 | right: 1,
59 | },
60 |
61 | input: {
62 | paddingTop: "3px",
63 | paddingBottom: "3px",
64 | fontSize: "0.9em",
65 | },
66 | };
67 | });
68 |
69 | /** A Select that is styled to work well with other menu bar controls. */
70 | export default function MenuSelect({
71 | tooltipTitle,
72 | ...selectProps
73 | }: MenuSelectProps) {
74 | const { classes, cx } = useStyles();
75 | // We use a controlled tooltip here because otherwise it seems the tooltip can
76 | // get stuck open after selecting something (as it can re-trigger the
77 | // Tooltip's onOpen upon clicking a MenuItem). We instead trigger it to
78 | // open/close based on interaction specifically with the Select (not the usual
79 | // Tooltip onOpen/onClose)
80 | const [tooltipOpen, setTooltipOpen] = useState(false);
81 | const select = (
82 |
83 | margin="none"
84 | variant="outlined"
85 | size="small"
86 | {...selectProps}
87 | onMouseEnter={(...args) => {
88 | setTooltipOpen(true);
89 | selectProps.onMouseEnter?.(...args);
90 | }}
91 | onMouseLeave={(...args) => {
92 | setTooltipOpen(false);
93 | selectProps.onMouseLeave?.(...args);
94 | }}
95 | onClick={(...args) => {
96 | setTooltipOpen(false);
97 | selectProps.onClick?.(...args);
98 | }}
99 | inputProps={{
100 | ...selectProps.inputProps,
101 | className: cx(classes.input, selectProps.inputProps?.className),
102 | }}
103 | // Always show the dropdown options directly below the select input,
104 | // aligned to left-most edge
105 | MenuProps={{
106 | anchorOrigin: {
107 | vertical: "bottom",
108 | horizontal: "left",
109 | },
110 | transformOrigin: {
111 | vertical: "top",
112 | horizontal: "left",
113 | },
114 | ...selectProps.MenuProps,
115 | }}
116 | className={cx(classes.selectRoot, selectProps.className)}
117 | classes={{
118 | ...selectProps.classes,
119 | select: cx(classes.select, selectProps.classes?.select),
120 | icon: cx(classes.selectDropdownIcon, selectProps.classes?.icon),
121 | }}
122 | />
123 | );
124 | return tooltipTitle ? (
125 |
130 | {select}
131 |
132 | ) : (
133 | select
134 | );
135 | }
136 |
--------------------------------------------------------------------------------
/src/controls/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ColorPicker";
2 | export * from "./ColorPickerPopper";
3 | export * from "./ColorSwatchButton";
4 | export { default as MenuButton, type MenuButtonProps } from "./MenuButton";
5 | export {
6 | default as MenuButtonAddImage,
7 | type MenuButtonAddImageProps,
8 | } from "./MenuButtonAddImage";
9 | export {
10 | default as MenuButtonAddTable,
11 | type MenuButtonAddTableProps,
12 | } from "./MenuButtonAddTable";
13 | export {
14 | default as MenuButtonAlignCenter,
15 | type MenuButtonAlignCenterProps,
16 | } from "./MenuButtonAlignCenter";
17 | export {
18 | default as MenuButtonAlignJustify,
19 | type MenuButtonAlignJustifyProps,
20 | } from "./MenuButtonAlignJustify";
21 | export {
22 | default as MenuButtonAlignLeft,
23 | type MenuButtonAlignLeftProps,
24 | } from "./MenuButtonAlignLeft";
25 | export {
26 | default as MenuButtonAlignRight,
27 | type MenuButtonAlignRightProps,
28 | } from "./MenuButtonAlignRight";
29 | export {
30 | default as MenuButtonBlockquote,
31 | type MenuButtonBlockquoteProps,
32 | } from "./MenuButtonBlockquote";
33 | export {
34 | default as MenuButtonBold,
35 | type MenuButtonBoldProps,
36 | } from "./MenuButtonBold";
37 | export {
38 | default as MenuButtonBulletedList,
39 | type MenuButtonBulletedListProps,
40 | } from "./MenuButtonBulletedList";
41 | export {
42 | default as MenuButtonCode,
43 | type MenuButtonCodeProps,
44 | } from "./MenuButtonCode";
45 | export {
46 | default as MenuButtonCodeBlock,
47 | type MenuButtonCodeBlockProps,
48 | } from "./MenuButtonCodeBlock";
49 | export * from "./MenuButtonColorPicker";
50 | export {
51 | default as MenuButtonEditLink,
52 | type MenuButtonEditLinkProps,
53 | } from "./MenuButtonEditLink";
54 | export {
55 | default as MenuButtonHighlightColor,
56 | type MenuButtonHighlightColorProps,
57 | } from "./MenuButtonHighlightColor";
58 | export {
59 | default as MenuButtonHighlightToggle,
60 | type MenuButtonHighlightToggleProps,
61 | } from "./MenuButtonHighlightToggle";
62 | export {
63 | default as MenuButtonHorizontalRule,
64 | type MenuButtonHorizontalRuleProps,
65 | } from "./MenuButtonHorizontalRule";
66 | export {
67 | default as MenuButtonImageUpload,
68 | type MenuButtonImageUploadProps,
69 | } from "./MenuButtonImageUpload";
70 | export {
71 | default as MenuButtonIndent,
72 | type MenuButtonIndentProps,
73 | } from "./MenuButtonIndent";
74 | export {
75 | default as MenuButtonItalic,
76 | type MenuButtonItalicProps,
77 | } from "./MenuButtonItalic";
78 | export {
79 | default as MenuButtonOrderedList,
80 | type MenuButtonOrderedListProps,
81 | } from "./MenuButtonOrderedList";
82 | export {
83 | default as MenuButtonRedo,
84 | type MenuButtonRedoProps,
85 | } from "./MenuButtonRedo";
86 | export {
87 | default as MenuButtonRemoveFormatting,
88 | type MenuButtonRemoveFormattingProps,
89 | } from "./MenuButtonRemoveFormatting";
90 | export {
91 | default as MenuButtonStrikethrough,
92 | type MenuButtonStrikethroughProps,
93 | } from "./MenuButtonStrikethrough";
94 | export {
95 | default as MenuButtonSubscript,
96 | type MenuButtonSubscriptProps,
97 | } from "./MenuButtonSubscript";
98 | export {
99 | default as MenuButtonSuperscript,
100 | type MenuButtonSuperscriptProps,
101 | } from "./MenuButtonSuperscript";
102 | export {
103 | default as MenuButtonTaskList,
104 | type MenuButtonTaskListProps,
105 | } from "./MenuButtonTaskList";
106 | export {
107 | default as MenuButtonTextColor,
108 | type MenuButtonTextColorProps,
109 | } from "./MenuButtonTextColor";
110 | export {
111 | default as MenuButtonTooltip,
112 | type MenuButtonTooltipProps,
113 | } from "./MenuButtonTooltip";
114 | export {
115 | default as MenuButtonUnderline,
116 | type MenuButtonUnderlineProps,
117 | } from "./MenuButtonUnderline";
118 | export {
119 | default as MenuButtonUndo,
120 | type MenuButtonUndoProps,
121 | } from "./MenuButtonUndo";
122 | export {
123 | default as MenuButtonUnindent,
124 | type MenuButtonUnindentProps,
125 | } from "./MenuButtonUnindent";
126 | export {
127 | default as MenuControlsContainer,
128 | type MenuControlsContainerProps,
129 | } from "./MenuControlsContainer";
130 | export { default as MenuSelect, type MenuSelectProps } from "./MenuSelect";
131 | export {
132 | default as MenuSelectFontFamily,
133 | type FontFamilySelectOption,
134 | type MenuSelectFontFamilyProps,
135 | } from "./MenuSelectFontFamily";
136 | export {
137 | default as MenuSelectFontSize,
138 | type MenuSelectFontSizeProps,
139 | } from "./MenuSelectFontSize";
140 | export {
141 | default as MenuSelectHeading,
142 | type HeadingOptionValue,
143 | type MenuSelectHeadingProps,
144 | } from "./MenuSelectHeading";
145 | export {
146 | default as MenuSelectTextAlign,
147 | type MenuSelectTextAlignProps,
148 | type TextAlignSelectOption,
149 | } from "./MenuSelectTextAlign";
150 | export {
151 | default as TableMenuControls,
152 | type TableMenuControlsProps,
153 | } from "./TableMenuControls";
154 |
--------------------------------------------------------------------------------
/src/demo/SuggestionList.tsx:
--------------------------------------------------------------------------------
1 | import { List, ListItem, ListItemButton, Paper } from "@mui/material";
2 | import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
3 | import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
4 | import type { MentionSuggestion } from "./mentionSuggestionOptions";
5 |
6 | export type SuggestionListRef = {
7 | // For convenience using this SuggestionList from within the
8 | // mentionSuggestionOptions, we'll match the signature of SuggestionOptions's
9 | // `onKeyDown` returned in its `render` function
10 | onKeyDown: NonNullable<
11 | ReturnType<
12 | NonNullable["render"]>
13 | >["onKeyDown"]
14 | >;
15 | };
16 |
17 | // This type is based on
18 | // https://github.com/ueberdosis/tiptap/blob/a27c35ac8f1afc9d51f235271814702bc72f1e01/packages/extension-mention/src/mention.ts#L73-L103.
19 | // TODO(Steven DeMartini): Use the Tiptap exported MentionNodeAttrs interface
20 | // once https://github.com/ueberdosis/tiptap/pull/4136 is merged.
21 | interface MentionNodeAttrs {
22 | id: string | null;
23 | label?: string | null;
24 | }
25 |
26 | export type SuggestionListProps = SuggestionProps;
27 |
28 | const SuggestionList = forwardRef(
29 | (props, ref) => {
30 | const [selectedIndex, setSelectedIndex] = useState(0);
31 |
32 | const selectItem = (index: number) => {
33 | if (index >= props.items.length) {
34 | // Make sure we actually have enough items to select the given index. For
35 | // instance, if a user presses "Enter" when there are no options, the index will
36 | // be 0 but there won't be any items, so just ignore the callback here
37 | return;
38 | }
39 |
40 | const suggestion = props.items[index];
41 |
42 | // Set all of the attributes of our Mention node based on the suggestion
43 | // data. The fields of `suggestion` will depend on whatever data you
44 | // return from your `items` function in your "suggestion" options handler.
45 | // Our suggestion handler returns `MentionSuggestion`s (which we've
46 | // indicated via SuggestionProps). We are passing an
47 | // object of the `MentionNodeAttrs` shape when calling `command` (utilized
48 | // by the Mention extension to create a Mention Node).
49 | const mentionItem: MentionNodeAttrs = {
50 | id: suggestion.id,
51 | label: suggestion.mentionLabel,
52 | };
53 | // @ts-expect-error there is currently a bug in the Tiptap SuggestionProps
54 | // type where if you specify the suggestion type (like
55 | // `SuggestionProps`), it will incorrectly require that
56 | // type variable for `command`'s argument as well (whereas instead the
57 | // type of that argument should be the Mention Node attributes). This
58 | // should be fixed once https://github.com/ueberdosis/tiptap/pull/4136 is
59 | // merged and we can add a separate type arg to `SuggestionProps` to
60 | // specify the type of the commanded selected item.
61 | props.command(mentionItem);
62 | };
63 |
64 | const upHandler = () => {
65 | setSelectedIndex(
66 | (selectedIndex + props.items.length - 1) % props.items.length
67 | );
68 | };
69 |
70 | const downHandler = () => {
71 | setSelectedIndex((selectedIndex + 1) % props.items.length);
72 | };
73 |
74 | const enterHandler = () => {
75 | selectItem(selectedIndex);
76 | };
77 |
78 | useEffect(() => setSelectedIndex(0), [props.items]);
79 |
80 | useImperativeHandle(ref, () => ({
81 | onKeyDown: ({ event }) => {
82 | if (event.key === "ArrowUp") {
83 | upHandler();
84 | return true;
85 | }
86 |
87 | if (event.key === "ArrowDown") {
88 | downHandler();
89 | return true;
90 | }
91 |
92 | if (event.key === "Enter") {
93 | enterHandler();
94 | return true;
95 | }
96 |
97 | return false;
98 | },
99 | }));
100 |
101 | return props.items.length > 0 ? (
102 |
103 |
110 | {props.items.map((item, index) => (
111 |
112 | selectItem(index)}
115 | >
116 | {item.mentionLabel}
117 |
118 |
119 | ))}
120 |
121 |
122 | ) : null;
123 | }
124 | );
125 |
126 | SuggestionList.displayName = "SuggestionList";
127 |
128 | export default SuggestionList;
129 |
--------------------------------------------------------------------------------
/src/FieldContainer.tsx:
--------------------------------------------------------------------------------
1 | import type React from "react";
2 | import { makeStyles } from "tss-react/mui";
3 | import { Z_INDEXES, getUtilityClasses } from "./styles";
4 |
5 | export type FieldContainerClasses = ReturnType["classes"];
6 |
7 | export type FieldContainerProps = {
8 | /**
9 | * Which style to use for the field. "outlined" shows a border around the children,
10 | * which updates its appearance depending on hover/focus states, like MUI's
11 | * OutlinedInput. "standard" does not include any outer border.
12 | */
13 | variant?: "outlined" | "standard";
14 | /** The content to render inside the container. */
15 | children: React.ReactNode;
16 | /** Class applied to the `root` element. */
17 | className?: string;
18 | /** Override or extend existing styles. */
19 | classes?: Partial;
20 | focused?: boolean;
21 | disabled?: boolean;
22 | };
23 |
24 | const fieldContainerClasses: FieldContainerClasses = getUtilityClasses(
25 | "FieldContainer",
26 | ["root", "outlined", "standard", "focused", "disabled", "notchedOutline"]
27 | );
28 |
29 | // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
30 | const useStyles = makeStyles({
31 | name: { FieldContainer },
32 | uniqId: "Os7ZPW", // https://docs.tss-react.dev/nested-selectors#ssr
33 | })((theme, _params, classes) => {
34 | // Based on the concept behind and styles of OutlinedInput and NotchedOutline
35 | // styles here, to imitate outlined input appearance in material-ui
36 | // https://github.com/mui-org/material-ui/blob/a4972c5931e637611f6421ed2a5cc3f78207cbb2/packages/material-ui/src/OutlinedInput/OutlinedInput.js#L9-L37
37 | // https://github.com/mui/material-ui/blob/a4972c5931e637611f6421ed2a5cc3f78207cbb2/packages/material-ui/src/OutlinedInput/NotchedOutline.js
38 | return {
39 | root: {},
40 |
41 | // Class/styles applied to the root element if the component is using the
42 | // "outlined" variant
43 | outlined: {
44 | borderRadius: theme.shape.borderRadius,
45 | padding: 1, //
46 | position: "relative",
47 |
48 | [`&:hover .${classes.notchedOutline}`]: {
49 | borderColor: theme.palette.text.primary,
50 | },
51 |
52 | [`&.${classes.focused} .${classes.notchedOutline}`]: {
53 | borderColor: theme.palette.primary.main,
54 | borderWidth: 2,
55 | },
56 |
57 | [`&.${classes.disabled} .${classes.notchedOutline}`]: {
58 | borderColor: theme.palette.action.disabled,
59 | },
60 | },
61 |
62 | // Class/styles applied to the root element if the component is using the
63 | // "standard" variant
64 | standard: {},
65 |
66 | // Class/styles applied to the root element if the component is focused (if the
67 | // `focused` prop is true)
68 | focused: {},
69 |
70 | // Styles applied to the root element if the component is disabled (if the
71 | // `disabled` prop is true)
72 | disabled: {},
73 |
74 | notchedOutline: {
75 | position: "absolute",
76 | inset: 0,
77 | borderRadius: "inherit",
78 | borderColor:
79 | theme.palette.mode === "light"
80 | ? "rgba(0, 0, 0, 0.23)"
81 | : "rgba(255, 255, 255, 0.23)",
82 | borderStyle: "solid",
83 | borderWidth: 1,
84 | pointerEvents: "none",
85 | overflow: "hidden",
86 | zIndex: Z_INDEXES.NOTCHED_OUTLINE,
87 | },
88 | };
89 | });
90 |
91 | /**
92 | * Renders an element with classes and styles that correspond to the state and
93 | * style-variant of a user-input field, the content of which should be passed in as
94 | * `children`.
95 | */
96 | export default function FieldContainer({
97 | variant = "outlined",
98 | children,
99 | focused,
100 | disabled,
101 | classes: overrideClasses = {},
102 | className,
103 | }: FieldContainerProps) {
104 | const { classes, cx } = useStyles(undefined, {
105 | props: { classes: overrideClasses },
106 | });
107 |
108 | return (
109 |
124 | {children}
125 |
126 | {variant === "outlined" && (
127 |
134 | )}
135 |
136 | );
137 | }
138 |
--------------------------------------------------------------------------------
/src/RichTextEditor.tsx:
--------------------------------------------------------------------------------
1 | import { useEditor, type Editor, type EditorOptions } from "@tiptap/react";
2 | import {
3 | forwardRef,
4 | useEffect,
5 | useImperativeHandle,
6 | type DependencyList,
7 | } from "react";
8 | import type { Except, SetRequired } from "type-fest";
9 | import RichTextEditorProvider from "./RichTextEditorProvider";
10 | import RichTextField, { type RichTextFieldProps } from "./RichTextField";
11 |
12 | export interface RichTextEditorProps
13 | extends SetRequired, "extensions"> {
14 | /**
15 | * Render the controls content to show inside the menu bar atop the editor
16 | * content. Typically you will want to render a
17 | * containing several MenuButton* components, depending on what controls you
18 | * want to include in the menu bar (and what extensions you've enabled).
19 | * If not provided, no menu bar will be shown.
20 | *
21 | * This is a render prop and we can't simply accept a ReactNode directly
22 | * because we need to ensure that the controls content is re-rendered whenever
23 | * the editor selection, content, etc. is updated (which is triggered via
24 | * useEditor within this component). If a ReactNode were directly passed in,
25 | * it would only re-render whenever the *parent* of RichTextEditor re-renders,
26 | * which wouldn't be sufficient.
27 | */
28 | renderControls?: (editor: Editor | null) => React.ReactNode;
29 | /**
30 | * Props applied to the RichTextField element inside (except `controls`, which
31 | * is controlled via `renderControls`, as this ensures proper re-rendering as
32 | * the editor changes).
33 | */
34 | RichTextFieldProps?: Except;
35 | /**
36 | * Optional content to render alongside/after the inner RichTextField, where
37 | * you can access the editor via the parameter to this render prop, or in a
38 | * child component via `useRichTextEditorContext()`. Useful for including
39 | * plugins like mui-tiptap's LinkBubbleMenu and TableBubbleMenu, or other
40 | * custom components (e.g. a menu that utilizes Tiptap's FloatingMenu). (This
41 | * is a render prop rather than just a ReactNode for the same reason as
42 | * `renderControls`; see above.)
43 | */
44 | children?: (editor: Editor | null) => React.ReactNode;
45 | /**
46 | * A dependency array for the useEditor hook, which will re-create the editor
47 | * when any value in the array changes.
48 | */
49 | editorDependencies?: DependencyList;
50 | /** Class applied to the root element. */
51 | className?: string;
52 | }
53 |
54 | export type RichTextEditorRef = {
55 | editor: Editor | null;
56 | };
57 |
58 | /**
59 | * An all-in-one component to directly render a MUI-styled Tiptap rich text
60 | * editor field.
61 | *
62 | * NOTE: changes to `content` will not trigger re-rendering of the component.
63 | * i.e., by default the `content` prop is essentially "initial content". To
64 | * change content after rendering, you can use a hook and call
65 | * `rteRef.current?.editor?.setContent(newContent)`. See README "Re-rendering
66 | * `RichTextEditor` when `content` changes" for more details.
67 | *
68 | * Example:
69 | *
70 | */
71 | const RichTextEditor = forwardRef(
72 | function RichTextEditor(
73 | {
74 | className,
75 | renderControls,
76 | RichTextFieldProps = {},
77 | children,
78 | editorDependencies = [],
79 | // We default to `editable=true` just like `useEditor` does
80 | editable = true,
81 | ...editorProps
82 | }: RichTextEditorProps,
83 | ref
84 | ) {
85 | const editor = useEditor(
86 | {
87 | editable: editable,
88 | ...editorProps,
89 | },
90 | editorDependencies
91 | );
92 |
93 | // Allow consumers of this component to access the editor via ref
94 | useImperativeHandle(ref, () => ({
95 | editor: editor,
96 | }));
97 |
98 | // Update editable state if/when it changes
99 | useEffect(() => {
100 | if (!editor || editor.isDestroyed || editor.isEditable === editable) {
101 | return;
102 | }
103 | // We use queueMicrotask to avoid any flushSync console errors as
104 | // mentioned here (though setEditable shouldn't trigger them in practice)
105 | // https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730
106 | queueMicrotask(() => editor.setEditable(editable));
107 | }, [editable, editor]);
108 |
109 | return (
110 |
111 |
117 | {children?.(editor)}
118 |
119 | );
120 | }
121 | );
122 |
123 | export default RichTextEditor;
124 |
--------------------------------------------------------------------------------
/src/demo/EditorMenuControls.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "@mui/material";
2 | import {
3 | MenuButtonAddTable,
4 | MenuButtonBlockquote,
5 | MenuButtonBold,
6 | MenuButtonBulletedList,
7 | MenuButtonCode,
8 | MenuButtonCodeBlock,
9 | MenuButtonEditLink,
10 | MenuButtonHighlightColor,
11 | MenuButtonHorizontalRule,
12 | MenuButtonImageUpload,
13 | MenuButtonIndent,
14 | MenuButtonItalic,
15 | MenuButtonOrderedList,
16 | MenuButtonRedo,
17 | MenuButtonRemoveFormatting,
18 | MenuButtonStrikethrough,
19 | MenuButtonSubscript,
20 | MenuButtonSuperscript,
21 | MenuButtonTaskList,
22 | MenuButtonTextColor,
23 | MenuButtonUnderline,
24 | MenuButtonUndo,
25 | MenuButtonUnindent,
26 | MenuControlsContainer,
27 | MenuDivider,
28 | MenuSelectFontFamily,
29 | MenuSelectFontSize,
30 | MenuSelectHeading,
31 | MenuSelectTextAlign,
32 | isTouchDevice,
33 | } from "../";
34 |
35 | export default function EditorMenuControls() {
36 | const theme = useTheme();
37 | return (
38 |
39 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
85 |
86 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | {/* On touch devices, we'll show indent/unindent buttons, since they're
117 | unlikely to have a keyboard that will allow for using Tab/Shift+Tab. These
118 | buttons probably aren't necessary for keyboard users and would add extra
119 | clutter. */}
120 | {isTouchDevice() && (
121 | <>
122 |
123 |
124 |
125 | >
126 | )}
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
142 | // For the sake of a demo, we don't have a server to upload the files
143 | // to, so we'll instead convert each one to a local "temporary" object
144 | // URL. This will not persist properly in a production setting. You
145 | // should instead upload the image files to your server, or perhaps
146 | // convert the images to bas64 if you would like to encode the image
147 | // data directly into the editor content, though that can make the
148 | // editor content very large.
149 | files.map((file) => ({
150 | src: URL.createObjectURL(file),
151 | alt: file.name,
152 | }))
153 | }
154 | />
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 | );
172 | }
173 |
--------------------------------------------------------------------------------
/src/controls/TableMenuControls.tsx:
--------------------------------------------------------------------------------
1 | import FormatColorFill from "@mui/icons-material/FormatColorFill";
2 | import GridOff from "@mui/icons-material/GridOff";
3 | import MenuDivider from "../MenuDivider";
4 | import { useRichTextEditorContext } from "../context";
5 | import {
6 | DeleteColumn,
7 | DeleteRow,
8 | InsertColumnLeft,
9 | InsertColumnRight,
10 | InsertRowBottom,
11 | InsertRowTop,
12 | LayoutColumnFill,
13 | LayoutRowFill,
14 | MergeCellsHorizontal,
15 | SplitCellsHorizontal,
16 | } from "../icons";
17 | import MenuButton from "./MenuButton";
18 | import MenuControlsContainer from "./MenuControlsContainer";
19 |
20 | export type TableMenuControlsProps = {
21 | /** Class applied to the root controls container element. */
22 | className?: string;
23 | /**
24 | * Override the default labels for any of the menu buttons. For any value that
25 | * is omitted in this object, it falls back to the default content.
26 | */
27 | labels?: {
28 | insertColumnBefore?: string;
29 | insertColumnAfter?: string;
30 | deleteColumn?: string;
31 | insertRowAbove?: string;
32 | insertRowBelow?: string;
33 | deleteRow?: string;
34 | mergeCells?: string;
35 | splitCell?: string;
36 | toggleHeaderRow?: string;
37 | toggleHeaderColumn?: string;
38 | toggleHeaderCell?: string;
39 | deleteTable?: string;
40 | };
41 | };
42 |
43 | /**
44 | * Renders all of the controls for manipulating a table in a Tiptap editor
45 | * (add or delete columns or rows, merge cells, etc.).
46 | */
47 | export default function TableMenuControls({
48 | className,
49 | labels,
50 | }: TableMenuControlsProps) {
51 | const editor = useRichTextEditorContext();
52 | return (
53 |
54 | editor?.chain().focus().addColumnBefore().run()}
58 | disabled={!editor?.can().addColumnBefore()}
59 | />
60 |
61 | editor?.chain().focus().addColumnAfter().run()}
65 | disabled={!editor?.can().addColumnAfter()}
66 | />
67 |
68 | editor?.chain().focus().deleteColumn().run()}
72 | disabled={!editor?.can().deleteColumn()}
73 | />
74 |
75 |
76 |
77 | editor?.chain().focus().addRowBefore().run()}
81 | disabled={!editor?.can().addRowBefore()}
82 | />
83 |
84 | editor?.chain().focus().addRowAfter().run()}
88 | disabled={!editor?.can().addRowAfter()}
89 | />
90 |
91 | editor?.chain().focus().deleteRow().run()}
95 | disabled={!editor?.can().deleteRow()}
96 | />
97 |
98 |
99 |
100 | editor?.chain().focus().mergeCells().run()}
104 | disabled={!editor?.can().mergeCells()}
105 | />
106 |
107 | editor?.chain().focus().splitCell().run()}
111 | disabled={!editor?.can().splitCell()}
112 | />
113 |
114 |
115 |
116 | editor?.chain().focus().toggleHeaderRow().run()}
120 | disabled={!editor?.can().toggleHeaderRow()}
121 | />
122 |
123 | editor?.chain().focus().toggleHeaderColumn().run()}
127 | disabled={!editor?.can().toggleHeaderColumn()}
128 | />
129 |
130 | editor?.chain().focus().toggleHeaderCell().run()}
134 | disabled={!editor?.can().toggleHeaderCell()}
135 | selected={editor?.isActive("tableHeader") ?? false}
136 | />
137 |
138 |
139 |
140 | editor?.chain().focus().deleteTable().run()}
144 | disabled={!editor?.can().deleteTable()}
145 | />
146 |
147 | );
148 | }
149 |
--------------------------------------------------------------------------------
/src/controls/MenuButtonColorPicker.tsx:
--------------------------------------------------------------------------------
1 | import type { PopperProps } from "@mui/material";
2 | import { useState, type ReactNode } from "react";
3 | import { makeStyles } from "tss-react/mui";
4 | import type { Except } from "type-fest";
5 | import { FormatColorBar } from "../icons";
6 | import type { ColorPickerProps, SwatchColorOption } from "./ColorPicker";
7 | import { ColorPickerPopper } from "./ColorPickerPopper";
8 | import MenuButton, {
9 | MENU_BUTTON_FONT_SIZE_DEFAULT,
10 | type MenuButtonProps,
11 | } from "./MenuButton";
12 |
13 | export interface MenuButtonColorPickerProps
14 | // Omit the default `color`, `value`, and `onChange` toggle button props so
15 | // that "color" can't be confused for the `value` prop, and so that we can use
16 | // our own types for `value` and `onChange`.
17 | extends Except {
18 | /** The current CSS color string value. */
19 | value: string | undefined;
20 | /** Callback when the color changes. */
21 | onChange: (newColor: string) => void;
22 | /**
23 | * Provide default list of colors (must be valid CSS color strings) which
24 | * are used to form buttons for color swatches.
25 | */
26 | swatchColors?: SwatchColorOption[];
27 | /**
28 | * If true, hides the horizontal bar at the base of the icon/button area that
29 | * shows the currently active color `value`. The indicator pairs well with
30 | * `*NoBar` icons like `FormatColorTextNoBar`, so you may want to hide it if
31 | * your `IconComponent` clashes. By default false.
32 | */
33 | hideColorIndicator?: boolean;
34 | /**
35 | * Override the props for the popper that houses the color picker interface.
36 | */
37 | PopperProps?: Partial;
38 | /** Override the props for the color picker. */
39 | ColorPickerProps?: Partial;
40 | /**
41 | * Unique HTML ID for the color picker popper that will be shown when clicking
42 | * this button (used for aria-describedby for accessibility).
43 | */
44 | popperId?: string;
45 | /** Override the default labels for any of the content. */
46 | labels?: {
47 | /**
48 | * Button label for removing the currently set color (setting the color to
49 | * ""). By default "None".
50 | */
51 | removeColorButton?: ReactNode;
52 | /**
53 | * Tooltip title for the button that removes the currently set color. By
54 | * default "" (no tooltip shown).
55 | */
56 | removeColorButtonTooltipTitle?: ReactNode;
57 | /**
58 | * Button label for canceling updating the color in the picker. By default
59 | * "Cancel".
60 | */
61 | cancelButton?: ReactNode;
62 | /**
63 | * Button label for saving the color chosen in the interface. By default
64 | * "OK".
65 | */
66 | saveButton?: ReactNode;
67 | /**
68 | * The placeholder shown in the text field entry for color. By default:
69 | * 'Ex: "#7cb5ec"'
70 | */
71 | textFieldPlaceholder?: string;
72 | };
73 | }
74 |
75 | const useStyles = makeStyles({ name: { MenuButtonColorPicker } })((theme) => ({
76 | menuButtonIcon: {
77 | fontSize: MENU_BUTTON_FONT_SIZE_DEFAULT,
78 | },
79 |
80 | colorIndicatorIcon: {
81 | position: "absolute",
82 | },
83 |
84 | colorIndicatorIconDisabled: {
85 | color: theme.palette.action.disabled,
86 | },
87 | }));
88 |
89 | export function MenuButtonColorPicker({
90 | value: colorValue,
91 | onChange,
92 | swatchColors,
93 | labels,
94 | hideColorIndicator = false,
95 | popperId,
96 | PopperProps,
97 | ColorPickerProps,
98 | ...menuButtonProps
99 | }: MenuButtonColorPickerProps) {
100 | const { classes, cx } = useStyles();
101 | const [anchorEl, setAnchorEl] = useState(null);
102 |
103 | const handleClose = () => setAnchorEl(null);
104 |
105 | const { IconComponent, children, ...otherMenuButtonProps } = menuButtonProps;
106 |
107 | return (
108 | <>
109 |
111 | anchorEl ? handleClose() : setAnchorEl(e.currentTarget)
112 | }
113 | aria-describedby={popperId}
114 | {...otherMenuButtonProps}
115 | >
116 | {children ?? (
117 | <>
118 | {IconComponent && (
119 |
120 | )}
121 |
122 | {/* We only show the color indicator if a color has been set. (It's
123 | effectively "transparent" otherwise, indicating no color has been
124 | chosen.) This bar indicator icon pairs well with the *NoBar icons
125 | like FormatColorTextNoBar. */}
126 | {!hideColorIndicator && colorValue && (
127 |
137 | )}
138 | >
139 | )}
140 |
141 |
142 | {
148 | onChange(newColor);
149 | handleClose();
150 | }}
151 | onCancel={handleClose}
152 | swatchColors={swatchColors}
153 | ColorPickerProps={ColorPickerProps}
154 | labels={labels}
155 | {...PopperProps}
156 | />
157 | >
158 | );
159 | }
160 |
--------------------------------------------------------------------------------
/src/controls/ColorPickerPopper.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | ClickAwayListener,
4 | Fade,
5 | Paper,
6 | Popper,
7 | Stack,
8 | Tooltip,
9 | type PopperProps,
10 | } from "@mui/material";
11 | import { useEffect, useState } from "react";
12 | import { makeStyles } from "tss-react/mui";
13 | import { Z_INDEXES } from "../styles";
14 | import { ColorPicker } from "./ColorPicker";
15 | import type { MenuButtonColorPickerProps } from "./MenuButtonColorPicker";
16 |
17 | export interface ColorPickerPopperBodyProps
18 | extends Pick<
19 | MenuButtonColorPickerProps,
20 | "swatchColors" | "labels" | "ColorPickerProps"
21 | > {
22 | /** The current color value. Must be a valid CSS color string. */
23 | value: string;
24 | /** Callback when the user is saving/changing the current color. */
25 | onSave: (newColor: string) => void;
26 | /** Callback when the user is canceling updates to the current color. */
27 | onCancel: () => void;
28 | }
29 |
30 | export interface ColorPickerPopperProps
31 | extends PopperProps,
32 | ColorPickerPopperBodyProps {}
33 |
34 | // NOTE: This component's state logic is able to be kept simple because the
35 | // component is unmounted whenever the outer Popper is not open, so we don't
36 | // have to worry about resetting the state ourselves when the user cancels, for
37 | // instance.
38 | export function ColorPickerPopperBody({
39 | value,
40 | onCancel,
41 | onSave,
42 | swatchColors,
43 | labels = {},
44 | ColorPickerProps,
45 | }: ColorPickerPopperBodyProps) {
46 | const {
47 | removeColorButton = "None",
48 | removeColorButtonTooltipTitle = "",
49 | cancelButton = "Cancel",
50 | saveButton = "OK",
51 | } = labels;
52 |
53 | // Because color can change rapidly as the user drags the color in the
54 | // ColorPicker gradient, we'll wait until "Save" to call the onSave prop, and
55 | // we'll store an internal localColor until then. (This could alternatively be
56 | // implemented such that we "save" directly whenever a swatch preset is
57 | // clicked, by looking at the `source` from `ColorPicker.onChange`, but it may
58 | // be useful to tweak a color from a swatch before saving.)
59 | const [localColor, setLocalColor] = useState(value);
60 | // Update our internal value whenever the `color` prop changes (since this is
61 | // a controlled component)
62 | useEffect(() => {
63 | setLocalColor(value);
64 | }, [value]);
65 |
66 | return (
67 | <>
68 | {
72 | setLocalColor(newColor);
73 | }}
74 | labels={labels}
75 | {...ColorPickerProps}
76 | />
77 |
78 |
79 |
80 | {
82 | // No color being specified can mean "none" in some scenarios
83 | // (e.g. highlighting) and "default color"/reset in others (text)
84 | onSave("");
85 | }}
86 | size="small"
87 | >
88 | {removeColorButton}
89 |
90 |
91 |
92 |
93 | {cancelButton}
94 |
95 |
96 | {
98 | onSave(localColor);
99 | }}
100 | size="small"
101 | >
102 | {saveButton}
103 |
104 |
105 | >
106 | );
107 | }
108 |
109 | const useStyles = makeStyles({ name: { ColorPickerPopper } })({
110 | root: {
111 | zIndex: Z_INDEXES.BUBBLE_MENU,
112 | // This width seems to work well to allow exactly 8 swatches, as well as the
113 | // default button content
114 | width: 235,
115 | },
116 | });
117 |
118 | /**
119 | * Renders the ColorPicker inside of a Popper interface, for use with the
120 | * MenuButtonColorPicker.
121 | */
122 | export function ColorPickerPopper({
123 | value,
124 | onSave,
125 | onCancel,
126 | swatchColors,
127 | ColorPickerProps,
128 | labels,
129 | ...popperProps
130 | }: ColorPickerPopperProps) {
131 | const { classes, cx } = useStyles();
132 | return (
133 |
139 | {({ TransitionProps }) => (
140 |
141 |
142 |
152 |
153 |
161 |
162 |
163 |
164 |
165 | )}
166 |
167 | );
168 | }
169 |
--------------------------------------------------------------------------------
/src/LinkBubbleMenu/index.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | import { makeStyles } from "tss-react/mui";
3 | import type { Except } from "type-fest";
4 | import ControlledBubbleMenu, {
5 | type ControlledBubbleMenuProps,
6 | } from "../ControlledBubbleMenu";
7 | import { useRichTextEditorContext } from "../context";
8 | import {
9 | LinkMenuState,
10 | type LinkBubbleMenuHandlerStorage,
11 | } from "../extensions/LinkBubbleMenuHandler";
12 | import EditLinkMenuContent, {
13 | type EditLinkMenuContentProps,
14 | } from "./EditLinkMenuContent";
15 | import ViewLinkMenuContent, {
16 | type ViewLinkMenuContentProps,
17 | } from "./ViewLinkMenuContent";
18 |
19 | export interface LinkBubbleMenuProps
20 | extends Partial<
21 | Except
22 | > {
23 | /**
24 | * Override the default text content/labels in this interface. For any value
25 | * that is omitted in this object, it falls back to the default content.
26 | */
27 | labels?: ViewLinkMenuContentProps["labels"] &
28 | EditLinkMenuContentProps["labels"];
29 | }
30 |
31 | const useStyles = makeStyles({ name: { LinkBubbleMenu } })((theme) => ({
32 | content: {
33 | padding: theme.spacing(1.5, 2, 0.5),
34 | },
35 | }));
36 |
37 | /**
38 | * A component that renders a bubble menu when viewing, creating, or editing a
39 | * link. Requires the mui-tiptap LinkBubbleMenuHandler extension and Tiptap's
40 | * Link extension (@tiptap/extension-link, https://tiptap.dev/api/marks/link) to
41 | * both be included in your editor `extensions` array.
42 | *
43 | * Pairs well with the ` ` component.
44 | *
45 | * If you're using `RichTextEditor`, include this component via
46 | * `RichTextEditor`’s `children` render-prop. Otherwise, include the
47 | * `LinkBubbleMenu` as a child of the component where you call `useEditor` and
48 | * render your `RichTextField` or `RichTextContent`. (The bubble menu itself
49 | * will be positioned appropriately no matter where you put it in your React
50 | * tree, as long as it is re-rendered whenever the Tiptap `editor` forces an
51 | * update, which will happen if it's a child of the component using
52 | * `useEditor`).
53 | */
54 | export default function LinkBubbleMenu({
55 | labels,
56 | ...controlledBubbleMenuProps
57 | }: LinkBubbleMenuProps) {
58 | const { classes } = useStyles();
59 | const editor = useRichTextEditorContext();
60 |
61 | if (!editor?.isEditable) {
62 | return null;
63 | }
64 |
65 | if (!("linkBubbleMenuHandler" in editor.storage)) {
66 | throw new Error(
67 | "You must add the LinkBubbleMenuHandler extension to the useEditor `extensions` array in order to use this component!"
68 | );
69 | }
70 | const handlerStorage = editor.storage
71 | .linkBubbleMenuHandler as LinkBubbleMenuHandlerStorage;
72 |
73 | // Update the menu step if the bubble menu state has changed
74 | const menuState = handlerStorage.state;
75 |
76 | let linkMenuContent = null;
77 | if (menuState === LinkMenuState.VIEW_LINK_DETAILS) {
78 | linkMenuContent = (
79 | {
84 | // Remove the link and place the cursor at the end of the link (which
85 | // requires "focus" to take effect)
86 | editor
87 | .chain()
88 | .unsetLink()
89 | .setTextSelection(editor.state.selection.to)
90 | .focus()
91 | .run();
92 | }}
93 | labels={labels}
94 | />
95 | );
96 | } else if (menuState === LinkMenuState.EDIT_LINK) {
97 | linkMenuContent = (
98 | {
102 | editor
103 | .chain()
104 | // Make sure if we're updating a link, we update the link for the
105 | // full link "mark"
106 | .extendMarkRange("link")
107 | // Update the link href and its text content
108 | .insertContent({
109 | type: "text",
110 | marks: [
111 | {
112 | type: "link",
113 | attrs: {
114 | href: link,
115 | },
116 | },
117 | ],
118 | text: text,
119 | })
120 | // Note that as of "@tiptap/extension-link" 2.0.0-beta.37 when
121 | // `autolink` is on (which we want), adding the link mark directly
122 | // via `insertContent` above wasn't sufficient for the link mark to
123 | // be applied (though specifying it above is still necessary), so we
124 | // insert the content there and call `setLink` separately here.
125 | // Unclear why this separate command is necessary, but it does the
126 | // trick.
127 | .setLink({
128 | href: link,
129 | })
130 | // Place the cursor at the end of the link (which requires "focus"
131 | // to take effect)
132 | .focus()
133 | .run();
134 |
135 | editor.commands.closeLinkBubbleMenu();
136 | }}
137 | labels={labels}
138 | />
139 | );
140 | }
141 |
142 | return (
143 |
149 | {linkMenuContent}
150 |
151 | );
152 | }
153 |
--------------------------------------------------------------------------------