├── .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 | 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 | 78 | 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 | 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 | 90 | 91 | 92 | 95 | 96 | 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 | --------------------------------------------------------------------------------