diff --git a/.gitignore b/.gitignore index 61030b7716..ef4e11d35d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ # dependencies node_modules dist -types +packages/*/types +examples/*/types /.pnp .pnp.js diff --git a/examples/editor/package.json b/examples/editor/package.json index 9ad54b2fe1..b8145f8c85 100644 --- a/examples/editor/package.json +++ b/examples/editor/package.json @@ -10,12 +10,13 @@ }, "dependencies": { "@blocknote/core": "^0.1.2", - "react": "^17.0.2", - "react-dom": "^17.0.2" + "@blocknote/react": "^0.1.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { - "@types/react": "^17.0.39", - "@types/react-dom": "^17.0.12", + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.9", "@vitejs/plugin-react": "^1.0.7", "eslint": "^8.10.0", "eslint-config-react-app": "^7.0.0", diff --git a/examples/editor/src/App.tsx b/examples/editor/src/App.tsx index 20a635af92..1c6c5c7f4e 100644 --- a/examples/editor/src/App.tsx +++ b/examples/editor/src/App.tsx @@ -1,6 +1,6 @@ // import logo from './logo.svg' -import { EditorContent, useEditor } from "@blocknote/core"; import "@blocknote/core/style.css"; +import { BlockNoteView, useBlockNote } from "@blocknote/react"; import { Editor } from "@tiptap/core"; import styles from "./App.module.css"; @@ -8,7 +8,7 @@ type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: Editor }; function App() { - const editor = useEditor({ + const editor = useBlockNote({ onUpdate: ({ editor }) => { console.log(editor.getJSON()); (window as WindowWithProseMirror).ProseMirror = editor; // Give tests a way to get editor instance @@ -21,7 +21,7 @@ function App() { }, }); - return ; + return ; } export default App; diff --git a/examples/editor/src/main.tsx b/examples/editor/src/main.tsx index 3a568f9845..0808f1a4d8 100644 --- a/examples/editor/src/main.tsx +++ b/examples/editor/src/main.tsx @@ -1,14 +1,13 @@ import React from "react"; -import ReactDOM from "react-dom"; +import { createRoot } from "react-dom/client"; import App from "./App"; import "./index.css"; window.React = React; -window.ReactDOM = ReactDOM; -ReactDOM.render( +const root = createRoot(document.getElementById("root")!); +root.render( - , - document.getElementById("root") + ); diff --git a/examples/editor/vite.config.ts b/examples/editor/vite.config.ts index 9c31e3ff42..d135de28dc 100644 --- a/examples/editor/vite.config.ts +++ b/examples/editor/vite.config.ts @@ -8,17 +8,24 @@ export default defineConfig((conf) => ({ optimizeDeps: { // link: ['vite-react-ts-components'], }, + build: { + sourcemap: true, + }, resolve: { alias: conf.command === "build" ? {} : { - // Comment out the line below to load a built version of blocknote + // Comment out the lines below to load a built version of blocknote // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, "../../packages/core/src/" ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/" + ), }, }, })); diff --git a/examples/vanilla/.gitignore b/examples/vanilla/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/examples/vanilla/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/vanilla/README.md b/examples/vanilla/README.md new file mode 100644 index 0000000000..868cba437c --- /dev/null +++ b/examples/vanilla/README.md @@ -0,0 +1,3 @@ +# Vanilla editor example + +This is an example client application that consumes @blocknote/core. diff --git a/examples/vanilla/index.html b/examples/vanilla/index.html new file mode 100644 index 0000000000..dc902f4b5b --- /dev/null +++ b/examples/vanilla/index.html @@ -0,0 +1,13 @@ + + + + + + + BlockNote demo vanilla js + + +
+ + + diff --git a/examples/vanilla/package.json b/examples/vanilla/package.json new file mode 100644 index 0000000000..9f5a7e0cdf --- /dev/null +++ b/examples/vanilla/package.json @@ -0,0 +1,30 @@ +{ + "name": "@blocknote/example-vanilla", + "private": true, + "version": "0.1.2", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint src --max-warnings 0" + }, + "dependencies": { + "@blocknote/core": "^0.1.2" + }, + "devDependencies": { + "eslint": "^8.10.0", + "eslint-config-react-app": "^7.0.0", + "typescript": "^4.5.4", + "vite": "^3.0.5", + "vite-plugin-eslint": "^1.7.0" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ], + "rules": { + "curly": 1 + } + } +} diff --git a/examples/vanilla/src/index.css b/examples/vanilla/src/index.css new file mode 100644 index 0000000000..d8ab4ad4a3 --- /dev/null +++ b/examples/vanilla/src/index.css @@ -0,0 +1,10 @@ +html, +body, +#root { + height: 100%; +} + +.editor { + padding: 0 calc((100% - 731px) / 2); + height: 100%; +} diff --git a/examples/vanilla/src/main.tsx b/examples/vanilla/src/main.tsx new file mode 100644 index 0000000000..2929893849 --- /dev/null +++ b/examples/vanilla/src/main.tsx @@ -0,0 +1,31 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import "./index.css"; +import { blockSideMenuFactory } from "./ui/blockSideMenuFactory"; +import { formattingToolbarFactory } from "./ui/formattingToolbarFactory"; +import { hyperlinkToolbarFactory } from "./ui/hyperlinkToolbarFactory"; +import { slashMenuFactory } from "./ui/slashMenuFactory"; + +const editor = new BlockNoteEditor({ + element: document.getElementById("root")!, + uiFactories: { + // Create an example formatting toolbar which just consists of a bold toggle + formattingToolbarFactory, + // Create an example menu for hyperlinks + hyperlinkToolbarFactory, + // Create an example menu for the /-menu + slashMenuFactory: slashMenuFactory, + // Create an example menu for when a block is hovered + blockSideMenuFactory, + }, + onUpdate: ({ editor }) => { + console.log(editor.getJSON()); + (window as any).ProseMirror = editor; // Give tests a way to get editor instance + }, + editorProps: { + attributes: { + class: "editor", + }, + }, +}); + +console.log("editor created", editor); diff --git a/examples/vanilla/src/ui/blockSideMenuFactory.ts b/examples/vanilla/src/ui/blockSideMenuFactory.ts new file mode 100644 index 0000000000..5c71b94f46 --- /dev/null +++ b/examples/vanilla/src/ui/blockSideMenuFactory.ts @@ -0,0 +1,46 @@ +import { BlockSideMenuFactory } from "@blocknote/core"; +import { createButton } from "./util"; + +/** + * This menu is drawn next to a block, when it's hovered over + * It renders a drag handle and + button to create a new block + */ +export const blockSideMenuFactory: BlockSideMenuFactory = (staticParams) => { + const container = document.createElement("div"); + container.style.background = "gray"; + container.style.position = "absolute"; + container.style.padding = "10px"; + container.style.opacity = "0.8"; + const addBtn = createButton("+", () => { + staticParams.addBlock(); + }); + container.appendChild(addBtn); + + const dragBtn = createButton("::", () => { + // TODO: render a submenu with a delete option that calls "props.deleteBlock" + }); + + dragBtn.addEventListener("dragstart", staticParams.blockDragStart); + dragBtn.addEventListener("dragend", staticParams.blockDragEnd); + container.style.display = "none"; + container.appendChild(dragBtn); + + document.body.appendChild(container); + + return { + element: container, + render: (params, isHidden) => { + if (isHidden) { + container.style.display = "block"; + } + + console.log("show blockmenu", params); + container.style.top = params.blockBoundingBox.y + "px"; + container.style.left = + params.blockBoundingBox.x - container.offsetWidth + "px"; + }, + hide: () => { + container.style.display = "none"; + }, + }; +}; diff --git a/examples/vanilla/src/ui/formattingToolbarFactory.ts b/examples/vanilla/src/ui/formattingToolbarFactory.ts new file mode 100644 index 0000000000..b78e4fa94d --- /dev/null +++ b/examples/vanilla/src/ui/formattingToolbarFactory.ts @@ -0,0 +1,45 @@ +import { FormattingToolbarFactory } from "@blocknote/core"; +import { createButton } from "./util"; + +/** + * This menu is drawn when a piece of text is selected. We can use it to change formatting options + * such as bold, italic, indentation, etc. + */ +export const formattingToolbarFactory: FormattingToolbarFactory = ( + staticParams +) => { + const container = document.createElement("div"); + container.style.background = "gray"; + container.style.position = "absolute"; + container.style.padding = "10px"; + container.style.opacity = "0.8"; + const boldBtn = createButton("set bold", () => { + staticParams.toggleBold(); + }); + container.appendChild(boldBtn); + + const linkBtn = createButton("set link", () => { + staticParams.setHyperlink("https://www.google.com"); + }); + + container.appendChild(boldBtn); + container.appendChild(linkBtn); + container.style.display = "none"; + document.body.appendChild(container); + + return { + element: container, + render: (params, isHidden) => { + if (isHidden) { + container.style.display = "block"; + } + + boldBtn.text = params.boldIsActive ? "unset bold" : "set bold"; + container.style.top = params.selectionBoundingBox.y + "px"; + container.style.left = params.selectionBoundingBox.x + "px"; + }, + hide: () => { + container.style.display = "none"; + }, + }; +}; diff --git a/examples/vanilla/src/ui/hyperlinkToolbarFactory.ts b/examples/vanilla/src/ui/hyperlinkToolbarFactory.ts new file mode 100644 index 0000000000..bfa7d67984 --- /dev/null +++ b/examples/vanilla/src/ui/hyperlinkToolbarFactory.ts @@ -0,0 +1,54 @@ +import { HyperlinkToolbarFactory } from "@blocknote/core"; +import { createButton } from "./util"; + +/** + * This menu is drawn when the cursor is moved to a hyperlink (using the keyboard), + * or when the mouse is hovering over a hyperlink + */ +export const hyperlinkToolbarFactory: HyperlinkToolbarFactory = ( + staticParams +) => { + const container = document.createElement("div"); + container.style.background = "gray"; + container.style.position = "absolute"; + container.style.padding = "10px"; + container.style.opacity = "0.8"; + + let url = ""; + let text = ""; + + const editBtn = createButton("edit", () => { + const newUrl = prompt("new url") || url; + staticParams.editHyperlink(newUrl, text); + }); + + container.appendChild(editBtn); + + const removeBtn = createButton("remove", () => { + staticParams.deleteHyperlink(); + }); + + container.appendChild(editBtn); + container.appendChild(removeBtn); + container.style.display = "none"; + document.body.appendChild(container); + + return { + element: container, + render: (params, isHidden) => { + if (isHidden) { + url = params.url; + text = params.text; + + container.style.display = "block"; + } + + console.log("show", params); + container.style.top = params.boundingBox.y + "px"; + container.style.left = params.boundingBox.x + "px"; + }, + hide: () => { + container.style.display = "none"; + }, + }; +}; diff --git a/examples/vanilla/src/ui/slashMenuFactory.ts b/examples/vanilla/src/ui/slashMenuFactory.ts new file mode 100644 index 0000000000..6dca65bea4 --- /dev/null +++ b/examples/vanilla/src/ui/slashMenuFactory.ts @@ -0,0 +1,60 @@ +import { SlashMenuItem, SuggestionsMenuFactory } from "@blocknote/core"; +import { createButton } from "./util"; + +/** + * This menu is drawn when the cursor is moved to a hyperlink (using the keyboard), + * or when the mouse is hovering over a hyperlink + */ +export const slashMenuFactory: SuggestionsMenuFactory = ( + staticParams +) => { + const container = document.createElement("div"); + container.style.background = "gray"; + container.style.position = "absolute"; + container.style.padding = "10px"; + container.style.opacity = "0.8"; + container.style.display = "none"; + document.body.appendChild(container); + + function updateItems( + items: SlashMenuItem[], + onClick: (item: SlashMenuItem) => void, + selected: number + ) { + container.innerHTML = ""; + const domItems = items.map((val, i) => { + const element = createButton(val.name, () => { + onClick(val); + }); + element.style.display = "block"; + if (selected === i) { + element.style.fontWeight = "bold"; + } + return element; + }); + container.append(...domItems); + return domItems; + } + + return { + element: container, + render: (params, isHidden) => { + updateItems( + params.items, + staticParams.itemCallback, + params.selectedItemIndex + ); + + if (isHidden) { + container.style.display = "block"; + } + + console.log("show", params); + container.style.top = params.queryStartBoundingBox.y + "px"; + container.style.left = params.queryStartBoundingBox.x + "px"; + }, + hide: () => { + container.style.display = "none"; + }, + }; +}; diff --git a/examples/vanilla/src/ui/util.ts b/examples/vanilla/src/ui/util.ts new file mode 100644 index 0000000000..74e4c55eb1 --- /dev/null +++ b/examples/vanilla/src/ui/util.ts @@ -0,0 +1,11 @@ +export function createButton(text: string, onClick: () => void) { + const element = document.createElement("a"); + element.href = "#"; + element.text = text; + element.style.margin = "10px"; + element.addEventListener("click", (e) => { + onClick(); + e.preventDefault(); + }); + return element; +} diff --git a/examples/vanilla/src/vite-env.d.ts b/examples/vanilla/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/vanilla/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/vanilla/tsconfig.json b/examples/vanilla/tsconfig.json new file mode 100644 index 0000000000..9387cb5ab6 --- /dev/null +++ b/examples/vanilla/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + // "paths": { + // "@blocknote/core": ["../../packages/core/src"] + // } + }, + "include": ["src"], + "references": [ + { "path": "./tsconfig.node.json" } + // { "path": "../../packages/core/tsconfig.json" } + ] +} diff --git a/examples/vanilla/tsconfig.node.json b/examples/vanilla/tsconfig.node.json new file mode 100644 index 0000000000..e993792cb1 --- /dev/null +++ b/examples/vanilla/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "composite": true, + "module": "esnext", + "moduleResolution": "node" + }, + "include": ["vite.config.ts"] +} diff --git a/examples/vanilla/vite.config.ts b/examples/vanilla/vite.config.ts new file mode 100644 index 0000000000..fc8bd4a83d --- /dev/null +++ b/examples/vanilla/vite.config.ts @@ -0,0 +1,26 @@ +import * as path from "path"; +import { defineConfig } from "vite"; +// import eslintPlugin from "vite-plugin-eslint"; +// https://vitejs.dev/config/ +export default defineConfig((conf) => ({ + plugins: [], + optimizeDeps: { + // link: ['vite-react-ts-components'], + }, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" + ? {} + : { + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/" + ), + }, + }, +})); diff --git a/package-lock.json b/package-lock.json index 1b635d9666..2af04677d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,12 +21,13 @@ "version": "0.1.2", "dependencies": { "@blocknote/core": "^0.1.2", - "react": "^17.0.2", - "react-dom": "^17.0.2" + "@blocknote/react": "^0.1.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { - "@types/react": "^17.0.39", - "@types/react-dom": "^17.0.12", + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.9", "@vitejs/plugin-react": "^1.0.7", "eslint": "^8.10.0", "eslint-config-react-app": "^7.0.0", @@ -180,6 +181,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz", "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==", + "dev": true, "dependencies": { "@babel/types": "^7.18.6" }, @@ -2060,6 +2062,10 @@ "resolved": "examples/editor", "link": true }, + "node_modules/@blocknote/react": { + "resolved": "packages/react", + "link": true + }, "node_modules/@emotion/babel-plugin": { "version": "11.10.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.10.5.tgz", @@ -2109,14 +2115,6 @@ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.0.tgz", "integrity": "sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ==" }, - "node_modules/@emotion/is-prop-valid": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.0.tgz", - "integrity": "sha512-3aDpDprjM0AwaxGE09bOPkNxHpBd+kA6jty3RnaEXdweX1DF1U3VQpPYb0g1IStAuK7SVQ1cy+bNBBKp4W3Fjg==", - "dependencies": { - "@emotion/memoize": "^0.8.0" - } - }, "node_modules/@emotion/memoize": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.0.tgz", @@ -2167,11 +2165,6 @@ "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.1.tgz", "integrity": "sha512-zxRBwl93sHMsOj4zs+OslQKg/uhF38MB+OMKoCrVuS0nyTkqnau+BM3WGEoOptg9Oz45T/aIGs1qbVAsEFo3nA==" }, - "node_modules/@emotion/stylis": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", - "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==" - }, "node_modules/@emotion/unitless": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.0.tgz", @@ -2252,24 +2245,24 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.0.1.tgz", - "integrity": "sha512-bO37brCPfteXQfFY0DyNDGB3+IMe4j150KFQcgJ5aBP295p9nBGeHEs/p0czrRbtlHq4Px/yoPXO/+dOCcF4uA==" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.0.2.tgz", + "integrity": "sha512-Skfy0YS3NJ5nV9us0uuPN0HDk1Q4edljaOhRBJGDWs9EBa7ZVMYBHRFlhLvvmwEoaIM9BlH6QJFn9/uZg0bACg==" }, "node_modules/@floating-ui/dom": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.0.3.tgz", - "integrity": "sha512-6H1kwjkOZKabApNtXRiYHvMmYJToJ1DV7rQ3xc/WJpOABhQIOJJOdz2AOejj8X+gcybaFmBpisVTZxBZAM3V0w==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.0.6.tgz", + "integrity": "sha512-kt/tg1oip9OAH1xjCTcx1OpcUpu9rjDw3GKJ/rEhUqhO7QyJWfrHU0DpLTNsH67+JyFL5Kv9X1utsXwKFVtyEQ==", "dependencies": { - "@floating-ui/core": "^1.0.1" + "@floating-ui/core": "^1.0.2" } }, "node_modules/@floating-ui/react-dom": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.0.0.tgz", - "integrity": "sha512-uiOalFKPG937UCLm42RxjESTWUVpbbatvlphQAU6bsv+ence6IoVG8JOUZcy8eW81NkU+Idiwvx10WFLmR4MIg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.0.1.tgz", + "integrity": "sha512-UW0t1Gi8ikbDRr8cQPVcqIDMBwUEENe5V4wlHWdrJ5egFnRQFBV9JirauTBFI6S8sM1qFUC1i+qa3g87E6CLTw==", "dependencies": { - "@floating-ui/dom": "^1.0.0" + "@floating-ui/dom": "^1.0.5" }, "peerDependencies": { "react": ">=16.8.0", @@ -2277,9 +2270,9 @@ } }, "node_modules/@floating-ui/react-dom-interactions": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.10.2.tgz", - "integrity": "sha512-KhF+UN+MVqUx1bG1fe0aAiBl1hbz07Uin6UW70mxwUDhaGpitM16CYvGri1EqGY4hnWK8TQknDSP8iQFOxjhsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.10.3.tgz", + "integrity": "sha512-UEHqdnzyoiWNU5az/tAljr9iXFzN18DcvpMqW+/cXz4FEhDEB1ogLtWldOWCujLerPBnSRocADALafelOReMpw==", "dependencies": { "@floating-ui/react-dom": "^1.0.0", "aria-hidden": "^1.1.3" @@ -4224,10 +4217,12 @@ } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "2.0.0-beta.199", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.0.0-beta.199.tgz", - "integrity": "sha512-T3K8xoDbX6J62lhIUpclQoW/1XFt7yfI5DCoxtVWUeKaF+pG6kdsB3CPG5C/+AQVlz2jSIJmQuPf8RQFpQs+yg==", + "version": "2.0.0-beta.209", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.0.0-beta.209.tgz", + "integrity": "sha512-tceZAuDpy3J96uGyCzpJFD3fHABJDTJTq5E0hm+TRQT+eVGVqZI0PE3/4yVFgkCshioTuJq8veMDFcqNsSkKsQ==", "dependencies": { + "@tiptap/core": "^2.0.0-beta.209", + "lodash": "^4.17.21", "prosemirror-state": "^1.4.1", "prosemirror-view": "^1.28.2", "tippy.js": "^6.3.7" @@ -4237,7 +4232,37 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.0.0-beta.193" + "@tiptap/core": "^2.0.0-beta.193", + "prosemirror-state": "^1.4.1", + "prosemirror-view": "^1.28.2" + } + }, + "node_modules/@tiptap/extension-bubble-menu/node_modules/@tiptap/core": { + "version": "2.0.0-beta.209", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.0.0-beta.209.tgz", + "integrity": "sha512-DOOzfo2XKD5Qt2oEGW33/6ugwSnvpl4WbxtlKdPadLoApk6Kja3K1Eps3pihBgIGmo4tkctkCzmj8wNWS7KeWg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "prosemirror-commands": "^1.3.1", + "prosemirror-keymap": "^1.2.0", + "prosemirror-model": "^1.18.1", + "prosemirror-schema-list": "^1.2.2", + "prosemirror-state": "^1.4.1", + "prosemirror-transform": "^1.7.0", + "prosemirror-view": "^1.28.2" + } + }, + "node_modules/@tiptap/extension-bubble-menu/node_modules/prosemirror-view": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.29.1.tgz", + "integrity": "sha512-OhujVZSDsh0l0PyHNdfaBj6DBkbhYaCfbaxmTeFrMKd/eWS+G6IC+OAbmR9IsLC8Se1HSbphMaXnsXjupHL3UQ==", + "dependencies": { + "prosemirror-model": "^1.16.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" } }, "node_modules/@tiptap/extension-code": { @@ -4298,23 +4323,6 @@ "@tiptap/core": "^2.0.0-beta.193" } }, - "node_modules/@tiptap/extension-floating-menu": { - "version": "2.0.0-beta.199", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.0.0-beta.199.tgz", - "integrity": "sha512-ELjqnNbxW66uqg54zlP2b4EVYUWvT2WvHmeOXALzoLlNzbqUopIl3XNRsvU2Dv1W88C1UjKgnRZIkHKFE1X3CA==", - "dependencies": { - "prosemirror-state": "^1.4.1", - "prosemirror-view": "^1.28.2", - "tippy.js": "^6.3.7" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^2.0.0-beta.193" - } - }, "node_modules/@tiptap/extension-gapcursor": { "version": "2.0.0-beta.199", "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.0.0-beta.199.tgz", @@ -4450,12 +4458,12 @@ } }, "node_modules/@tiptap/react": { - "version": "2.0.0-beta.199", - "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.0.0-beta.199.tgz", - "integrity": "sha512-AjBtoavcJ7WOoEXdJlrVEdEv6xuI5UFnqB88w8NlORSkWbfQ3uuOm3A0LUZ92/SsBz6NISZbsFahMy0DYgGbIA==", + "version": "2.0.0-beta.209", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.0.0-beta.209.tgz", + "integrity": "sha512-iuJ+hgaSPETPMX39QpX6e0tZmFAj9azl0qGhNm6NNB1biCehkB4qMfcfwecWFRWVpZKG5UtjJvjJ3UZM167Jlg==", "dependencies": { - "@tiptap/extension-bubble-menu": "^2.0.0-beta.199", - "@tiptap/extension-floating-menu": "^2.0.0-beta.199", + "@tiptap/extension-bubble-menu": "^2.0.0-beta.209", + "@tiptap/extension-floating-menu": "^2.0.0-beta.209", "prosemirror-view": "^1.28.2" }, "funding": { @@ -4468,6 +4476,33 @@ "react-dom": "^17.0.0 || ^18.0.0" } }, + "node_modules/@tiptap/react/node_modules/@tiptap/extension-floating-menu": { + "version": "2.0.0-beta.209", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.0.0-beta.209.tgz", + "integrity": "sha512-m5ucAguqDxuOvNcsmvuSLcN8TMkbhFmiC6dTJOyaAGjGn6d8Ly6aZh+lEwU228TebM0TKHTp8Xob1cLjV4TGgg==", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0-beta.193", + "prosemirror-state": "^1.4.1", + "prosemirror-view": "^1.28.2" + } + }, + "node_modules/@tiptap/react/node_modules/prosemirror-view": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.29.1.tgz", + "integrity": "sha512-OhujVZSDsh0l0PyHNdfaBj6DBkbhYaCfbaxmTeFrMKd/eWS+G6IC+OAbmR9IsLC8Se1HSbphMaXnsXjupHL3UQ==", + "dependencies": { + "prosemirror-model": "^1.16.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -4493,16 +4528,6 @@ "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==", "dev": true }, - "node_modules/@types/hoist-non-react-statics": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", - "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", - "dev": true, - "dependencies": { - "@types/react": "*", - "hoist-non-react-statics": "^3.3.0" - } - }, "node_modules/@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -4557,9 +4582,9 @@ "devOptional": true }, "node_modules/@types/react": { - "version": "17.0.52", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.52.tgz", - "integrity": "sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A==", + "version": "18.0.25", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.25.tgz", + "integrity": "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g==", "devOptional": true, "dependencies": { "@types/prop-types": "*", @@ -4568,12 +4593,12 @@ } }, "node_modules/@types/react-dom": { - "version": "17.0.18", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.18.tgz", - "integrity": "sha512-rLVtIfbwyur2iFKykP2w0pl/1unw26b5td16d5xMgp7/yjTHomkyxPYChFoCr/FtEX1lN9wY6lFj1qvKdS5kDw==", + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.9.tgz", + "integrity": "sha512-qnVvHxASt/H7i+XG1U1xMiY5t+IHcPGUK7TDMDzom08xa7e86eCeKOiLZezwCKVxJn6NEiiy2ekgX8aQssjIKg==", "dev": true, "dependencies": { - "@types/react": "^17" + "@types/react": "*" } }, "node_modules/@types/scheduler": { @@ -4588,17 +4613,6 @@ "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", "dev": true }, - "node_modules/@types/styled-components": { - "version": "5.1.26", - "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.26.tgz", - "integrity": "sha512-KuKJ9Z6xb93uJiIyxo/+ksS7yLjS1KzG6iv5i78dhVg/X3u5t1H7juRWqVmodIdz6wGVaIApo1u01kmFRdJHVw==", - "dev": true, - "dependencies": { - "@types/hoist-non-react-statics": "*", - "@types/react": "*", - "csstype": "^3.0.2" - } - }, "node_modules/@types/uuid": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", @@ -5091,9 +5105,9 @@ "dev": true }, "node_modules/aria-hidden": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.1.tgz", - "integrity": "sha512-PN344VAf9j1EAi+jyVHOJ8XidQdPVssGco39eNcsGdM4wcsILtxrKLkbuiMfLWYROK1FjRQasMWCBttrhjnr6A==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.2.tgz", + "integrity": "sha512-6y/ogyDTk/7YAe91T3E2PR1ALVKyM2QbTio5HwM+N1Q6CMlCKhvClyIjkckBswa0f2xJhjsfzIGa1yVSe1UMVA==", "dependencies": { "tslib": "^2.0.0" }, @@ -5332,26 +5346,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/babel-plugin-styled-components": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.0.7.tgz", - "integrity": "sha512-i7YhvPgVqRKfoQ66toiZ06jPNA3p6ierpfUuEWxNF+fV27Uv5gxBkf8KZLHUCc1nFA9j6+80pYoIpqCeyW3/bA==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.16.0", - "@babel/helper-module-imports": "^7.16.0", - "babel-plugin-syntax-jsx": "^6.18.0", - "lodash": "^4.17.11", - "picomatch": "^2.3.0" - }, - "peerDependencies": { - "styled-components": ">= 2" - } - }, - "node_modules/babel-plugin-syntax-jsx": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", - "integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==" - }, "node_modules/babel-plugin-transform-react-remove-prop-types": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz", @@ -5633,14 +5627,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/camelize": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", - "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001426", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001426.tgz", @@ -6161,24 +6147,6 @@ "node": ">= 8" } }, - "node_modules/css-color-keywords": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", - "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", - "engines": { - "node": ">=4" - } - }, - "node_modules/css-to-react-native": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.0.0.tgz", - "integrity": "sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ==", - "dependencies": { - "camelize": "^1.0.0", - "css-color-keywords": "^1.0.0", - "postcss-value-parser": "^4.0.2" - } - }, "node_modules/csstype": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", @@ -8252,6 +8220,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "peer": true, "dependencies": { "react-is": "^16.7.0" } @@ -8259,7 +8228,8 @@ "node_modules/hoist-non-react-statics/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "peer": true }, "node_modules/hosted-git-info": { "version": "4.1.0", @@ -10603,6 +10573,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -11291,6 +11262,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -11410,11 +11382,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11679,44 +11646,36 @@ } }, "node_modules/react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "dependencies": { "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" + "scheduler": "^0.23.0" }, "peerDependencies": { - "react": "17.0.2" + "react": "^18.2.0" } }, "node_modules/react-icons": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.6.0.tgz", - "integrity": "sha512-rR/L9m9340yO8yv1QT1QurxWQvWpbNHqVX0fzMln2HEb9TEIrQRGsqiNFQfiv9/JEUbyHmHPlNTB2LWm2Ttz0g==", + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.7.1.tgz", + "integrity": "sha512-yHd3oKGMgm7zxo3EA7H2n7vxSoiGmHk5t6Ou4bXsfcgWyhfDKMpyKfhHR6Bjnn63c+YXBLBPUql9H4wPJM6sXw==", "peerDependencies": { "react": "*" } }, - "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "peer": true - }, "node_modules/react-refresh": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz", @@ -12385,12 +12344,11 @@ "dev": true }, "node_modules/scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" } }, "node_modules/semver": { @@ -12438,11 +12396,6 @@ "node": ">=8" } }, - "node_modules/shallowequal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -12791,60 +12744,6 @@ "node": ">=4" } }, - "node_modules/styled-components": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.6.tgz", - "integrity": "sha512-hGTZquGAaTqhGWldX7hhfzjnIYBZ0IXQXkCYdvF1Sq3DsUaLx6+NTHC5Jj1ooM2F68sBiVz3lvhfwQs/S3l6qg==", - "hasInstallScript": true, - "dependencies": { - "@babel/helper-module-imports": "^7.0.0", - "@babel/traverse": "^7.4.5", - "@emotion/is-prop-valid": "^1.1.0", - "@emotion/stylis": "^0.8.4", - "@emotion/unitless": "^0.7.4", - "babel-plugin-styled-components": ">= 1.12.0", - "css-to-react-native": "^3.0.0", - "hoist-non-react-statics": "^3.0.0", - "shallowequal": "^1.1.0", - "supports-color": "^5.5.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/styled-components" - }, - "peerDependencies": { - "react": ">= 16.8.0", - "react-dom": ">= 16.8.0", - "react-is": ">= 16.8.0" - } - }, - "node_modules/styled-components/node_modules/@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" - }, - "node_modules/styled-components/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/styled-components/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/stylis": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.1.3.tgz", @@ -13793,8 +13692,6 @@ "@emotion/cache": "^11.10.5", "@emotion/serialize": "^1.1.1", "@emotion/utils": "^1.2.0", - "@mantine/core": "^5.6.1", - "@tippyjs/react": "^4.2.6", "@tiptap/core": "^2.0.0-beta.182", "@tiptap/extension-bold": "^2.0.0-beta.28", "@tiptap/extension-code": "^2.0.0-beta.28", @@ -13811,22 +13708,14 @@ "@tiptap/extension-strike": "^2.0.0-beta.29", "@tiptap/extension-text": "^2.0.0-beta.17", "@tiptap/extension-underline": "^2.0.0-beta.25", - "@tiptap/react": "^2.0.0-beta.114", "lodash": "^4.17.21", "prosemirror-model": "1.18.1", "prosemirror-state": "1.4.1", "prosemirror-view": "1.26.2", - "react": "^17.0.2", - "react-dom": "^17.0.2", - "react-icons": "^4.3.1", - "styled-components": "^5.3.3", "uuid": "^8.3.2" }, "devDependencies": { "@types/lodash": "^4.14.179", - "@types/react": "^17.0.39", - "@types/react-dom": "^17.0.12", - "@types/styled-components": "^5.1.24", "@types/uuid": "^8.3.4", "eslint": "^8.10.0", "eslint-config-react-app": "^7.0.0", @@ -13835,6 +13724,32 @@ "vite": "^3.0.5", "vite-plugin-eslint": "^1.7.0" } + }, + "packages/react": { + "name": "@blocknote/react", + "version": "0.1.2", + "dependencies": { + "@blocknote/core": "^0.1.2", + "@mantine/core": "^5.6.1", + "@tippyjs/react": "^4.2.6", + "@tiptap/react": "^2.0.0-beta.207", + "react-icons": "^4.3.1" + }, + "devDependencies": { + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.9", + "@vitejs/plugin-react": "^1.0.7", + "eslint": "^8.10.0", + "eslint-config-react-app": "^7.0.0", + "prettier": "^2.7.1", + "typescript": "^4.5.4", + "vite": "^3.0.5", + "vite-plugin-eslint": "^1.7.0" + }, + "peerDependencies": { + "react": "^18", + "react-dom": "^18" + } } }, "dependencies": { @@ -13945,6 +13860,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz", "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==", + "dev": true, "requires": { "@babel/types": "^7.18.6" } @@ -15237,8 +15153,6 @@ "@emotion/cache": "^11.10.5", "@emotion/serialize": "^1.1.1", "@emotion/utils": "^1.2.0", - "@mantine/core": "^5.6.1", - "@tippyjs/react": "^4.2.6", "@tiptap/core": "^2.0.0-beta.182", "@tiptap/extension-bold": "^2.0.0-beta.28", "@tiptap/extension-code": "^2.0.0-beta.28", @@ -15255,11 +15169,7 @@ "@tiptap/extension-strike": "^2.0.0-beta.29", "@tiptap/extension-text": "^2.0.0-beta.17", "@tiptap/extension-underline": "^2.0.0-beta.25", - "@tiptap/react": "^2.0.0-beta.114", "@types/lodash": "^4.14.179", - "@types/react": "^17.0.39", - "@types/react-dom": "^17.0.12", - "@types/styled-components": "^5.1.24", "@types/uuid": "^8.3.4", "eslint": "^8.10.0", "eslint-config-react-app": "^7.0.0", @@ -15268,10 +15178,6 @@ "prosemirror-model": "1.18.1", "prosemirror-state": "1.4.1", "prosemirror-view": "1.26.2", - "react": "^17.0.2", - "react-dom": "^17.0.2", - "react-icons": "^4.3.1", - "styled-components": "^5.3.3", "typescript": "^4.5.4", "uuid": "^8.3.2", "vite": "^3.0.5", @@ -15282,13 +15188,33 @@ "version": "file:examples/editor", "requires": { "@blocknote/core": "^0.1.2", - "@types/react": "^17.0.39", - "@types/react-dom": "^17.0.12", + "@blocknote/react": "^0.1.2", + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.9", "@vitejs/plugin-react": "^1.0.7", "eslint": "^8.10.0", "eslint-config-react-app": "^7.0.0", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^4.5.4", + "vite": "^3.0.5", + "vite-plugin-eslint": "^1.7.0" + } + }, + "@blocknote/react": { + "version": "file:packages/react", + "requires": { + "@blocknote/core": "^0.1.2", + "@mantine/core": "^5.6.1", + "@tippyjs/react": "^4.2.6", + "@tiptap/react": "^2.0.0-beta.207", + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.9", + "@vitejs/plugin-react": "^1.0.7", + "eslint": "^8.10.0", + "eslint-config-react-app": "^7.0.0", + "prettier": "^2.7.1", + "react-icons": "^4.3.1", "typescript": "^4.5.4", "vite": "^3.0.5", "vite-plugin-eslint": "^1.7.0" @@ -15339,14 +15265,6 @@ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.0.tgz", "integrity": "sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ==" }, - "@emotion/is-prop-valid": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.0.tgz", - "integrity": "sha512-3aDpDprjM0AwaxGE09bOPkNxHpBd+kA6jty3RnaEXdweX1DF1U3VQpPYb0g1IStAuK7SVQ1cy+bNBBKp4W3Fjg==", - "requires": { - "@emotion/memoize": "^0.8.0" - } - }, "@emotion/memoize": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.0.tgz", @@ -15385,11 +15303,6 @@ "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.1.tgz", "integrity": "sha512-zxRBwl93sHMsOj4zs+OslQKg/uhF38MB+OMKoCrVuS0nyTkqnau+BM3WGEoOptg9Oz45T/aIGs1qbVAsEFo3nA==" }, - "@emotion/stylis": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", - "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==" - }, "@emotion/unitless": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.0.tgz", @@ -15444,30 +15357,30 @@ } }, "@floating-ui/core": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.0.1.tgz", - "integrity": "sha512-bO37brCPfteXQfFY0DyNDGB3+IMe4j150KFQcgJ5aBP295p9nBGeHEs/p0czrRbtlHq4Px/yoPXO/+dOCcF4uA==" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.0.2.tgz", + "integrity": "sha512-Skfy0YS3NJ5nV9us0uuPN0HDk1Q4edljaOhRBJGDWs9EBa7ZVMYBHRFlhLvvmwEoaIM9BlH6QJFn9/uZg0bACg==" }, "@floating-ui/dom": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.0.3.tgz", - "integrity": "sha512-6H1kwjkOZKabApNtXRiYHvMmYJToJ1DV7rQ3xc/WJpOABhQIOJJOdz2AOejj8X+gcybaFmBpisVTZxBZAM3V0w==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.0.6.tgz", + "integrity": "sha512-kt/tg1oip9OAH1xjCTcx1OpcUpu9rjDw3GKJ/rEhUqhO7QyJWfrHU0DpLTNsH67+JyFL5Kv9X1utsXwKFVtyEQ==", "requires": { - "@floating-ui/core": "^1.0.1" + "@floating-ui/core": "^1.0.2" } }, "@floating-ui/react-dom": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.0.0.tgz", - "integrity": "sha512-uiOalFKPG937UCLm42RxjESTWUVpbbatvlphQAU6bsv+ence6IoVG8JOUZcy8eW81NkU+Idiwvx10WFLmR4MIg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.0.1.tgz", + "integrity": "sha512-UW0t1Gi8ikbDRr8cQPVcqIDMBwUEENe5V4wlHWdrJ5egFnRQFBV9JirauTBFI6S8sM1qFUC1i+qa3g87E6CLTw==", "requires": { - "@floating-ui/dom": "^1.0.0" + "@floating-ui/dom": "^1.0.5" } }, "@floating-ui/react-dom-interactions": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.10.2.tgz", - "integrity": "sha512-KhF+UN+MVqUx1bG1fe0aAiBl1hbz07Uin6UW70mxwUDhaGpitM16CYvGri1EqGY4hnWK8TQknDSP8iQFOxjhsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.10.3.tgz", + "integrity": "sha512-UEHqdnzyoiWNU5az/tAljr9iXFzN18DcvpMqW+/cXz4FEhDEB1ogLtWldOWCujLerPBnSRocADALafelOReMpw==", "requires": { "@floating-ui/react-dom": "^1.0.0", "aria-hidden": "^1.1.3" @@ -17020,13 +16933,33 @@ "requires": {} }, "@tiptap/extension-bubble-menu": { - "version": "2.0.0-beta.199", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.0.0-beta.199.tgz", - "integrity": "sha512-T3K8xoDbX6J62lhIUpclQoW/1XFt7yfI5DCoxtVWUeKaF+pG6kdsB3CPG5C/+AQVlz2jSIJmQuPf8RQFpQs+yg==", + "version": "2.0.0-beta.209", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.0.0-beta.209.tgz", + "integrity": "sha512-tceZAuDpy3J96uGyCzpJFD3fHABJDTJTq5E0hm+TRQT+eVGVqZI0PE3/4yVFgkCshioTuJq8veMDFcqNsSkKsQ==", "requires": { + "@tiptap/core": "^2.0.0-beta.209", + "lodash": "^4.17.21", "prosemirror-state": "^1.4.1", "prosemirror-view": "^1.28.2", "tippy.js": "^6.3.7" + }, + "dependencies": { + "@tiptap/core": { + "version": "2.0.0-beta.209", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.0.0-beta.209.tgz", + "integrity": "sha512-DOOzfo2XKD5Qt2oEGW33/6ugwSnvpl4WbxtlKdPadLoApk6Kja3K1Eps3pihBgIGmo4tkctkCzmj8wNWS7KeWg==", + "requires": {} + }, + "prosemirror-view": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.29.1.tgz", + "integrity": "sha512-OhujVZSDsh0l0PyHNdfaBj6DBkbhYaCfbaxmTeFrMKd/eWS+G6IC+OAbmR9IsLC8Se1HSbphMaXnsXjupHL3UQ==", + "requires": { + "prosemirror-model": "^1.16.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + } } }, "@tiptap/extension-code": { @@ -17060,16 +16993,6 @@ "prosemirror-dropcursor": "1.5.0" } }, - "@tiptap/extension-floating-menu": { - "version": "2.0.0-beta.199", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.0.0-beta.199.tgz", - "integrity": "sha512-ELjqnNbxW66uqg54zlP2b4EVYUWvT2WvHmeOXALzoLlNzbqUopIl3XNRsvU2Dv1W88C1UjKgnRZIkHKFE1X3CA==", - "requires": { - "prosemirror-state": "^1.4.1", - "prosemirror-view": "^1.28.2", - "tippy.js": "^6.3.7" - } - }, "@tiptap/extension-gapcursor": { "version": "2.0.0-beta.199", "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.0.0-beta.199.tgz", @@ -17141,13 +17064,33 @@ "requires": {} }, "@tiptap/react": { - "version": "2.0.0-beta.199", - "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.0.0-beta.199.tgz", - "integrity": "sha512-AjBtoavcJ7WOoEXdJlrVEdEv6xuI5UFnqB88w8NlORSkWbfQ3uuOm3A0LUZ92/SsBz6NISZbsFahMy0DYgGbIA==", + "version": "2.0.0-beta.209", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.0.0-beta.209.tgz", + "integrity": "sha512-iuJ+hgaSPETPMX39QpX6e0tZmFAj9azl0qGhNm6NNB1biCehkB4qMfcfwecWFRWVpZKG5UtjJvjJ3UZM167Jlg==", "requires": { - "@tiptap/extension-bubble-menu": "^2.0.0-beta.199", - "@tiptap/extension-floating-menu": "^2.0.0-beta.199", + "@tiptap/extension-bubble-menu": "^2.0.0-beta.209", + "@tiptap/extension-floating-menu": "^2.0.0-beta.209", "prosemirror-view": "^1.28.2" + }, + "dependencies": { + "@tiptap/extension-floating-menu": { + "version": "2.0.0-beta.209", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.0.0-beta.209.tgz", + "integrity": "sha512-m5ucAguqDxuOvNcsmvuSLcN8TMkbhFmiC6dTJOyaAGjGn6d8Ly6aZh+lEwU228TebM0TKHTp8Xob1cLjV4TGgg==", + "requires": { + "tippy.js": "^6.3.7" + } + }, + "prosemirror-view": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.29.1.tgz", + "integrity": "sha512-OhujVZSDsh0l0PyHNdfaBj6DBkbhYaCfbaxmTeFrMKd/eWS+G6IC+OAbmR9IsLC8Se1HSbphMaXnsXjupHL3UQ==", + "requires": { + "prosemirror-model": "^1.16.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + } } }, "@tootallnate/once": { @@ -17172,16 +17115,6 @@ "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==", "dev": true }, - "@types/hoist-non-react-statics": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", - "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", - "dev": true, - "requires": { - "@types/react": "*", - "hoist-non-react-statics": "^3.3.0" - } - }, "@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -17236,9 +17169,9 @@ "devOptional": true }, "@types/react": { - "version": "17.0.52", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.52.tgz", - "integrity": "sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A==", + "version": "18.0.25", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.25.tgz", + "integrity": "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g==", "devOptional": true, "requires": { "@types/prop-types": "*", @@ -17247,12 +17180,12 @@ } }, "@types/react-dom": { - "version": "17.0.18", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.18.tgz", - "integrity": "sha512-rLVtIfbwyur2iFKykP2w0pl/1unw26b5td16d5xMgp7/yjTHomkyxPYChFoCr/FtEX1lN9wY6lFj1qvKdS5kDw==", + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.9.tgz", + "integrity": "sha512-qnVvHxASt/H7i+XG1U1xMiY5t+IHcPGUK7TDMDzom08xa7e86eCeKOiLZezwCKVxJn6NEiiy2ekgX8aQssjIKg==", "dev": true, "requires": { - "@types/react": "^17" + "@types/react": "*" } }, "@types/scheduler": { @@ -17267,17 +17200,6 @@ "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", "dev": true }, - "@types/styled-components": { - "version": "5.1.26", - "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.26.tgz", - "integrity": "sha512-KuKJ9Z6xb93uJiIyxo/+ksS7yLjS1KzG6iv5i78dhVg/X3u5t1H7juRWqVmodIdz6wGVaIApo1u01kmFRdJHVw==", - "dev": true, - "requires": { - "@types/hoist-non-react-statics": "*", - "@types/react": "*", - "csstype": "^3.0.2" - } - }, "@types/uuid": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", @@ -17608,9 +17530,9 @@ "dev": true }, "aria-hidden": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.1.tgz", - "integrity": "sha512-PN344VAf9j1EAi+jyVHOJ8XidQdPVssGco39eNcsGdM4wcsILtxrKLkbuiMfLWYROK1FjRQasMWCBttrhjnr6A==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.2.tgz", + "integrity": "sha512-6y/ogyDTk/7YAe91T3E2PR1ALVKyM2QbTio5HwM+N1Q6CMlCKhvClyIjkckBswa0f2xJhjsfzIGa1yVSe1UMVA==", "requires": { "tslib": "^2.0.0" } @@ -17787,23 +17709,6 @@ "@babel/helper-define-polyfill-provider": "^0.3.3" } }, - "babel-plugin-styled-components": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.0.7.tgz", - "integrity": "sha512-i7YhvPgVqRKfoQ66toiZ06jPNA3p6ierpfUuEWxNF+fV27Uv5gxBkf8KZLHUCc1nFA9j6+80pYoIpqCeyW3/bA==", - "requires": { - "@babel/helper-annotate-as-pure": "^7.16.0", - "@babel/helper-module-imports": "^7.16.0", - "babel-plugin-syntax-jsx": "^6.18.0", - "lodash": "^4.17.11", - "picomatch": "^2.3.0" - } - }, - "babel-plugin-syntax-jsx": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", - "integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==" - }, "babel-plugin-transform-react-remove-prop-types": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz", @@ -18010,11 +17915,6 @@ "quick-lru": "^4.0.1" } }, - "camelize": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", - "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==" - }, "caniuse-lite": { "version": "1.0.30001426", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001426.tgz", @@ -18414,21 +18314,6 @@ "which": "^2.0.1" } }, - "css-color-keywords": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", - "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==" - }, - "css-to-react-native": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.0.0.tgz", - "integrity": "sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ==", - "requires": { - "camelize": "^1.0.0", - "css-color-keywords": "^1.0.0", - "postcss-value-parser": "^4.0.2" - } - }, "csstype": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", @@ -19904,6 +19789,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "peer": true, "requires": { "react-is": "^16.7.0" }, @@ -19911,7 +19797,8 @@ "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "peer": true } } }, @@ -21691,7 +21578,8 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true }, "object-inspect": { "version": "1.12.2", @@ -22190,7 +22078,8 @@ "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true }, "pify": { "version": "5.0.0", @@ -22263,11 +22152,6 @@ "source-map-js": "^1.0.2" } }, - "postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" - }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -22486,36 +22370,28 @@ "dev": true }, "react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" } }, "react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "requires": { "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" + "scheduler": "^0.23.0" } }, "react-icons": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.6.0.tgz", - "integrity": "sha512-rR/L9m9340yO8yv1QT1QurxWQvWpbNHqVX0fzMln2HEb9TEIrQRGsqiNFQfiv9/JEUbyHmHPlNTB2LWm2Ttz0g==", + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.7.1.tgz", + "integrity": "sha512-yHd3oKGMgm7zxo3EA7H2n7vxSoiGmHk5t6Ou4bXsfcgWyhfDKMpyKfhHR6Bjnn63c+YXBLBPUql9H4wPJM6sXw==", "requires": {} }, - "react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "peer": true - }, "react-refresh": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz", @@ -23011,12 +22887,11 @@ "dev": true }, "scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" } }, "semver": { @@ -23054,11 +22929,6 @@ "kind-of": "^6.0.2" } }, - "shallowequal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" - }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -23329,43 +23199,6 @@ "through": "^2.3.4" } }, - "styled-components": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.6.tgz", - "integrity": "sha512-hGTZquGAaTqhGWldX7hhfzjnIYBZ0IXQXkCYdvF1Sq3DsUaLx6+NTHC5Jj1ooM2F68sBiVz3lvhfwQs/S3l6qg==", - "requires": { - "@babel/helper-module-imports": "^7.0.0", - "@babel/traverse": "^7.4.5", - "@emotion/is-prop-valid": "^1.1.0", - "@emotion/stylis": "^0.8.4", - "@emotion/unitless": "^0.7.4", - "babel-plugin-styled-components": ">= 1.12.0", - "css-to-react-native": "^3.0.0", - "hoist-non-react-statics": "^3.0.0", - "shallowequal": "^1.1.0", - "supports-color": "^5.5.0" - }, - "dependencies": { - "@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, "stylis": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.1.3.tgz", diff --git a/package.json b/package.json index 5f1970b7f7..3b492f67fb 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "postpublish": "rm -rf packages/core/README.md" }, "overrides": { - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "18.2.0", + "react-dom": "18.2.0", "prosemirror-view": "1.26.2" } } diff --git a/packages/core/package.json b/packages/core/package.json index 1cdfbd2953..df48c0880b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -49,8 +49,6 @@ "@emotion/cache": "^11.10.5", "@emotion/serialize": "^1.1.1", "@emotion/utils": "^1.2.0", - "@mantine/core": "^5.6.1", - "@tippyjs/react": "^4.2.6", "@tiptap/core": "^2.0.0-beta.182", "@tiptap/extension-bold": "^2.0.0-beta.28", "@tiptap/extension-code": "^2.0.0-beta.28", @@ -67,26 +65,14 @@ "@tiptap/extension-strike": "^2.0.0-beta.29", "@tiptap/extension-text": "^2.0.0-beta.17", "@tiptap/extension-underline": "^2.0.0-beta.25", - "@tiptap/react": "^2.0.0-beta.114", "lodash": "^4.17.21", "prosemirror-model": "1.18.1", "prosemirror-state": "1.4.1", "prosemirror-view": "1.26.2", - "react": "^17.0.2", - "react-dom": "^17.0.2", - "react-icons": "^4.3.1", - "styled-components": "^5.3.3", "uuid": "^8.3.2" }, - "overrides": { - "react-dom": "$react-dom", - "react": "$react" - }, "devDependencies": { "@types/lodash": "^4.14.179", - "@types/react": "^17.0.39", - "@types/react-dom": "^17.0.12", - "@types/styled-components": "^5.1.24", "@types/uuid": "^8.3.4", "eslint": "^8.10.0", "eslint-config-react-app": "^7.0.0", diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/BlockNoteEditor.ts new file mode 100644 index 0000000000..5ac5b7ef73 --- /dev/null +++ b/packages/core/src/BlockNoteEditor.ts @@ -0,0 +1,54 @@ +import { Editor, EditorOptions } from "@tiptap/core"; + +// import "./blocknote.css"; +import { getBlockNoteExtensions, UiFactories } from "./BlockNoteExtensions"; +import styles from "./editor.module.css"; + +export type BlockNoteEditorOptions = EditorOptions & { + enableBlockNoteExtensions: boolean; + disableHistoryExtension: boolean; + uiFactories: UiFactories; +}; + +const blockNoteOptions = { + enableInputRules: true, + enablePasteRules: true, + enableCoreExtensions: false, +}; + +export class BlockNoteEditor { + public readonly tiptapEditor: Editor & { contentComponent: any }; + + constructor(options: Partial = {}) { + const blockNoteExtensions = getBlockNoteExtensions( + options.uiFactories || {} + ); + + let extensions = options.disableHistoryExtension + ? blockNoteExtensions.filter((e) => e.name !== "history") + : blockNoteExtensions; + + const tiptapOptions = { + ...blockNoteOptions, + ...options, + extensions: + options.enableBlockNoteExtensions === false + ? options.extensions + : [...(options.extensions || []), ...extensions], + editorProps: { + attributes: { + ...(options.editorProps?.attributes || {}), + class: [ + styles.bnEditor, + styles.bnRoot, + (options.editorProps?.attributes as any)?.class || "", + ].join(" "), + }, + }, + }; + + this.tiptapEditor = new Editor(tiptapOptions) as Editor & { + contentComponent: any; + }; + } +} diff --git a/packages/core/src/BlockNoteExtensions.ts b/packages/core/src/BlockNoteExtensions.ts index d6b117146f..ea1ad8e75e 100644 --- a/packages/core/src/BlockNoteExtensions.ts +++ b/packages/core/src/BlockNoteExtensions.ts @@ -13,14 +13,20 @@ import Text from "@tiptap/extension-text"; import Underline from "@tiptap/extension-underline"; import { blocks } from "./extensions/Blocks"; import blockStyles from "./extensions/Blocks/nodes/Block.module.css"; -import { BubbleMenuExtension } from "./extensions/BubbleMenu/BubbleMenuExtension"; +import { FormattingToolbarExtension } from "./extensions/FormattingToolbar/FormattingToolbarExtension"; import { DraggableBlocksExtension } from "./extensions/DraggableBlocks/DraggableBlocksExtension"; -import HyperlinkMark from "./extensions/Hyperlinks/HyperlinkMark"; +import HyperlinkMark from "./extensions/HyperlinkToolbar/HyperlinkMark"; import { FixedParagraph } from "./extensions/Paragraph/FixedParagraph"; import { Placeholder } from "./extensions/Placeholder/PlaceholderExtension"; import SlashMenuExtension from "./extensions/SlashMenu"; import { TrailingNode } from "./extensions/TrailingNode/TrailingNodeExtension"; import UniqueID from "./extensions/UniqueID/UniqueID"; +import { FormattingToolbarFactory } from "./extensions/FormattingToolbar/FormattingToolbarFactoryTypes"; +import { HyperlinkToolbarFactory } from "./extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes"; +import { SuggestionsMenuFactory } from "./shared/plugins/suggestion/SuggestionsMenuFactoryTypes"; +import { BlockSideMenuFactory } from "./extensions/DraggableBlocks/BlockSideMenuFactoryTypes"; +import { Link } from "@tiptap/extension-link"; +import { SlashMenuItem } from "./extensions/SlashMenu/SlashMenuItem"; export const Document = Node.create({ name: "doc", @@ -28,10 +34,17 @@ export const Document = Node.create({ content: "block+", }); +export type UiFactories = Partial<{ + formattingToolbarFactory: FormattingToolbarFactory; + hyperlinkToolbarFactory: HyperlinkToolbarFactory; + slashMenuFactory: SuggestionsMenuFactory; + blockSideMenuFactory: BlockSideMenuFactory; +}>; + /** * Get all the Tiptap extensions BlockNote is configured with by default */ -export const getBlockNoteExtensions = () => { +export const getBlockNoteExtensions = (uiFactories: UiFactories) => { const ret: Extensions = [ extensions.ClipboardTextSerializer, extensions.Commands, @@ -65,19 +78,51 @@ export const getBlockNoteExtensions = () => { Italic, Strike, Underline, - HyperlinkMark, FixedParagraph, // custom blocks: ...blocks, - DraggableBlocksExtension, + DropCursor.configure({ width: 5, color: "#ddeeff" }), - BubbleMenuExtension, History, // This needs to be at the bottom of this list, because Key events (such as enter, when selecting a /command), // should be handled before Enter handlers in other components like splitListItem - SlashMenuExtension, TrailingNode, ]; + + if (uiFactories.blockSideMenuFactory) { + ret.push( + DraggableBlocksExtension.configure({ + blockSideMenuFactory: uiFactories.blockSideMenuFactory, + }) + ); + } + + if (uiFactories.formattingToolbarFactory) { + ret.push( + FormattingToolbarExtension.configure({ + formattingToolbarFactory: uiFactories.formattingToolbarFactory, + }) + ); + } + + if (uiFactories.hyperlinkToolbarFactory) { + ret.push( + HyperlinkMark.configure({ + hyperlinkToolbarFactory: uiFactories.hyperlinkToolbarFactory, + }) + ); + } else { + ret.push(Link); + } + + if (uiFactories.slashMenuFactory) { + ret.push( + SlashMenuExtension.configure({ + slashMenuFactory: uiFactories.slashMenuFactory, + }) + ); + } + return ret; }; diff --git a/packages/core/src/assets/fonts-inter.css b/packages/core/src/assets/fonts-inter.css new file mode 100644 index 0000000000..a074dfe49d --- /dev/null +++ b/packages/core/src/assets/fonts-inter.css @@ -0,0 +1,92 @@ +/* Generated using https://google-webfonts-helper.herokuapp.com/fonts/inter?subsets=latin */ + +/* inter-100 - latin */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 100; + src: local(""), + url("./inter-v12-latin/inter-v12-latin-100.woff2") format("woff2"), + /* Chrome 26+, Opera 23+, Firefox 39+ */ + url("./inter-v12-latin/inter-v12-latin-100.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} +/* inter-200 - latin */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 200; + src: local(""), + url("./inter-v12-latin/inter-v12-latin-200.woff2") format("woff2"), + /* Chrome 26+, Opera 23+, Firefox 39+ */ + url("./inter-v12-latin/inter-v12-latin-200.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} +/* inter-300 - latin */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 300; + src: local(""), + url("./inter-v12-latin/inter-v12-latin-300.woff2") format("woff2"), + /* Chrome 26+, Opera 23+, Firefox 39+ */ + url("./inter-v12-latin/inter-v12-latin-300.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} +/* inter-regular - latin */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400; + src: local(""), + url("./inter-v12-latin/inter-v12-latin-regular.woff2") format("woff2"), + /* Chrome 26+, Opera 23+, Firefox 39+ */ + url("./inter-v12-latin/inter-v12-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} +/* inter-500 - latin */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 500; + src: local(""), + url("./inter-v12-latin/inter-v12-latin-500.woff2") format("woff2"), + /* Chrome 26+, Opera 23+, Firefox 39+ */ + url("./inter-v12-latin/inter-v12-latin-500.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} +/* inter-600 - latin */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 600; + src: local(""), + url("./inter-v12-latin/inter-v12-latin-600.woff2") format("woff2"), + /* Chrome 26+, Opera 23+, Firefox 39+ */ + url("./inter-v12-latin/inter-v12-latin-600.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} +/* inter-700 - latin */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 700; + src: local(""), + url("./inter-v12-latin/inter-v12-latin-700.woff2") format("woff2"), + /* Chrome 26+, Opera 23+, Firefox 39+ */ + url("./inter-v12-latin/inter-v12-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} +/* inter-800 - latin */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 800; + src: local(""), + url("./inter-v12-latin/inter-v12-latin-800.woff2") format("woff2"), + /* Chrome 26+, Opera 23+, Firefox 39+ */ + url("./inter-v12-latin/inter-v12-latin-800.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} +/* inter-900 - latin */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 900; + src: local(""), + url("./inter-v12-latin/inter-v12-latin-900.woff2") format("woff2"), + /* Chrome 26+, Opera 23+, Firefox 39+ */ + url("./inter-v12-latin/inter-v12-latin-900.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} diff --git a/packages/core/src/editor.module.css b/packages/core/src/editor.module.css index 78fa284a5a..594eb4069a 100644 --- a/packages/core/src/editor.module.css +++ b/packages/core/src/editor.module.css @@ -1,3 +1,40 @@ +@import url("./assets/fonts-inter.css"); + .bnEditor { outline: none; } + +/* +bnRoot should be applied to all top-level elements + +This includes the Prosemirror editor, but also
element such as +Tippy popups that are appended to document.body directly +*/ +.bnRoot { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.bnRoot *, +.bnRoot *::before, +.bnRoot *::after { + -webkit-box-sizing: inherit; + -moz-box-sizing: inherit; + box-sizing: inherit; +} + +.bnEditor, .dragPreview { + /* Define a set of colors to be used throughout the app for consistency + see https://atlassian.design/foundations/color for more info */ + --N800: #172b4d; /* Dark neutral used for tooltips and text on light background */ + --N40: #dfe1e6; /* Light neutral used for subtle borders and text on dark background */ + + font-family: "Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, + "Open Sans", "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", + "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + color: rgb(60, 65, 73); +} diff --git a/packages/core/src/extensions/Blocks/nodes/Block.ts b/packages/core/src/extensions/Blocks/nodes/Block.ts index aee2aa93f4..c02d140ec3 100644 --- a/packages/core/src/extensions/Blocks/nodes/Block.ts +++ b/packages/core/src/extensions/Blocks/nodes/Block.ts @@ -5,16 +5,18 @@ import BlockAttributes from "../BlockAttributes"; import { getBlockInfoFromPos } from "../helpers/getBlockInfoFromPos"; import { PreviousBlockTypePlugin } from "../PreviousBlockTypePlugin"; import styles from "./Block.module.css"; -import { HeadingContentAttributes } from "./BlockTypes/HeadingBlock/HeadingContent"; -import { ListItemContentAttributes } from "./BlockTypes/ListItemBlock/ListItemContent"; +import { TextContentType } from "./BlockTypes/TextBlock/TextContent"; +import { HeadingContentType } from "./BlockTypes/HeadingBlock/HeadingContent"; +import { ListItemContentType } from "./BlockTypes/ListItemBlock/ListItemContent"; export interface IBlock { HTMLAttributes: Record; } -export type BlockContentAttributes = - | HeadingContentAttributes - | ListItemContentAttributes; +export type BlockContentType = + | TextContentType + | HeadingContentType + | ListItemContentType; declare module "@tiptap/core" { interface Commands { @@ -25,13 +27,11 @@ declare module "@tiptap/core" { BNSplitBlock: (posInBlock: number, keepType: boolean) => ReturnType; BNSetContentType: ( posInBlock: number, - type: string, - attributes?: BlockContentAttributes + type: BlockContentType ) => ReturnType; BNCreateBlockOrSetContentType: ( posInBlock: number, - type: string, - attributes?: BlockContentAttributes + type: BlockContentType ) => ReturnType; }; } @@ -282,23 +282,23 @@ export const Block = Node.create({ return true; }, - // Changes the block at a given position to a given content type. + // Changes the content of a block at a given position to a given type. BNSetContentType: - (posInBlock, type, attributes) => + (posInBlock, type) => ({ state, dispatch }) => { const blockInfo = getBlockInfoFromPos(state.doc, posInBlock); if (blockInfo === undefined) { return false; } - const { startPos, endPos } = blockInfo; + const { startPos, contentNode } = blockInfo; if (dispatch) { state.tr.setBlockType( startPos + 1, - endPos - 1, - state.schema.node(type).type, - attributes + startPos + contentNode.nodeSize + 1, + state.schema.node(type.name).type, + type.attrs ); } @@ -307,7 +307,7 @@ export const Block = Node.create({ // Changes the block at a given position to a given content type if it's empty, otherwise creates a new block of // that type below it. BNCreateBlockOrSetContentType: - (posInBlock, type, attributes) => + (posInBlock, type) => ({ state, chain }) => { const blockInfo = getBlockInfoFromPos(state.doc, posInBlock); if (blockInfo === undefined) { @@ -320,7 +320,7 @@ export const Block = Node.create({ const oldBlockContentPos = startPos + 1; return chain() - .BNSetContentType(posInBlock, type, attributes) + .BNSetContentType(posInBlock, type) .setTextSelection(oldBlockContentPos) .run(); } else { @@ -329,7 +329,7 @@ export const Block = Node.create({ return chain() .BNCreateBlock(newBlockInsertionPos) - .BNSetContentType(newBlockContentPos, type, attributes) + .BNSetContentType(newBlockContentPos, type) .setTextSelection(newBlockContentPos) .run(); } @@ -362,10 +362,9 @@ export const Block = Node.create({ const isTextBlock = contentType.name === "textContent"; if (selectionAtBlockStart && !isTextBlock) { - return commands.BNSetContentType( - state.selection.from, - "textContent" - ); + return commands.BNSetContentType(state.selection.from, { + name: "textContent", + }); } return false; @@ -506,41 +505,51 @@ export const Block = Node.create({ "Mod-Alt-1": () => this.editor.commands.BNSetContentType( this.editor.state.selection.anchor, - "headingContent", { - headingLevel: "1", + name: "headingContent", + attrs: { + headingLevel: "1", + }, } ), "Mod-Alt-2": () => this.editor.commands.BNSetContentType( this.editor.state.selection.anchor, - "headingContent", { - headingLevel: "2", + name: "headingContent", + attrs: { + headingLevel: "2", + }, } ), "Mod-Alt-3": () => this.editor.commands.BNSetContentType( this.editor.state.selection.anchor, - "headingContent", { - headingLevel: "3", + name: "headingContent", + attrs: { + headingLevel: "3", + }, } ), "Mod-Shift-7": () => this.editor.commands.BNSetContentType( this.editor.state.selection.anchor, - "listItemContent", { - listItemType: "unordered", + name: "listItemContent", + attrs: { + listItemType: "unordered", + }, } ), "Mod-Shift-8": () => this.editor.commands.BNSetContentType( this.editor.state.selection.anchor, - "listItemContent", { - listItemType: "ordered", + name: "listItemContent", + attrs: { + listItemType: "ordered", + }, } ), }; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockTypes/HeadingBlock/HeadingContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockTypes/HeadingBlock/HeadingContent.ts index 94fadeae7f..d49341c290 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockTypes/HeadingBlock/HeadingContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockTypes/HeadingBlock/HeadingContent.ts @@ -1,8 +1,11 @@ import { InputRule, mergeAttributes, Node } from "@tiptap/core"; import styles from "../../Block.module.css"; -export type HeadingContentAttributes = { - headingLevel: string; +export type HeadingContentType = { + name: "headingContent"; + attrs?: { + headingLevel: string; + }; }; export const HeadingContent = Node.create({ @@ -33,8 +36,11 @@ export const HeadingContent = Node.create({ find: new RegExp(`^(#{${parseInt(level)}})\\s$`), handler: ({ state, chain, range }) => { chain() - .BNSetContentType(state.selection.from, "headingContent", { - headingLevel: level, + .BNSetContentType(state.selection.from, { + name: "headingContent", + attrs: { + headingLevel: level, + }, }) // Removes the "#" character(s) used to set the heading. .deleteRange({ from: range.from, to: range.to }); @@ -49,17 +55,17 @@ export const HeadingContent = Node.create({ { tag: "h1", attrs: { headingLevel: "1" }, - node: "block" + node: "block", }, { tag: "h2", attrs: { headingLevel: "2" }, - node: "block" + node: "block", }, { tag: "h3", attrs: { headingLevel: "3" }, - node: "block" + node: "block", }, ]; }, diff --git a/packages/core/src/extensions/Blocks/nodes/BlockTypes/ListItemBlock/ListItemContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockTypes/ListItemBlock/ListItemContent.ts index 21cc0f1eea..08ba9ec6f0 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockTypes/ListItemBlock/ListItemContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockTypes/ListItemBlock/ListItemContent.ts @@ -3,8 +3,11 @@ import { OrderedListItemIndexPlugin } from "./OrderedListItemIndexPlugin"; import { getBlockInfoFromPos } from "../../../helpers/getBlockInfoFromPos"; import styles from "../../Block.module.css"; -export type ListItemContentAttributes = { - listItemType: string; +export type ListItemContentType = { + name: "listItemContent"; + attrs?: { + listItemType: string; + }; }; export const ListItemContent = Node.create({ @@ -42,8 +45,11 @@ export const ListItemContent = Node.create({ find: new RegExp(`^[-+*]\\s$`), handler: ({ state, chain, range }) => { chain() - .BNSetContentType(state.selection.from, "listItemContent", { - listItemType: "unordered", + .BNSetContentType(state.selection.from, { + name: "listItemContent", + attrs: { + listItemType: "unordered", + }, }) // Removes the "-", "+", or "*" character used to set the list. .deleteRange({ from: range.from, to: range.to }); @@ -54,8 +60,11 @@ export const ListItemContent = Node.create({ find: new RegExp(`^1\\.\\s$`), handler: ({ state, chain, range }) => { chain() - .BNSetContentType(state.selection.from, "listItemContent", { - listItemType: "ordered", + .BNSetContentType(state.selection.from, { + name: "listItemContent", + attrs: { + listItemType: "ordered", + }, }) // Removes the "1." characters used to set the list. .deleteRange({ from: range.from, to: range.to }); @@ -83,10 +92,9 @@ export const ListItemContent = Node.create({ // Changes list item block to a text block if both the content is empty. commands.command(() => { if (node.textContent.length === 0) { - return commands.BNSetContentType( - state.selection.from, - "textContent" - ); + return commands.BNSetContentType(state.selection.from, { + name: "textContent", + }); } return false; @@ -151,7 +159,7 @@ export const ListItemContent = Node.create({ return false; }, - node: "block" + node: "block", }, ]; }, diff --git a/packages/core/src/extensions/Blocks/nodes/BlockTypes/TextBlock/TextContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockTypes/TextBlock/TextContent.ts index 99dba95916..e6be56209a 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockTypes/TextBlock/TextContent.ts +++ b/packages/core/src/extensions/Blocks/nodes/BlockTypes/TextBlock/TextContent.ts @@ -1,6 +1,11 @@ import { Node } from "@tiptap/core"; import styles from "../../Block.module.css"; +export type TextContentType = { + name: "textContent"; + attrs?: {}; +}; + export const TextContent = Node.create({ name: "textContent", group: "blockContent", diff --git a/packages/core/src/extensions/BubbleMenu/BubbleMenuExtension.tsx b/packages/core/src/extensions/BubbleMenu/BubbleMenuExtension.tsx deleted file mode 100644 index 0ee37bcff1..0000000000 --- a/packages/core/src/extensions/BubbleMenu/BubbleMenuExtension.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { MantineProvider } from "@mantine/core"; -import { Extension } from "@tiptap/core"; -import { PluginKey } from "prosemirror-state"; -import ReactDOM from "react-dom"; -import { BlockNoteTheme } from "../../BlockNoteTheme"; -import rootStyles from "../../root.module.css"; -import { createBubbleMenuPlugin } from "./BubbleMenuPlugin"; -import { BubbleMenu } from "./component/BubbleMenu"; - -/** - * The menu that is displayed when selecting a piece of text. - */ -export const BubbleMenuExtension = Extension.create<{}>({ - name: "BubbleMenuExtension", - - addProseMirrorPlugins() { - const element = document.createElement("div"); - element.className = rootStyles.bnRoot; - ReactDOM.render( - - - , - element - ); - return [ - createBubbleMenuPlugin({ - editor: this.editor, - element, - pluginKey: new PluginKey("BubbleMenuPlugin"), - tippyOptions: { - appendTo: document.body, - }, - }), - ]; - }, -}); diff --git a/packages/core/src/extensions/BubbleMenu/BubbleMenuPlugin.ts b/packages/core/src/extensions/BubbleMenu/BubbleMenuPlugin.ts deleted file mode 100644 index ed87cde122..0000000000 --- a/packages/core/src/extensions/BubbleMenu/BubbleMenuPlugin.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { - Editor, - isNodeSelection, - isTextSelection, - posToDOMRect, -} from "@tiptap/core"; -import { EditorState, Plugin, PluginKey } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; -import tippy, { Instance, Props } from "tippy.js"; - -// Same as TipTap bubblemenu plugin, but with these changes: -// https://github.com/ueberdosis/tiptap/pull/2596/files -export interface BubbleMenuPluginProps { - pluginKey: PluginKey | string; - editor: Editor; - element: HTMLElement; - tippyOptions?: Partial; - shouldShow?: - | ((props: { - editor: Editor; - view: EditorView; - state: EditorState; - oldState?: EditorState; - from: number; - to: number; - }) => boolean) - | null; -} - -export type BubbleMenuViewProps = BubbleMenuPluginProps & { - view: EditorView; -}; - -export class BubbleMenuView { - public editor: Editor; - - public element: HTMLElement; - - public view: EditorView; - - public preventHide = false; - - public preventShow = false; - - public tippy: Instance | undefined; - - public tippyOptions?: Partial; - - public shouldShow: Exclude = ({ - view, - state, - from, - to, - }) => { - const { doc, selection } = state; - const { empty } = selection; - - // Sometime check for `empty` is not enough. - // Doubleclick an empty paragraph returns a node size of 2. - // So we check also for an empty text size. - const isEmptyTextBlock = - !doc.textBetween(from, to).length && isTextSelection(state.selection); - - if (!view.hasFocus() || empty || isEmptyTextBlock) { - return false; - } - - return true; - }; - - constructor({ - editor, - element, - view, - tippyOptions = {}, - shouldShow, - }: BubbleMenuViewProps) { - this.editor = editor; - this.element = element; - this.view = view; - - if (shouldShow) { - this.shouldShow = shouldShow; - } - - this.element.addEventListener("mousedown", this.mousedownHandler, { - capture: true, - }); - this.view.dom.addEventListener("mousedown", this.viewMousedownHandler); - this.view.dom.addEventListener("mouseup", this.viewMouseupHandler); - this.view.dom.addEventListener("dragstart", this.dragstartHandler); - - this.editor.on("focus", this.focusHandler); - this.editor.on("blur", this.blurHandler); - this.tippyOptions = tippyOptions; - // Detaches menu content from its current parent - this.element.remove(); - this.element.style.visibility = "visible"; - } - - mousedownHandler = () => { - this.preventHide = true; - }; - - viewMousedownHandler = () => { - this.preventShow = true; - }; - - viewMouseupHandler = () => { - this.preventShow = false; - setTimeout(() => this.update(this.editor.view)); - }; - - dragstartHandler = () => { - this.hide(); - }; - - focusHandler = () => { - // we use `setTimeout` to make sure `selection` is already updated - setTimeout(() => this.update(this.editor.view)); - }; - - blurHandler = ({ event }: { event: FocusEvent }) => { - if (this.preventHide) { - this.preventHide = false; - - return; - } - - if ( - event?.relatedTarget && - this.element.parentNode?.contains(event.relatedTarget as Node) - ) { - return; - } - - this.hide(); - }; - - createTooltip() { - const { element: editorElement } = this.editor.options; - const editorIsAttached = !!editorElement.parentElement; - - if (this.tippy || !editorIsAttached) { - return; - } - - this.tippy = tippy(editorElement, { - duration: 0, - getReferenceClientRect: null, - content: this.element, - interactive: true, - trigger: "manual", - placement: "top", - hideOnClick: "toggle", - ...this.tippyOptions, - }); - - // maybe we have to hide tippy on its own blur event as well - if (this.tippy.popper.firstChild) { - (this.tippy.popper.firstChild as HTMLElement).addEventListener( - "blur", - (event) => { - this.blurHandler({ event }); - } - ); - } - } - - update(view: EditorView, oldState?: EditorState) { - const { state, composing } = view; - const { doc, selection } = state; - const isSame = - oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection); - - if (composing || isSame) { - return; - } - - this.createTooltip(); - - // support for CellSelections - const { ranges } = selection; - const from = Math.min(...ranges.map((range) => range.$from.pos)); - const to = Math.max(...ranges.map((range) => range.$to.pos)); - - const shouldShow = this.shouldShow?.({ - editor: this.editor, - view, - state, - oldState, - from, - to, - }); - - if (!shouldShow || this.preventShow) { - this.hide(); - - return; - } - - this.tippy?.setProps({ - getReferenceClientRect: () => { - if (isNodeSelection(state.selection)) { - const node = view.nodeDOM(from) as HTMLElement; - - if (node) { - return node.getBoundingClientRect(); - } - } - - return posToDOMRect(view, from, to); - }, - }); - - this.show(); - } - - show() { - this.tippy?.show(); - } - - hide() { - this.tippy?.hide(); - } - - destroy() { - this.tippy?.destroy(); - this.element.removeEventListener("mousedown", this.mousedownHandler, { - capture: true, - }); - this.view.dom.removeEventListener("mousedown", this.viewMousedownHandler); - this.view.dom.removeEventListener("mouseup", this.viewMouseupHandler); - this.view.dom.removeEventListener("dragstart", this.dragstartHandler); - this.editor.off("focus", this.focusHandler); - this.editor.off("blur", this.blurHandler); - } -} - -export const createBubbleMenuPlugin = (options: BubbleMenuPluginProps) => { - return new Plugin({ - key: new PluginKey("BubbleMenuPlugin"), - view: (view) => new BubbleMenuView({ view, ...options }), - }); -}; diff --git a/packages/core/src/extensions/BubbleMenu/component/BubbleMenu.tsx b/packages/core/src/extensions/BubbleMenu/component/BubbleMenu.tsx deleted file mode 100644 index 315d6b967e..0000000000 --- a/packages/core/src/extensions/BubbleMenu/component/BubbleMenu.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { Editor } from "@tiptap/core"; -import { - RiBold, - RiH1, - RiH2, - RiH3, - RiIndentDecrease, - RiIndentIncrease, - RiItalic, - RiLink, - RiListOrdered, - RiListUnordered, - RiStrikethrough, - RiText, - RiUnderline, -} from "react-icons/ri"; -import { ToolbarButton } from "../../../shared/components/toolbar/ToolbarButton"; -import { ToolbarDropdown } from "../../../shared/components/toolbar/ToolbarDropdown"; -import { Toolbar } from "../../../shared/components/toolbar/Toolbar"; -import { useEditorForceUpdate } from "../../../shared/hooks/useEditorForceUpdate"; -import { findBlock } from "../../Blocks/helpers/findBlock"; -import { formatKeyboardShortcut } from "../../../utils"; -import LinkToolbarButton from "./LinkToolbarButton"; -import { IconType } from "react-icons"; -import { Node } from "prosemirror-model"; - -function getBlockName(blockContentNode: Node) { - if (blockContentNode.type.name === "textContent") { - return "Text"; - } - - if (blockContentNode.type.name === "headingContent") { - return "Heading " + blockContentNode.attrs["headingLevel"]; - } - - if (blockContentNode.type.name === "listItemContent") { - return blockContentNode.attrs["listItemType"] === "unordered" - ? "Bullet List" - : "Numbered List"; - } - - return ""; -} - -// TODO: add list options, indentation -export const BubbleMenu = (props: { editor: Editor }) => { - useEditorForceUpdate(props.editor); - - const selectedNode = props.editor.state.selection.$from.node(); - const currentBlockName = getBlockName(selectedNode); - - const blockIconMap: Record = { - Text: RiText, - "Heading 1": RiH1, - "Heading 2": RiH2, - "Heading 3": RiH3, - "Bullet List": RiListUnordered, - "Numbered List": RiListOrdered, - }; - - return ( - - { - // Setting editor focus using a chained command instead causes bubble menu to flicker on click. - props.editor.view.focus(); - props.editor.commands.BNSetContentType( - props.editor.state.selection.from, - "textContent" - ); - }, - text: "Text", - icon: RiText, - isSelected: selectedNode.type.name === "textContent", - }, - { - onClick: () => { - props.editor.view.focus(); - props.editor.commands.BNSetContentType( - props.editor.state.selection.from, - "headingContent", - { - headingLevel: "1", - } - ); - }, - text: "Heading 1", - icon: RiH1, - isSelected: - selectedNode.type.name === "headingContent" && - selectedNode.attrs["headingLevel"] === "1", - }, - { - onClick: () => { - props.editor.view.focus(); - props.editor.commands.BNSetContentType( - props.editor.state.selection.from, - "headingContent", - { - headingLevel: "2", - } - ); - }, - text: "Heading 2", - icon: RiH2, - isSelected: - selectedNode.type.name === "headingContent" && - selectedNode.attrs["headingLevel"] === "2", - }, - { - onClick: () => { - props.editor.view.focus(); - props.editor.commands.BNSetContentType( - props.editor.state.selection.from, - "headingContent", - { - headingLevel: "3", - } - ); - }, - text: "Heading 3", - icon: RiH3, - isSelected: - selectedNode.type.name === "headingContent" && - selectedNode.attrs["headingLevel"] === "3", - }, - { - onClick: () => { - props.editor.view.focus(); - props.editor.commands.BNSetContentType( - props.editor.state.selection.from, - "listItemContent", - { - listItemType: "unordered", - } - ); - }, - text: "Bullet List", - icon: RiListUnordered, - isSelected: - selectedNode.type.name === "listItemContent" && - selectedNode.attrs["listItemType"] === "unordered", - }, - { - onClick: () => { - props.editor.view.focus(); - props.editor.commands.BNSetContentType( - props.editor.state.selection.from, - "listItemContent", - { - listItemType: "ordered", - } - ); - }, - text: "Numbered List", - icon: RiListOrdered, - isSelected: - selectedNode.type.name === "listItemContent" && - selectedNode.attrs["listItemType"] === "ordered", - }, - ]} - /> - { - // Setting editor focus using a chained command instead causes bubble menu to flicker on click. - props.editor.view.focus(); - props.editor.commands.toggleBold(); - }} - isSelected={props.editor.isActive("bold")} - mainTooltip="Bold" - secondaryTooltip={formatKeyboardShortcut("Mod+B")} - icon={RiBold} - /> - { - props.editor.view.focus(); - props.editor.commands.toggleItalic(); - }} - isSelected={props.editor.isActive("italic")} - mainTooltip="Italic" - secondaryTooltip={formatKeyboardShortcut("Mod+I")} - icon={RiItalic} - /> - { - props.editor.view.focus(); - props.editor.commands.toggleUnderline(); - }} - isSelected={props.editor.isActive("underline")} - mainTooltip="Underline" - secondaryTooltip={formatKeyboardShortcut("Mod+U")} - icon={RiUnderline} - /> - { - props.editor.view.focus(); - props.editor.commands.toggleStrike(); - }} - isSelected={props.editor.isActive("strike")} - mainTooltip="Strike-through" - secondaryTooltip={formatKeyboardShortcut("Mod+Shift+X")} - icon={RiStrikethrough} - /> - { - props.editor.view.focus(); - props.editor.commands.sinkListItem("block"); - }} - isDisabled={!props.editor.can().sinkListItem("block")} - mainTooltip="Indent" - secondaryTooltip={formatKeyboardShortcut("Tab")} - icon={RiIndentIncrease} - /> - - { - props.editor.view.focus(); - props.editor.commands.liftListItem("block"); - }} - isDisabled={ - !props.editor.can().command(({ state }) => { - const block = findBlock(state.selection); - if (!block) { - return false; - } - // If the depth is greater than 2 you can lift - return block.depth > 2; - }) - } - mainTooltip="Decrease Indent" - secondaryTooltip={formatKeyboardShortcut("Shift+Tab")} - icon={RiIndentDecrease} - /> - - - {/* { - const comment = this.props.commentStore.createComment(); - props.editor.chain().focus().setComment(comment.id).run(); - }} - styleDetails={comment} - /> */} - - ); -}; diff --git a/packages/core/src/extensions/BubbleMenu/component/LinkToolbarButton.tsx b/packages/core/src/extensions/BubbleMenu/component/LinkToolbarButton.tsx deleted file mode 100644 index 9317f9f153..0000000000 --- a/packages/core/src/extensions/BubbleMenu/component/LinkToolbarButton.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import Tippy from "@tippyjs/react"; -import { Editor } from "@tiptap/core"; -import { useCallback, useState } from "react"; -import { - ToolbarButton, - ToolbarButtonProps, -} from "../../../shared/components/toolbar/ToolbarButton"; -import { EditHyperlinkMenu } from "../../Hyperlinks/menus/EditHyperlinkMenu"; - -type Props = ToolbarButtonProps & { - editor: Editor; -}; - -/** - * The link menu button opens a tooltip on click - */ -export const LinkToolbarButton = (props: Props) => { - const [creationMenu, setCreationMenu] = useState(); - - // TODO: review code; does this pattern still make sense? - const updateCreationMenu = useCallback(() => { - const onSubmit = (url: string, text: string) => { - if (url === "") { - return; - } - const mark = props.editor.schema.mark("link", { href: url }); - let { from, to } = props.editor.state.selection; - props.editor.view.dispatch( - props.editor.view.state.tr - .insertText(text, from, to) - .addMark(from, from + text.length, mark) - ); - }; - - // get the currently selected text and url from the document, and use it to - // create a new creation menu - const { from, to } = props.editor.state.selection; - const selectedText = props.editor.state.doc.textBetween(from, to); - const activeUrl = props.editor.isActive("link") - ? props.editor.getAttributes("link").href || "" - : ""; - - setCreationMenu( - - ); - }, [props.editor]); - - return ( - { - updateCreationMenu(); - }} - interactive={true} - maxWidth={500}> - - - ); -}; - -export default LinkToolbarButton; diff --git a/packages/core/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.ts b/packages/core/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.ts new file mode 100644 index 0000000000..333a763f21 --- /dev/null +++ b/packages/core/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.ts @@ -0,0 +1,20 @@ +import { EditorElement, ElementFactory } from "../../shared/EditorElement"; + +export type BlockSideMenuStaticParams = { + addBlock: () => void; + deleteBlock: () => void; + blockDragStart: (event: DragEvent) => void; + blockDragEnd: () => void; + freezeMenu: () => void; + unfreezeMenu: () => void; +}; + +export type BlockSideMenuDynamicParams = { + blockBoundingBox: DOMRect; +}; + +export type BlockSideMenu = EditorElement; +export type BlockSideMenuFactory = ElementFactory< + BlockSideMenuStaticParams, + BlockSideMenuDynamicParams +>; diff --git a/packages/core/src/extensions/DraggableBlocks/DraggableBlocksExtension.ts b/packages/core/src/extensions/DraggableBlocks/DraggableBlocksExtension.ts index e6042402ca..8f4a7af603 100644 --- a/packages/core/src/extensions/DraggableBlocks/DraggableBlocksExtension.ts +++ b/packages/core/src/extensions/DraggableBlocks/DraggableBlocksExtension.ts @@ -1,15 +1,33 @@ -import { Extension } from "@tiptap/core"; +import { Editor, Extension } from "@tiptap/core"; +import { BlockSideMenuFactory } from "./BlockSideMenuFactoryTypes"; import { createDraggableBlocksPlugin } from "./DraggableBlocksPlugin"; +export type DraggableBlocksOptions = { + editor: Editor; + blockSideMenuFactory: BlockSideMenuFactory; +}; + /** - * This extension adds a drag handle in front of all nodes with a "data-id" attribute + * This extension adds a menu to the side of blocks which features various BlockNote functions such as adding and + * removing blocks. More importantly, it adds a drag handle which allows the user to drag and drop blocks. * * code based on https://github.com/ueberdosis/tiptap/issues/323#issuecomment-506637799 */ -export const DraggableBlocksExtension = Extension.create<{}>({ - name: "DraggableBlocksExtension", - priority: 1000, // Need to be high, in order to hide draghandle when typing slash - addProseMirrorPlugins() { - return [createDraggableBlocksPlugin(this.editor)]; - }, -}); +export const DraggableBlocksExtension = + Extension.create({ + name: "DraggableBlocksExtension", + priority: 1000, // Need to be high, in order to hide menu when typing slash + addProseMirrorPlugins() { + if (!this.options.blockSideMenuFactory) { + throw new Error( + "UI Element factory not defined for DraggableBlocksExtension" + ); + } + return [ + createDraggableBlocksPlugin({ + editor: this.editor, + blockSideMenuFactory: this.options.blockSideMenuFactory, + }), + ]; + }, + }); diff --git a/packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.tsx b/packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.ts similarity index 55% rename from packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.tsx rename to packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.ts index 7df590bdb9..7fc8d3ba4b 100644 --- a/packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.tsx +++ b/packages/core/src/extensions/DraggableBlocks/DraggableBlocksPlugin.ts @@ -1,13 +1,19 @@ -import { NodeSelection, Plugin, PluginKey, Selection } from "prosemirror-state"; +import { Editor } from "@tiptap/core"; import { Node } from "prosemirror-model"; +import { NodeSelection, Plugin, PluginKey, Selection } from "prosemirror-state"; import * as pv from "prosemirror-view"; import { EditorView } from "prosemirror-view"; -import ReactDOM from "react-dom"; -import { DragHandle } from "./components/DragHandle"; -import { MantineProvider } from "@mantine/core"; -import { BlockNoteTheme } from "../../BlockNoteTheme"; import { MultipleNodeSelection } from "../Blocks/MultipleNodeSelection"; -import { Editor } from "@tiptap/core"; +import { DraggableBlocksOptions } from "./DraggableBlocksExtension"; +import { + BlockSideMenu, + BlockSideMenuDynamicParams, + BlockSideMenuFactory, + BlockSideMenuStaticParams, +} from "./BlockSideMenuFactoryTypes"; +import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos"; +import { SlashMenuPluginKey } from "../SlashMenu/SlashMenuExtension"; +import styles from "../../editor.module.css"; const serializeForClipboard = (pv as any).__serializeForClipboard; // code based on https://github.com/ueberdosis/tiptap/issues/323#issuecomment-506637799 @@ -162,6 +168,7 @@ function setDragImage(view: EditorView, from: number, to = from) { // dataTransfer.setDragImage(element) only works if element is attached to the DOM. dragImageElement = parentClone; + dragImageElement.className = styles.dragPreview; document.body.appendChild(dragImageElement); } @@ -218,157 +225,232 @@ function dragStart(e: DragEvent, view: EditorView) { } } -export const createDraggableBlocksPlugin = (editor: Editor) => { - let dropElement: HTMLElement | undefined; +export type BlockMenuViewProps = { + editor: Editor; + blockMenuFactory: BlockSideMenuFactory; + horizontalPosAnchoredAtRoot: boolean; +}; - const WIDTH = 48; +export class BlockMenuView { + editor: Editor; // When true, the drag handle with be anchored at the same level as root elements // When false, the drag handle with be just to the left of the element - const horizontalPosAnchoredAtRoot = true; + horizontalPosAnchoredAtRoot: boolean; - let menuShown = false; - let addClicked = false; + blockMenu: BlockSideMenu; - const onShow = () => { - menuShown = true; - }; - const onHide = () => { - menuShown = false; - }; - const onAddClicked = () => { - addClicked = true; - }; + hoveredBlock: HTMLElement | undefined; - return new Plugin({ - key: new PluginKey("DraggableBlocksPlugin"), - view(editorView) { - dropElement = document.createElement("div"); - dropElement.setAttribute("draggable", "true"); - dropElement.style.position = "absolute"; - dropElement.style.height = "24px"; // default height - document.body.append(dropElement); - - dropElement.addEventListener("dragstart", (e) => - dragStart(e, editorView) - ); - dropElement.addEventListener("dragend", () => unsetDragImage()); + menuOpen = false; + menuFrozen = false; + + constructor({ + editor, + blockMenuFactory, + horizontalPosAnchoredAtRoot, + }: BlockMenuViewProps) { + this.editor = editor; + this.horizontalPosAnchoredAtRoot = horizontalPosAnchoredAtRoot; - return { - // update(view, prevState) {}, - destroy() { - if (!dropElement) { - throw new Error("unexpected"); + this.blockMenu = blockMenuFactory(this.getStaticParams()); + + // Shows or updates menu position whenever the cursor moves, if the menu isn't frozen. + document.body.addEventListener( + "mousemove", + (event) => { + if (this.menuFrozen) { + return; + } + + // Gets block at mouse cursor's vertical position. + const coords = { + left: this.editor.view.dom.clientWidth / 2, // take middle of editor + top: event.clientY, + }; + const block = getDraggableBlockFromCoords(coords, this.editor.view); + + // Closes the menu if the mouse cursor is beyond the editor vertically. + if (!block) { + if (this.menuOpen) { + this.menuOpen = false; + this.blockMenu.hide(); } - dropElement.parentNode!.removeChild(dropElement); - dropElement = undefined; - }, - }; - }, - props: { - // handleDOMEvents: { - - // }, - // handleDOMEvents: { - // dragend(view, event) { - // // setTimeout(() => { - // // let node = document.querySelector(".ProseMirror-hideselection"); - // // if (node) { - // // node.classList.remove("ProseMirror-hideselection"); - // // } - // // }, 50); - // return true; - // }, - handleKeyDown(_view, _event) { - if (!dropElement) { - throw new Error("unexpected"); + + return; + } + + // Doesn't update if the menu is already open and the mouse cursor is still hovering the same block. + if ( + this.menuOpen && + this.hoveredBlock?.hasAttribute("data-id") && + this.hoveredBlock?.getAttribute("data-id") === block.id + ) { + return; + } + + // Gets the block's content node, which lets to ignore child blocks when determining the block menu's position. + const blockContent = block.node.firstChild as HTMLElement; + this.hoveredBlock = blockContent; + + if (!blockContent) { + return; + } + + // Shows or updates elements. + if (!this.menuOpen) { + this.menuOpen = true; + this.blockMenu.render(this.getDynamicParams(), true); + } else { + this.blockMenu.render(this.getDynamicParams(), false); } - menuShown = false; - addClicked = false; - ReactDOM.render(<>, dropElement); - return false; }, - handleDOMEvents: { - // drag(view, event) { - // // event.dataTransfer!.; - // return false; - // }, - mouseleave(_view, _event: any) { - if (!dropElement) { - throw new Error("unexpected"); - } - // TODO - // dropElement.style.display = "none"; - return true; - }, - mousedown(_view, _event: any) { - if (!dropElement) { - throw new Error("unexpected"); - } - menuShown = false; - addClicked = false; - ReactDOM.render(<>, dropElement); - return false; - }, - mousemove(view, event: any) { - if (!dropElement) { - throw new Error("unexpected"); - } + true + ); + + // Hides and unfreezes the menu whenever the user selects the editor with the mouse or presses a key. + // TODO: Better integration with suggestions menu and only editor scope? + document.body.addEventListener( + "mousedown", + (event) => { + if (this.blockMenu.element?.contains(event.target as HTMLElement)) { + return; + } - if (menuShown || addClicked) { - // The submenu is open, don't move draghandle - // Or if the user clicked the add button - return true; - } - const coords = { - left: view.dom.clientWidth / 2, // take middle of editor - top: event.clientY, - }; - const block = getDraggableBlockFromCoords(coords, view); - - if (!block) { - console.warn("Perhaps we should hide element?"); - return true; - } + if (this.menuOpen) { + this.menuOpen = false; + this.blockMenu.hide(); + } + + this.menuFrozen = false; + }, + true + ); + document.body.addEventListener( + "keydown", + () => { + if (this.menuOpen) { + this.menuOpen = false; + this.blockMenu.hide(); + } - // I want the dim of the blocks content node - // because if the block contains other blocks - // Its dims change, moving the position of the drag handle - const blockContent = block.node.firstChild as HTMLElement; + this.menuFrozen = false; + }, + true + ); + } - if (!blockContent) { - return true; - } + destroy() { + if (this.menuOpen) { + this.menuOpen = false; + this.blockMenu.hide(); + } + } + + addBlock() { + this.menuOpen = false; + this.menuFrozen = true; + this.blockMenu.hide(); + + const blockBoundingBox = this.hoveredBlock!.getBoundingClientRect(); + + const pos = this.editor.view.posAtCoords({ + left: blockBoundingBox.left, + top: blockBoundingBox.top, + }); + if (!pos) { + return; + } + + const blockInfo = getBlockInfoFromPos(this.editor.state.doc, pos.pos); + if (blockInfo === undefined) { + return; + } + + const { contentNode, endPos } = blockInfo; + + // Creates a new block if current one is not empty for the suggestion menu to open in. + if (contentNode.textContent.length !== 0) { + const newBlockInsertionPos = endPos + 1; + const newBlockContentPos = newBlockInsertionPos + 2; + + this.editor + .chain() + .BNCreateBlock(newBlockInsertionPos) + .BNSetContentType(newBlockContentPos, { name: "textContent" }) + .setTextSelection(newBlockContentPos) + .run(); + } - const rect = absoluteRect(blockContent); - const win = block.node.ownerDocument.defaultView!; - const dropElementRect = dropElement.getBoundingClientRect(); - const left = - (horizontalPosAnchoredAtRoot ? getHorizontalAnchor() : rect.left) - - WIDTH + - win.pageXOffset; - rect.top += - rect.height / 2 - dropElementRect.height / 2 + win.pageYOffset; - - dropElement.style.left = left + "px"; - dropElement.style.top = rect.top + "px"; - - ReactDOM.render( - - - , - dropElement - ); - return true; - }, + // Focuses and activates the suggestion menu. + this.editor.view.focus(); + this.editor.view.dispatch( + this.editor.view.state.tr.scrollIntoView().setMeta(SlashMenuPluginKey, { + // TODO import suggestion plugin key + activate: true, + type: "drag", + }) + ); + } + + deleteBlock() { + this.menuOpen = false; + this.blockMenu.hide(); + + const blockBoundingBox = this.hoveredBlock!.getBoundingClientRect(); + + const pos = this.editor.view.posAtCoords({ + left: blockBoundingBox.left, + top: blockBoundingBox.top, + }); + if (!pos) { + return; + } + + this.editor.commands.BNDeleteBlock(pos.pos); + } + + getStaticParams(): BlockSideMenuStaticParams { + return { + addBlock: () => this.addBlock(), + deleteBlock: () => this.deleteBlock(), + blockDragStart: (event: DragEvent) => dragStart(event, this.editor.view), + blockDragEnd: () => unsetDragImage(), + freezeMenu: () => { + this.menuFrozen = true; }, - }, + unfreezeMenu: () => { + this.menuFrozen = false; + }, + }; + } + + getDynamicParams(): BlockSideMenuDynamicParams { + const blockBoundingBox = this.hoveredBlock!.getBoundingClientRect(); + + return { + blockBoundingBox: new DOMRect( + this.horizontalPosAnchoredAtRoot + ? getHorizontalAnchor() + : blockBoundingBox.x, + blockBoundingBox.y, + blockBoundingBox.width, + blockBoundingBox.height + ), + }; + } +} + +export const createDraggableBlocksPlugin = ( + options: DraggableBlocksOptions +) => { + return new Plugin({ + key: new PluginKey("DraggableBlocksPlugin"), + view: () => + new BlockMenuView({ + editor: options.editor, + blockMenuFactory: options.blockSideMenuFactory, + horizontalPosAnchoredAtRoot: true, + }), }); }; diff --git a/packages/core/src/extensions/DraggableBlocks/components/DragHandle.tsx b/packages/core/src/extensions/DraggableBlocks/components/DragHandle.tsx deleted file mode 100644 index c880b556b8..0000000000 --- a/packages/core/src/extensions/DraggableBlocks/components/DragHandle.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Editor } from "@tiptap/core"; -import { ActionIcon, Menu } from "@mantine/core"; -import { AiOutlinePlus, MdDragIndicator } from "react-icons/all"; -import DragHandleMenu from "./DragHandleMenu"; -import { SlashMenuPluginKey } from "../../SlashMenu/SlashMenuExtension"; -import { getBlockInfoFromPos } from "../../Blocks/helpers/getBlockInfoFromPos"; - -export const DragHandle = (props: { - editor: Editor; - coords: { left: number; top: number }; - onShow?: () => void; - onHide?: () => void; - onAddClicked?: () => void; -}) => { - const onDelete = () => { - const pos = props.editor.view.posAtCoords(props.coords); - if (!pos) { - return; - } - - props.editor.commands.BNDeleteBlock(pos.pos); - }; - - const onAddClick = () => { - if (props.onAddClicked) { - props.onAddClicked(); - } - - const pos = props.editor.view.posAtCoords(props.coords); - if (!pos) { - return; - } - - const blockInfo = getBlockInfoFromPos(props.editor.state.doc, pos.pos); - if (blockInfo === undefined) { - return; - } - - const { contentNode, endPos } = blockInfo; - - // Creates a new block if current one is not empty for the suggestion menu to open in. - if (contentNode.textContent.length !== 0) { - const newBlockInsertionPos = endPos + 1; - const newBlockContentPos = newBlockInsertionPos + 2; - - props.editor - .chain() - .BNCreateBlock(newBlockInsertionPos) - .BNSetContentType(newBlockContentPos, "textContent") - .setTextSelection(newBlockContentPos) - .run(); - } - - // Focuses and activates the suggestion menu. - props.editor.view.focus(); - props.editor.view.dispatch( - props.editor.view.state.tr.scrollIntoView().setMeta(SlashMenuPluginKey, { - // TODO import suggestion plugin key - activate: true, - type: "drag", - }) - ); - }; - - return ( -
- - {} - - - - - {} - - - - -
- ); -}; diff --git a/packages/core/src/extensions/DraggableBlocks/components/DragHandleMenu.tsx b/packages/core/src/extensions/DraggableBlocks/components/DragHandleMenu.tsx deleted file mode 100644 index 2ccaabfd55..0000000000 --- a/packages/core/src/extensions/DraggableBlocks/components/DragHandleMenu.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { createStyles, Menu } from "@mantine/core"; - -type Props = { - onDelete: () => void; -}; - -const DragHandleMenu = (props: Props) => { - const { classes } = createStyles({ root: {} })(undefined, { - name: "DragHandleMenu", - }); - - return ( - - Delete - - ); -}; - -export default DragHandleMenu; diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarExtension.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarExtension.ts new file mode 100644 index 0000000000..fb63801145 --- /dev/null +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarExtension.ts @@ -0,0 +1,29 @@ +import { Extension } from "@tiptap/core"; +import { PluginKey } from "prosemirror-state"; +import { FormattingToolbarFactory } from "./FormattingToolbarFactoryTypes"; +import { createFormattingToolbarPlugin } from "./FormattingToolbarPlugin"; + +/** + * The menu that is displayed when selecting a piece of text. + */ +export const FormattingToolbarExtension = Extension.create<{ + formattingToolbarFactory: FormattingToolbarFactory; +}>({ + name: "FormattingToolbarExtension", + + addProseMirrorPlugins() { + if (!this.options.formattingToolbarFactory) { + throw new Error( + "UI Element factory not defined for FormattingToolbarExtension" + ); + } + + return [ + createFormattingToolbarPlugin({ + editor: this.editor, + formattingToolbarFactory: this.options.formattingToolbarFactory, + pluginKey: new PluginKey("FormattingToolbarPlugin"), + }), + ]; + }, +}); diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts new file mode 100644 index 0000000000..fa055803ba --- /dev/null +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts @@ -0,0 +1,35 @@ +import { EditorElement, ElementFactory } from "../../shared/EditorElement"; +import { BlockContentType } from "../Blocks/nodes/Block"; + +export type FormattingToolbarStaticParams = { + toggleBold: () => void; + toggleItalic: () => void; + toggleUnderline: () => void; + toggleStrike: () => void; + setHyperlink: (url: string, text?: string) => void; + + setBlockType: (type: BlockContentType) => void; +}; + +export type FormattingToolbarDynamicParams = { + boldIsActive: boolean; + italicIsActive: boolean; + underlineIsActive: boolean; + strikeIsActive: boolean; + hyperlinkIsActive: boolean; + activeHyperlinkUrl: string; + activeHyperlinkText: string; + + // BlockContentType is mostly used to set a block's type, so the attr field is optional as block content types have + // default values for attributes. However, it means that a block type's attributes field will never be undefined due to + // these default values, which the Required type enforces. + activeBlockType: Required; + + selectionBoundingBox: DOMRect; +}; + +export type FormattingToolbar = EditorElement; +export type FormattingToolbarFactory = ElementFactory< + FormattingToolbarStaticParams, + FormattingToolbarDynamicParams +>; diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts new file mode 100644 index 0000000000..61541d1142 --- /dev/null +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts @@ -0,0 +1,308 @@ +import { + Editor, + isNodeSelection, + isTextSelection, + posToDOMRect, +} from "@tiptap/core"; +import { EditorState, Plugin, PluginKey } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { + FormattingToolbar, + FormattingToolbarDynamicParams, + FormattingToolbarFactory, + FormattingToolbarStaticParams, +} from "./FormattingToolbarFactoryTypes"; +import { BlockContentType } from "../Blocks/nodes/Block"; + +// Same as TipTap bubblemenu plugin, but with these changes: +// https://github.com/ueberdosis/tiptap/pull/2596/files +export interface FormattingToolbarPluginProps { + pluginKey: PluginKey; + editor: Editor; + formattingToolbarFactory: FormattingToolbarFactory; + shouldShow?: + | ((props: { + editor: Editor; + view: EditorView; + state: EditorState; + oldState?: EditorState; + from: number; + to: number; + }) => boolean) + | null; +} + +export type FormattingToolbarViewProps = FormattingToolbarPluginProps & { + view: EditorView; +}; + +export class FormattingToolbarView { + public editor: Editor; + + public view: EditorView; + + public formattingToolbar: FormattingToolbar; + + public preventHide = false; + + public preventShow = false; + + public toolbarIsOpen = false; + + public shouldShow: Exclude = + ({ view, state, from, to }) => { + const { doc, selection } = state; + const { empty } = selection; + + // Sometime check for `empty` is not enough. + // Doubleclick an empty paragraph returns a node size of 2. + // So we check also for an empty text size. + const isEmptyTextBlock = + !doc.textBetween(from, to).length && isTextSelection(state.selection); + + return !(!view.hasFocus() || empty || isEmptyTextBlock); + }; + + constructor({ + editor, + formattingToolbarFactory, + view, + shouldShow, + }: FormattingToolbarViewProps) { + this.editor = editor; + this.view = view; + + this.formattingToolbar = formattingToolbarFactory(this.getStaticParams()); + + if (shouldShow) { + this.shouldShow = shouldShow; + } + + this.view.dom.addEventListener("mousedown", this.viewMousedownHandler); + this.view.dom.addEventListener("mouseup", this.viewMouseupHandler); + this.view.dom.addEventListener("dragstart", this.dragstartHandler); + + this.editor.on("focus", this.focusHandler); + this.editor.on("blur", this.blurHandler); + } + + viewMousedownHandler = () => { + this.preventShow = true; + }; + + viewMouseupHandler = () => { + this.preventShow = false; + setTimeout(() => this.update(this.editor.view)); + }; + + dragstartHandler = () => { + this.formattingToolbar.hide(); + this.toolbarIsOpen = false; + }; + + focusHandler = () => { + // we use `setTimeout` to make sure `selection` is already updated + setTimeout(() => this.update(this.editor.view)); + }; + + blurHandler = ({ event }: { event: FocusEvent }) => { + if (this.preventHide) { + this.preventHide = false; + + return; + } + + if ( + event?.relatedTarget && + this.formattingToolbar.element?.parentNode?.contains( + event.relatedTarget as Node + ) + ) { + return; + } + + if (this.toolbarIsOpen) { + this.formattingToolbar.hide(); + this.toolbarIsOpen = false; + } + }; + + update(view: EditorView, oldState?: EditorState) { + const { state, composing } = view; + const { doc, selection } = state; + const isSame = + oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection); + + if (composing || isSame) { + return; + } + + // support for CellSelections + const { ranges } = selection; + const from = Math.min(...ranges.map((range) => range.$from.pos)); + const to = Math.max(...ranges.map((range) => range.$to.pos)); + + const shouldShow = this.shouldShow?.({ + editor: this.editor, + view, + state, + oldState, + from, + to, + }); + + // Checks if menu should be shown. + if ( + !this.toolbarIsOpen && + !this.preventShow && + (shouldShow || this.preventHide) + ) { + this.formattingToolbar.render(this.getDynamicParams(), true); + this.toolbarIsOpen = true; + + // TODO: Is this necessary? Also for other menu plugins. + // Listener stops focus moving to the menu on click. + this.formattingToolbar.element!.addEventListener("mousedown", (event) => + event.preventDefault() + ); + + return; + } + + // Checks if menu should be updated. + if ( + this.toolbarIsOpen && + !this.preventShow && + (shouldShow || this.preventHide) + ) { + this.formattingToolbar.render(this.getDynamicParams(), false); + return; + } + + // Checks if menu should be hidden. + if ( + this.toolbarIsOpen && + !this.preventHide && + (!shouldShow || this.preventShow) + ) { + this.formattingToolbar.hide(); + this.toolbarIsOpen = false; + + // Listener stops focus moving to the menu on click. + this.formattingToolbar.element!.removeEventListener( + "mousedown", + (event) => event.preventDefault() + ); + + return; + } + } + + destroy() { + this.view.dom.removeEventListener("mousedown", this.viewMousedownHandler); + this.view.dom.removeEventListener("mouseup", this.viewMouseupHandler); + this.view.dom.removeEventListener("dragstart", this.dragstartHandler); + + this.editor.off("focus", this.focusHandler); + this.editor.off("blur", this.blurHandler); + } + + getSelectionBoundingBox() { + const { state } = this.editor.view; + const { selection } = state; + + // support for CellSelections + const { ranges } = selection; + const from = Math.min(...ranges.map((range) => range.$from.pos)); + const to = Math.max(...ranges.map((range) => range.$to.pos)); + + if (isNodeSelection(selection)) { + const node = this.editor.view.nodeDOM(from) as HTMLElement; + + if (node) { + return node.getBoundingClientRect(); + } + } + + return posToDOMRect(this.editor.view, from, to); + } + + getStaticParams(): FormattingToolbarStaticParams { + return { + toggleBold: () => { + this.editor.view.focus(); + this.editor.commands.toggleBold(); + }, + toggleItalic: () => { + this.editor.view.focus(); + this.editor.commands.toggleItalic(); + }, + toggleUnderline: () => { + this.editor.view.focus(); + this.editor.commands.toggleUnderline(); + }, + toggleStrike: () => { + this.editor.view.focus(); + this.editor.commands.toggleStrike(); + }, + setHyperlink: (url: string, text?: string) => { + if (url === "") { + return; + } + + let { from, to } = this.editor.state.selection; + + if (!text) { + text = this.editor.state.doc.textBetween(from, to); + } + + const mark = this.editor.schema.mark("link", { href: url }); + + this.editor.view.dispatch( + this.editor.view.state.tr + .insertText(text, from, to) + .addMark(from, from + text.length, mark) + ); + this.editor.view.focus(); + }, + setBlockType: (type: BlockContentType) => { + this.editor.view.focus(); + this.editor.commands.BNSetContentType( + this.editor.state.selection.from, + type + ); + }, + }; + } + + getDynamicParams(): FormattingToolbarDynamicParams { + return { + boldIsActive: this.editor.isActive("bold"), + italicIsActive: this.editor.isActive("italic"), + underlineIsActive: this.editor.isActive("underline"), + strikeIsActive: this.editor.isActive("strike"), + hyperlinkIsActive: this.editor.isActive("link"), + activeHyperlinkUrl: this.editor.getAttributes("link").href + ? this.editor.getAttributes("link").href + : "", + activeHyperlinkText: this.editor.state.doc.textBetween( + this.editor.state.selection.from, + this.editor.state.selection.to + ), + activeBlockType: { + name: this.editor.state.selection.$from.node().type.name, + attrs: this.editor.state.selection.$from.node().attrs, + } as Required, + selectionBoundingBox: this.getSelectionBoundingBox(), + }; + } +} + +export const createFormattingToolbarPlugin = ( + options: FormattingToolbarPluginProps +) => { + return new Plugin({ + key: new PluginKey("FormattingToolbarPlugin"), + view: (view) => new FormattingToolbarView({ view, ...options }), + }); +}; diff --git a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkMark.ts b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkMark.ts new file mode 100644 index 0000000000..e61cc46dfd --- /dev/null +++ b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkMark.ts @@ -0,0 +1,28 @@ +import { Link } from "@tiptap/extension-link"; +import { + createHyperlinkToolbarPlugin, + HyperlinkToolbarPluginProps, +} from "./HyperlinkToolbarPlugin"; + +/** + * This custom link includes a special menu for editing/deleting/opening the link. + * The menu will be triggered by hovering over the link with the mouse, + * or by moving the cursor inside the link text + */ +const Hyperlink = Link.extend({ + priority: 500, + addProseMirrorPlugins() { + if (!this.options.hyperlinkToolbarFactory) { + throw new Error("UI Element factory not defined for HyperlinkMark"); + } + + return [ + ...(this.parent?.() || []), + createHyperlinkToolbarPlugin(this.editor, { + hyperlinkToolbarFactory: this.options.hyperlinkToolbarFactory, + }), + ]; + }, +}); + +export default Hyperlink; diff --git a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes.ts b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes.ts new file mode 100644 index 0000000000..77c8cc2b41 --- /dev/null +++ b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes.ts @@ -0,0 +1,19 @@ +import { EditorElement, ElementFactory } from "../../shared/EditorElement"; + +export type HyperlinkToolbarStaticParams = { + editHyperlink: (url: string, text: string) => void; + deleteHyperlink: () => void; +}; + +export type HyperlinkToolbarDynamicParams = { + url: string; + text: string; + + boundingBox: DOMRect; +}; + +export type HyperlinkToolbar = EditorElement; +export type HyperlinkToolbarFactory = ElementFactory< + HyperlinkToolbarStaticParams, + HyperlinkToolbarDynamicParams +>; diff --git a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts new file mode 100644 index 0000000000..f5425b6616 --- /dev/null +++ b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts @@ -0,0 +1,251 @@ +import { Editor, getMarkRange, posToDOMRect, Range } from "@tiptap/core"; +import { Mark } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; +import { + HyperlinkToolbar, + HyperlinkToolbarDynamicParams, + HyperlinkToolbarFactory, + HyperlinkToolbarStaticParams, +} from "./HyperlinkToolbarFactoryTypes"; +const PLUGIN_KEY = new PluginKey("HyperlinkToolbarPlugin"); + +export type HyperlinkToolbarPluginProps = { + hyperlinkToolbarFactory: HyperlinkToolbarFactory; +}; + +export type HyperlinkToolbarViewProps = { + editor: Editor; + hyperlinkToolbarFactory: HyperlinkToolbarFactory; +}; + +class HyperlinkToolbarView { + editor: Editor; + + hyperlinkToolbar: HyperlinkToolbar; + + menuUpdateTimer: NodeJS.Timeout | undefined; + startMenuUpdateTimer: () => void; + stopMenuUpdateTimer: () => void; + + mouseHoveredHyperlinkMark: Mark | undefined; + mouseHoveredHyperlinkMarkRange: Range | undefined; + + keyboardHoveredHyperlinkMark: Mark | undefined; + keyboardHoveredHyperlinkMarkRange: Range | undefined; + + hyperlinkMark: Mark | undefined; + hyperlinkMarkRange: Range | undefined; + + constructor({ editor, hyperlinkToolbarFactory }: HyperlinkToolbarViewProps) { + this.editor = editor; + + this.hyperlinkToolbar = hyperlinkToolbarFactory(this.getStaticParams()); + + this.startMenuUpdateTimer = () => { + this.menuUpdateTimer = setTimeout(() => { + this.update(); + }, 250); + }; + + this.stopMenuUpdateTimer = () => { + if (this.menuUpdateTimer) { + clearTimeout(this.menuUpdateTimer); + this.menuUpdateTimer = undefined; + } + + return false; + }; + + editor.view.dom.addEventListener("mouseover", (event) => { + // Resets the hyperlink mark currently hovered by the mouse cursor. + this.mouseHoveredHyperlinkMark = undefined; + this.mouseHoveredHyperlinkMarkRange = undefined; + + this.stopMenuUpdateTimer(); + + if ( + event.target instanceof HTMLAnchorElement && + event.target.nodeName === "A" + ) { + // Finds link mark at the hovered element's position to update mouseHoveredHyperlinkMark and + // mouseHoveredHyperlinkMarkRange. + const hoveredHyperlinkElement = event.target; + const posInHoveredHyperlinkMark = + editor.view.posAtDOM(hoveredHyperlinkElement, 0) + 1; + const resolvedPosInHoveredHyperlinkMark = editor.state.doc.resolve( + posInHoveredHyperlinkMark + ); + const marksAtPos = resolvedPosInHoveredHyperlinkMark.marks(); + + for (const mark of marksAtPos) { + if (mark.type.name === editor.schema.mark("link").type.name) { + this.mouseHoveredHyperlinkMark = mark; + this.mouseHoveredHyperlinkMarkRange = + getMarkRange( + resolvedPosInHoveredHyperlinkMark, + mark.type, + mark.attrs + ) || undefined; + + break; + } + } + } + + this.startMenuUpdateTimer(); + + return false; + }); + } + + update() { + if (!this.editor.view.hasFocus()) { + return; + } + + // Saves the currently hovered hyperlink mark before it's updated. + const prevHyperlinkMark = this.hyperlinkMark; + + // Resets the currently hovered hyperlink mark. + this.hyperlinkMark = undefined; + this.hyperlinkMarkRange = undefined; + + // Resets the hyperlink mark currently hovered by the keyboard cursor. + this.keyboardHoveredHyperlinkMark = undefined; + this.keyboardHoveredHyperlinkMarkRange = undefined; + + // Finds link mark at the editor selection's position to update keyboardHoveredHyperlinkMark and + // keyboardHoveredHyperlinkMarkRange. + if (this.editor.state.selection.empty) { + const marksAtPos = this.editor.state.selection.$from.marks(); + + for (const mark of marksAtPos) { + if (mark.type.name === this.editor.schema.mark("link").type.name) { + this.keyboardHoveredHyperlinkMark = mark; + this.keyboardHoveredHyperlinkMarkRange = + getMarkRange( + this.editor.state.selection.$from, + mark.type, + mark.attrs + ) || undefined; + + break; + } + } + } + + if (this.mouseHoveredHyperlinkMark) { + this.hyperlinkMark = this.mouseHoveredHyperlinkMark; + this.hyperlinkMarkRange = this.mouseHoveredHyperlinkMarkRange; + } + + // Keyboard cursor position takes precedence over mouse hovered hyperlink. + if (this.keyboardHoveredHyperlinkMark) { + this.hyperlinkMark = this.keyboardHoveredHyperlinkMark; + this.hyperlinkMarkRange = this.keyboardHoveredHyperlinkMarkRange; + } + + if (this.hyperlinkMark) { + this.getDynamicParams(); + + // Shows menu. + if (!prevHyperlinkMark) { + this.hyperlinkToolbar.render(this.getDynamicParams(), true); + + this.hyperlinkToolbar.element?.addEventListener( + "mouseleave", + this.startMenuUpdateTimer + ); + this.hyperlinkToolbar.element?.addEventListener( + "mouseenter", + this.stopMenuUpdateTimer + ); + + return; + } + + // Updates menu. + this.hyperlinkToolbar.render(this.getDynamicParams(), false); + } + + // Hides menu. + if (!this.hyperlinkMark && prevHyperlinkMark) { + this.hyperlinkToolbar.element?.removeEventListener( + "mouseleave", + this.startMenuUpdateTimer + ); + this.hyperlinkToolbar.element?.removeEventListener( + "mouseenter", + this.stopMenuUpdateTimer + ); + + this.hyperlinkToolbar.hide(); + + return; + } + } + + getStaticParams(): HyperlinkToolbarStaticParams { + return { + editHyperlink: (url: string, text: string) => { + const tr = this.editor.view.state.tr.insertText( + text, + this.hyperlinkMarkRange!.from, + this.hyperlinkMarkRange!.to + ); + tr.addMark( + this.hyperlinkMarkRange!.from, + this.hyperlinkMarkRange!.from + text.length, + this.editor.schema.mark("link", { href: url }) + ); + this.editor.view.dispatch(tr); + this.editor.view.focus(); + + this.hyperlinkToolbar.hide(); + }, + deleteHyperlink: () => { + this.editor.view.dispatch( + this.editor.view.state.tr + .removeMark( + this.hyperlinkMarkRange!.from, + this.hyperlinkMarkRange!.to, + this.hyperlinkMark!.type + ) + .setMeta("preventAutolink", true) + ); + this.editor.view.focus(); + + this.hyperlinkToolbar.hide(); + }, + }; + } + + getDynamicParams(): HyperlinkToolbarDynamicParams { + return { + url: this.hyperlinkMark!.attrs.href, + text: this.editor.view.state.doc.textBetween( + this.hyperlinkMarkRange!.from, + this.hyperlinkMarkRange!.to + ), + boundingBox: posToDOMRect( + this.editor.view, + this.hyperlinkMarkRange!.from, + this.hyperlinkMarkRange!.to + ), + }; + } +} + +export const createHyperlinkToolbarPlugin = ( + editor: Editor, + options: HyperlinkToolbarPluginProps +) => { + return new Plugin({ + key: PLUGIN_KEY, + view: () => + new HyperlinkToolbarView({ + editor: editor, + hyperlinkToolbarFactory: options.hyperlinkToolbarFactory, + }), + }); +}; diff --git a/packages/core/src/extensions/Hyperlinks/HyperlinkMark.tsx b/packages/core/src/extensions/Hyperlinks/HyperlinkMark.tsx deleted file mode 100644 index 4431c493d5..0000000000 --- a/packages/core/src/extensions/Hyperlinks/HyperlinkMark.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Link } from "@tiptap/extension-link"; -import { createHyperlinkMenuPlugin } from "./HyperlinkMenuPlugin"; - -/** - * This custom link includes a special menu for editing/deleting/opening the link. - * The menu will be triggered by hovering over the link with the mouse, - * or by moving the cursor inside the link text - */ -const Hyperlink = Link.extend({ - priority: 500, - addProseMirrorPlugins() { - return [...(this.parent?.() || []), createHyperlinkMenuPlugin()]; - }, -}); - -export default Hyperlink; diff --git a/packages/core/src/extensions/Hyperlinks/HyperlinkMenuPlugin.tsx b/packages/core/src/extensions/Hyperlinks/HyperlinkMenuPlugin.tsx deleted file mode 100644 index cd8a62e1ae..0000000000 --- a/packages/core/src/extensions/Hyperlinks/HyperlinkMenuPlugin.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { MantineProvider } from "@mantine/core"; -import Tippy from "@tippyjs/react"; -import { getMarkRange } from "@tiptap/core"; -import { Mark, ResolvedPos } from "prosemirror-model"; -import { Plugin, PluginKey } from "prosemirror-state"; -import ReactDOM from "react-dom"; -import { BlockNoteTheme } from "../../BlockNoteTheme"; -import { HyperlinkMenu } from "./menus/HyperlinkMenu"; -const PLUGIN_KEY = new PluginKey("HyperlinkMenuPlugin"); - -export const createHyperlinkMenuPlugin = () => { - // as we always use Tippy appendTo(document.body), we can just create an element - // that we use for ReactDOM, but it isn't used anywhere (except by React internally) - const fakeRenderTarget = document.createElement("div"); - - let hoveredLink: HTMLAnchorElement | undefined; - let menuState: "cursor-based" | "mouse-based" | "hidden" = "hidden"; - let nextTippyKey = 0; - - return new Plugin({ - key: PLUGIN_KEY, - view() { - return { - update: async (view, _prevState) => { - const selection = view.state.selection; - if (selection.from !== selection.to) { - // don't show menu when we have an active selection - if (menuState !== "hidden") { - menuState = "hidden"; - ReactDOM.render(<>, fakeRenderTarget); - } - return; - } - - let pos: number | undefined; - let resPos: ResolvedPos | undefined; - let linkMark: Mark | undefined; - let basedOnCursorPos = false; - if (hoveredLink) { - pos = view.posAtDOM(hoveredLink.firstChild!, 0); - resPos = view.state.doc.resolve(pos); - // based on https://github.com/ueberdosis/tiptap/blob/main/packages/core/src/helpers/getMarkRange.ts - const start = resPos.parent.childAfter(resPos.parentOffset).node; - linkMark = start?.marks.find((m) => m.type.name.startsWith("link")); - } - - if ( - !linkMark && - (view.hasFocus() || menuState === "cursor-based") // prevents re-opening menu after submission. Only open cursor-based menu if editor has focus - ) { - // no hovered link mark, try get from cursor position - pos = selection.from; - resPos = view.state.doc.resolve(pos); - const start = resPos.parent.childAfter(resPos.parentOffset).node; - linkMark = start?.marks.find((m) => m.type.name.startsWith("link")); - basedOnCursorPos = true; - } - - if (!linkMark || !pos || !resPos) { - // The mouse-based popup takes care of hiding itself (tippy) - // Because the cursor-based popup is has "showOnCreate", we want to hide it manually - // if the cursor moves way - if (menuState === "cursor-based") { - menuState = "hidden"; - ReactDOM.render(<>, fakeRenderTarget); - } - return; - } - - const range = getMarkRange(resPos, linkMark.type, linkMark.attrs); - if (!range) { - return; - } - const text = view.state.doc.textBetween(range.from, range.to); - const url = linkMark.attrs.href; - - const anchorPos = { - // use the 'median' position of the range - ...view.coordsAtPos(Math.round((range.from + range.to) / 2)), - height: 0, // needed to satisfy types - width: 0, - }; - - const foundLinkMark = linkMark; // typescript workaround for event handlers - - // A URL has to begin with http(s):// to be interpreted as an absolute path - const editHandler = (href: string, text: string) => { - menuState = "hidden"; - ReactDOM.render(<>, fakeRenderTarget); - - // update the mark with new href - (foundLinkMark as any).attrs = { ...foundLinkMark.attrs, href }; // TODO: invalid assign to attrs - // insertText actually replaces the range with text - const tr = view.state.tr.insertText(text, range.from, range.to); - // the former range.to is no longer in use - tr.addMark(range.from, range.from + text.length, foundLinkMark); - view.dispatch(tr); - }; - - const removeHandler = () => { - view.dispatch( - view.state.tr - .removeMark(range.from, range.to, foundLinkMark.type) - .setMeta("preventAutolink", true) - ); - }; - - const hyperlinkMenu = ( - - anchorPos as any} - content={ - - } - onHide={() => { - nextTippyKey++; - menuState = "hidden"; - }} - aria={{ expanded: false }} - interactive={true} - interactiveBorder={30} - triggerTarget={hoveredLink} - showOnCreate={basedOnCursorPos} - appendTo={document.body}> -
-
-
- ); - ReactDOM.render(hyperlinkMenu, fakeRenderTarget); - menuState = basedOnCursorPos ? "cursor-based" : "mouse-based"; - }, - }; - }, - - props: { - handleDOMEvents: { - // update view when an is hovered over - mouseover(view, event: any) { - const newHoveredLink = - event.target instanceof HTMLAnchorElement && - event.target.nodeName === "A" - ? event.target - : undefined; - - if (newHoveredLink !== hoveredLink) { - // dispatch a meta transaction to make sure the view gets updated - hoveredLink = newHoveredLink; - - view.dispatch( - view.state.tr.setMeta(PLUGIN_KEY, { hoveredLinkChanged: true }) - ); - } - return false; - }, - }, - }, - }); -}; diff --git a/packages/core/src/extensions/Hyperlinks/menus/HoverHyperlinkMenu.tsx b/packages/core/src/extensions/Hyperlinks/menus/HoverHyperlinkMenu.tsx deleted file mode 100644 index acdaf8c9dc..0000000000 --- a/packages/core/src/extensions/Hyperlinks/menus/HoverHyperlinkMenu.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { RiExternalLinkFill, RiLinkUnlink } from "react-icons/ri"; -import { Toolbar } from "../../../shared/components/toolbar/Toolbar"; -import { ToolbarButton } from "../../../shared/components/toolbar/ToolbarButton"; - -type HoverHyperlinkMenuProps = { - url: string; - edit: () => void; - remove: () => void; -}; - -/** - * Menu which opens when hovering an existing hyperlink. - * Provides buttons for editing, opening, and removing the hyperlink. - */ -export const HoverHyperlinkMenu = (props: HoverHyperlinkMenuProps) => { - return ( - - - Edit Link - - { - window.open(props.url, "_blank"); - }} - icon={RiExternalLinkFill} - /> - - - ); -}; diff --git a/packages/core/src/extensions/Hyperlinks/menus/HyperlinkMenu.tsx b/packages/core/src/extensions/Hyperlinks/menus/HyperlinkMenu.tsx deleted file mode 100644 index 4748334296..0000000000 --- a/packages/core/src/extensions/Hyperlinks/menus/HyperlinkMenu.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useState } from "react"; -import Tippy from "@tippyjs/react"; -import { EditHyperlinkMenu } from "./EditHyperlinkMenu"; -import { HoverHyperlinkMenu } from "./HoverHyperlinkMenu"; -import rootStyles from "../../../root.module.css"; - -type HyperlinkMenuProps = { - url: string; - text: string; - pos: { - height: number; - width: number; - left: number; - right: number; - top: number; - bottom: number; - }; - update: (url: string, text: string) => void; - remove: () => void; -}; - -/** - * Main menu component for the hyperlink extension. - * Either renders a menu to create/edit a hyperlink, or a menu to interact with it on mouse hover. - */ -export const HyperlinkMenu = (props: HyperlinkMenuProps) => { - const [isEditing, setIsEditing] = useState(false); - - const editHyperlinkMenu = ( - props.pos as any} - content={ - - } - interactive={true} - interactiveBorder={30} - showOnCreate={true} - trigger={"click"} // so that we don't hide on mouse out - hideOnClick - className={rootStyles.bnRoot} - appendTo={document.body}> -
-
- ); - - const hoverHyperlinkMenu = ( - setIsEditing(true)} - remove={props.remove} - /> - ); - - if (isEditing) { - return editHyperlinkMenu; - } else { - return hoverHyperlinkMenu; - } -}; diff --git a/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts b/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts index 4a984d018b..aeb8d21cfb 100644 --- a/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts +++ b/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts @@ -1,7 +1,7 @@ import { Editor, Extension } from "@tiptap/core"; import { Node as ProsemirrorNode } from "prosemirror-model"; -import { Decoration, DecorationSet } from "prosemirror-view"; import { Plugin } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; import { SlashMenuPluginKey } from "../SlashMenu/SlashMenuExtension"; /** @@ -78,7 +78,7 @@ export const Placeholder = Extension.create({ } // If slash menu is of drag type and active, show the filter placeholder - if (menuState.type === "drag" && menuState.active) { + if (menuState?.type === "drag" && menuState?.active) { classes.push(this.options.isFilterClass); } // using widget, didn't work (caret position bug) diff --git a/packages/core/src/extensions/SlashMenu/SlashMenuExtension.ts b/packages/core/src/extensions/SlashMenu/SlashMenuExtension.ts index e2c0c4bafa..35757879f0 100644 --- a/packages/core/src/extensions/SlashMenu/SlashMenuExtension.ts +++ b/packages/core/src/extensions/SlashMenu/SlashMenuExtension.ts @@ -1,11 +1,13 @@ import { Extension } from "@tiptap/core"; +import { PluginKey } from "prosemirror-state"; import { createSuggestionPlugin } from "../../shared/plugins/suggestion/SuggestionPlugin"; +import { SuggestionsMenuFactory } from "../../shared/plugins/suggestion/SuggestionsMenuFactoryTypes"; import defaultCommands from "./defaultCommands"; import { SlashMenuItem } from "./SlashMenuItem"; -import { PluginKey } from "prosemirror-state"; export type SlashMenuOptions = { commands: { [key: string]: SlashMenuItem }; + slashMenuFactory: SuggestionsMenuFactory | undefined; }; export const SlashMenuPluginKey = new PluginKey("suggestions-slash-commands"); @@ -16,15 +18,21 @@ export const SlashMenuExtension = Extension.create({ addOptions() { return { commands: defaultCommands, + slashMenuFactory: undefined, // TODO: fix undefined }; }, addProseMirrorPlugins() { + if (!this.options.slashMenuFactory) { + throw new Error("UI Element factory not defined for SlashMenuExtension"); + } + return [ createSuggestionPlugin({ pluginKey: SlashMenuPluginKey, editor: this.editor, char: "/", + suggestionsMenuFactory: this.options.slashMenuFactory!, items: (query) => { const commands = []; diff --git a/packages/core/src/extensions/SlashMenu/SlashMenuItem.ts b/packages/core/src/extensions/SlashMenu/SlashMenuItem.ts index 0124ecb76b..576038b894 100644 --- a/packages/core/src/extensions/SlashMenu/SlashMenuItem.ts +++ b/packages/core/src/extensions/SlashMenu/SlashMenuItem.ts @@ -1,6 +1,5 @@ import { Editor, Range } from "@tiptap/core"; -import SuggestionItem from "../../shared/plugins/suggestion/SuggestionItem"; -import { IconType } from "react-icons"; +import { SuggestionItem } from "../../shared/plugins/suggestion/SuggestionItem"; export type SlashMenuCallback = (editor: Editor, range: Range) => boolean; @@ -40,7 +39,6 @@ export class SlashMenuItem implements SuggestionItem { public readonly group: SlashMenuGroups, public readonly execute: SlashMenuCallback, public readonly aliases: string[] = [], - public readonly icon?: IconType, public readonly hint?: string, public readonly shortcut?: string ) { diff --git a/packages/core/src/extensions/SlashMenu/defaultCommands.tsx b/packages/core/src/extensions/SlashMenu/defaultCommands.tsx index f5024a56cb..d5064b2f55 100644 --- a/packages/core/src/extensions/SlashMenu/defaultCommands.tsx +++ b/packages/core/src/extensions/SlashMenu/defaultCommands.tsx @@ -1,12 +1,4 @@ -import { - RiH1, - RiH2, - RiH3, - RiListOrdered, - RiListUnordered, - RiText, -} from "react-icons/ri"; -import { formatKeyboardShortcut } from "../../utils"; +import { formatKeyboardShortcut } from "../../shared/utils"; import { SlashMenuGroups, SlashMenuItem } from "./SlashMenuItem"; /** @@ -22,13 +14,15 @@ const defaultCommands: { [key: string]: SlashMenuItem } = { .chain() .focus() .deleteRange(range) - .BNCreateBlockOrSetContentType(range.from, "headingContent", { - headingLevel: "1", + .BNCreateBlockOrSetContentType(range.from, { + name: "headingContent", + attrs: { + headingLevel: "1", + }, }) .run(); }, ["h", "heading1", "h1"], - RiH1, "Used for a top-level heading", formatKeyboardShortcut("Mod-Alt-1") ), @@ -42,13 +36,15 @@ const defaultCommands: { [key: string]: SlashMenuItem } = { .chain() .focus() .deleteRange(range) - .BNCreateBlockOrSetContentType(range.from, "headingContent", { - headingLevel: "2", + .BNCreateBlockOrSetContentType(range.from, { + name: "headingContent", + attrs: { + headingLevel: "2", + }, }) .run(); }, ["h2", "heading2", "subheading"], - RiH2, "Used for key sections", formatKeyboardShortcut("Mod-Alt-2") ), @@ -62,13 +58,15 @@ const defaultCommands: { [key: string]: SlashMenuItem } = { .chain() .focus() .deleteRange(range) - .BNCreateBlockOrSetContentType(range.from, "headingContent", { - headingLevel: "3", + .BNCreateBlockOrSetContentType(range.from, { + name: "headingContent", + attrs: { + headingLevel: "3", + }, }) .run(); }, ["h3", "heading3", "subheading"], - RiH3, "Used for subsections and group headings", formatKeyboardShortcut("Mod-Alt-3") ), @@ -82,13 +80,15 @@ const defaultCommands: { [key: string]: SlashMenuItem } = { .chain() .focus() .deleteRange(range) - .BNCreateBlockOrSetContentType(range.from, "listItemContent", { - listItemType: "ordered", + .BNCreateBlockOrSetContentType(range.from, { + name: "listItemContent", + attrs: { + listItemType: "ordered", + }, }) .run(); }, ["li", "list", "numberedlist", "numbered list"], - RiListOrdered, "Used to display a numbered list", formatKeyboardShortcut("Mod-Shift-7") ), @@ -102,13 +102,15 @@ const defaultCommands: { [key: string]: SlashMenuItem } = { .chain() .focus() .deleteRange(range) - .BNCreateBlockOrSetContentType(range.from, "listItemContent", { - listItemType: "unordered", + .BNCreateBlockOrSetContentType(range.from, { + name: "listItemContent", + attrs: { + listItemType: "unordered", + }, }) .run(); }, ["ul", "list", "bulletlist", "bullet list"], - RiListUnordered, "Used to display an unordered list", formatKeyboardShortcut("Mod-Shift-8") ), @@ -122,11 +124,10 @@ const defaultCommands: { [key: string]: SlashMenuItem } = { .chain() .focus() .deleteRange(range) - .BNCreateBlockOrSetContentType(range.from, "textContent") + .BNCreateBlockOrSetContentType(range.from, { name: "textContent" }) .run(); }, ["p"], - RiText, "Used for the body of your document", formatKeyboardShortcut("Mod-Alt-0") ), diff --git a/packages/core/src/fonts-inter.css b/packages/core/src/fonts-inter.css deleted file mode 100644 index 8a2be5de51..0000000000 --- a/packages/core/src/fonts-inter.css +++ /dev/null @@ -1,94 +0,0 @@ -/* Generated using https://google-webfonts-helper.herokuapp.com/fonts/inter?subsets=latin */ - -/* inter-100 - latin */ -@font-face { - font-family: "Inter"; - font-style: normal; - font-weight: 100; - src: local(""), - url("./assets/inter-v12-latin/inter-v12-latin-100.woff2") format("woff2"), - /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./assets/inter-v12-latin/inter-v12-latin-100.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ -} -/* inter-200 - latin */ -@font-face { - font-family: "Inter"; - font-style: normal; - font-weight: 200; - src: local(""), - url("./assets/inter-v12-latin/inter-v12-latin-200.woff2") format("woff2"), - /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./assets/inter-v12-latin/inter-v12-latin-200.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ -} -/* inter-300 - latin */ -@font-face { - font-family: "Inter"; - font-style: normal; - font-weight: 300; - src: local(""), - url("./assets/inter-v12-latin/inter-v12-latin-300.woff2") format("woff2"), - /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./assets/inter-v12-latin/inter-v12-latin-300.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ -} -/* inter-regular - latin */ -@font-face { - font-family: "Inter"; - font-style: normal; - font-weight: 400; - src: local(""), - url("./assets/inter-v12-latin/inter-v12-latin-regular.woff2") - format("woff2"), - /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./assets/inter-v12-latin/inter-v12-latin-regular.woff") - format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ -} -/* inter-500 - latin */ -@font-face { - font-family: "Inter"; - font-style: normal; - font-weight: 500; - src: local(""), - url("./assets/inter-v12-latin/inter-v12-latin-500.woff2") format("woff2"), - /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./assets/inter-v12-latin/inter-v12-latin-500.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ -} -/* inter-600 - latin */ -@font-face { - font-family: "Inter"; - font-style: normal; - font-weight: 600; - src: local(""), - url("./assets/inter-v12-latin/inter-v12-latin-600.woff2") format("woff2"), - /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./assets/inter-v12-latin/inter-v12-latin-600.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ -} -/* inter-700 - latin */ -@font-face { - font-family: "Inter"; - font-style: normal; - font-weight: 700; - src: local(""), - url("./assets/inter-v12-latin/inter-v12-latin-700.woff2") format("woff2"), - /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./assets/inter-v12-latin/inter-v12-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ -} -/* inter-800 - latin */ -@font-face { - font-family: "Inter"; - font-style: normal; - font-weight: 800; - src: local(""), - url("./assets/inter-v12-latin/inter-v12-latin-800.woff2") format("woff2"), - /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./assets/inter-v12-latin/inter-v12-latin-800.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ -} -/* inter-900 - latin */ -@font-face { - font-family: "Inter"; - font-style: normal; - font-weight: 900; - src: local(""), - url("./assets/inter-v12-latin/inter-v12-latin-900.woff2") format("woff2"), - /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./assets/inter-v12-latin/inter-v12-latin-900.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ -} diff --git a/packages/core/src/globals.css b/packages/core/src/globals.css deleted file mode 100644 index 636c561a70..0000000000 --- a/packages/core/src/globals.css +++ /dev/null @@ -1,28 +0,0 @@ -@import url("fonts-inter.css"); - -/* TODO: should not be on root as this changes entire consuming application */ - -:root { - /* Define a set of colors to be used throughout the app for consistency - see https://atlassian.design/foundations/color for more info */ - --N800: #172b4d; /* Dark neutral used for tooltips and text on light background */ - --N40: #dfe1e6; /* Light neutral used for subtle borders and text on dark background */ - - font-family: "Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, - "Open Sans", "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", - "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - color: rgb(60, 65, 73); -} - -button { - font-family: "Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, - "Open Sans", "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", - "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - color: rgb(60, 65, 73); -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4ec3fed60a..f108f64c36 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,9 @@ -import "./globals.css"; - +export * from "./BlockNoteEditor"; export * from "./BlockNoteExtensions"; -export * from "./EditorContent"; -export * from "./useEditor"; +export type { BlockContentType } from "./extensions/Blocks/nodes/Block"; +export * from "./extensions/FormattingToolbar/FormattingToolbarFactoryTypes"; +export * from "./extensions/DraggableBlocks/BlockSideMenuFactoryTypes"; +export * from "./extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes"; +export * from "./extensions/SlashMenu/SlashMenuItem"; +export type { SuggestionItem } from "./shared/plugins/suggestion/SuggestionItem"; +export * from "./shared/plugins/suggestion/SuggestionsMenuFactoryTypes"; diff --git a/packages/core/src/root.module.css b/packages/core/src/root.module.css deleted file mode 100644 index e2f9877bad..0000000000 --- a/packages/core/src/root.module.css +++ /dev/null @@ -1,19 +0,0 @@ -/* -bnRoot should be applied to all top-level elements - -This includes the Prosemirror editor, but also
element such as -Tippy popups that are appended to document.body directly -*/ -.bnRoot { - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; -} - -.bnRoot *, -.bnRoot *::before, -.bnRoot *::after { - -webkit-box-sizing: inherit; - -moz-box-sizing: inherit; - box-sizing: inherit; -} diff --git a/packages/core/src/shared/EditorElement.ts b/packages/core/src/shared/EditorElement.ts new file mode 100644 index 0000000000..57071e2db9 --- /dev/null +++ b/packages/core/src/shared/EditorElement.ts @@ -0,0 +1,10 @@ +export type EditorElement> = { + element: HTMLElement | undefined; + render: (params: ElementDynamicParams, isHidden: boolean) => void; + hide: () => void; +}; + +export type ElementFactory< + ElementStaticParams extends Record, + ElementDynamicParams extends Record +> = (staticParams: ElementStaticParams) => EditorElement; diff --git a/packages/core/src/shared/components/tooltip/TooltipContent.module.css b/packages/core/src/shared/components/tooltip/TooltipContent.module.css deleted file mode 100644 index c687d30ccf..0000000000 --- a/packages/core/src/shared/components/tooltip/TooltipContent.module.css +++ /dev/null @@ -1,15 +0,0 @@ -.tooltip { - color: var(--N40); - background-color: var(--N800); - box-shadow: 0 0 10px rgba(253, 254, 255, 0.8), - 0 0 3px rgba(253, 254, 255, 0.4); - border-radius: 2px; - font-size: smaller; - text-align: center; - padding: 4px; -} - -.secondaryText { - font-weight: 400; - opacity: 0.6; -} diff --git a/packages/core/src/shared/components/tooltip/TooltipContent.tsx b/packages/core/src/shared/components/tooltip/TooltipContent.tsx deleted file mode 100644 index 2a57e841b8..0000000000 --- a/packages/core/src/shared/components/tooltip/TooltipContent.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import styles from "./TooltipContent.module.css"; - -/** - * Helper for the tooltip for inline bubble menu buttons. - * - * Often used to display a tooltip showing the command name + keyboard shortcut, e.g.: - * - * Bold - * Ctrl+B - * - * TODO: maybe use default Tippy styles instead? - */ -export const TooltipContent = (props: { - mainTooltip: string; - secondaryTooltip?: string; -}) => ( -
-
{props.mainTooltip}
- {props.secondaryTooltip && ( -
{props.secondaryTooltip}
- )} -
-); diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionItem.ts b/packages/core/src/shared/plugins/suggestion/SuggestionItem.ts index 9f22687fb8..837089e32b 100644 --- a/packages/core/src/shared/plugins/suggestion/SuggestionItem.ts +++ b/packages/core/src/shared/plugins/suggestion/SuggestionItem.ts @@ -1,9 +1,7 @@ -import { IconType } from "react-icons"; - /** * A generic interface used in all suggestion menus (slash menu, mentions, etc) */ -export default interface SuggestionItem { +export interface SuggestionItem { /** * The name of the item */ @@ -14,11 +12,6 @@ export default interface SuggestionItem { */ groupName: string; - /** - * The react icon - */ - icon?: IconType; - hint?: string; shortcut?: string; diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionListReactRenderer.tsx b/packages/core/src/shared/plugins/suggestion/SuggestionListReactRenderer.tsx deleted file mode 100644 index 7e712ff48e..0000000000 --- a/packages/core/src/shared/plugins/suggestion/SuggestionListReactRenderer.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { Editor as ReactEditor, ReactRenderer } from "@tiptap/react"; -import { Editor } from "@tiptap/core"; -import tippy, { Instance } from "tippy.js"; -import SuggestionItem from "./SuggestionItem"; -import { - SuggestionList, - SuggestionListProps, -} from "./components/SuggestionList"; -import { BlockNoteTheme } from "../../../BlockNoteTheme"; -import { MantineProvider } from "@mantine/core"; - -/** - * The interface that each suggestion renderer should conform to. - */ -export interface SuggestionRenderer { - /** - * Disposes of the suggestion menu. - */ - onExit?: (props: SuggestionRendererProps) => void; - - /** - * Updates the suggestion menu. - * - * This function should be called when the renderer's `props` change, - * after `onStart` has been called. - */ - onUpdate?: (props: SuggestionRendererProps) => void; - - /** - * Creates and displays a new suggestion menu popup. - */ - onStart?: (props: SuggestionRendererProps) => void; - - /** - * Function for handling key events - */ - onKeyDown?: (event: KeyboardEvent) => boolean; - - /** - * The DOM Element representing the suggestion menu - */ - getComponent: () => Element | undefined; -} - -export type SuggestionRendererProps = { - /** - * Object containing all suggestion items, grouped by their `groupName`. - */ - groups: { - [groupName: string]: T[]; - }; - - /** - * The total number of suggestion-items. - */ - count: number; - - /** - * This callback is executed whenever the user selects an item. - * - * @param item the selected item - */ - onSelectItem: (item: T) => void; - - /** - * A function returning the client rect to use as reference for positioning the suggestion menu popup. - */ - clientRect: (() => DOMRect) | null; - - /** - * This callback is executed when the suggestion menu needs to be closed, - * e.g. when the user presses escape. - */ - onClose: () => void; -}; - -/** - * This function creates a SuggestionRenderer based on TipTap's ReactRenderer utility. - * - * The resulting renderer can be used to display a suggestion menu containing (grouped) suggestion items. - * - * This renderer also takes care of the following key events: - * - Key up/down, for navigating the suggestion menu (selecting different items) - * - Enter for picking the currently selected item and closing the menu - * - Escape to close the menu, without taking action - * - * @param editor the TipTap editor - * @returns the newly constructed SuggestionRenderer - */ -export default function createRenderer( - editor: Editor -): SuggestionRenderer { - let component: ReactRenderer; - let popup: Instance[]; - let componentsDisposedOrDisposing = true; - let selectedIndex = 0; - let props: SuggestionRendererProps | undefined; - - /** - * Helper function to find out what item corresponds to a certain index. - * - * This function might throw an error if the index is invalid, - * or when this function is not called in the proper environment. - * - * @param index the index - * @returns the item that corresponds to the index - */ - const itemByIndex = (index: number): T => { - if (!props) { - throw new Error("props not set"); - } - let currentIndex = 0; - for (const groupName in props.groups) { - const items = props.groups[groupName]; - const groupSize = items.length; - // Check if index lies within this group - if (index < currentIndex + groupSize) { - return items[index - currentIndex]; - } - currentIndex += groupSize; - } - throw Error("item not found"); - }; - - return { - getComponent: () => { - if (!popup || !popup[0]) { - return undefined; - } - // return the tippy container element, this is used to ensure - // that click events inside the menu are handled properly. - return popup[0].reference; - }, - onStart: (newProps) => { - props = newProps; - componentsDisposedOrDisposing = false; - selectedIndex = 0; - const componentProps: SuggestionListProps = { - groups: newProps.groups, - count: newProps.count, - onSelectItem: newProps.onSelectItem, - selectedIndex, - }; - - component = new ReactRenderer( - (props: SuggestionListProps) => ( - - - - ), - { - editor: editor as ReactEditor, - props: componentProps, - } - ); - - popup = tippy("body", { - getReferenceClientRect: newProps.clientRect, - appendTo: () => document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - }); - }, - - onUpdate: (newProps) => { - props = newProps; - if (props.groups !== component.props.groups) { - // if the set of items is different (e.g.: by typing / searching), reset the selectedIndex to 0 - selectedIndex = 0; - } - const componentProps: SuggestionListProps = { - groups: props.groups, - count: props.count, - onSelectItem: props.onSelectItem, - selectedIndex, - }; - component.updateProps(componentProps); - - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); - }, - - onKeyDown: (event) => { - if (!props) { - return false; - } - if (event.key === "ArrowUp") { - selectedIndex = (selectedIndex + props.count - 1) % props.count; - component.updateProps({ - selectedIndex, - }); - return true; - } - - if (event.key === "ArrowDown") { - selectedIndex = (selectedIndex + 1) % props.count; - component.updateProps({ - selectedIndex, - }); - return true; - } - - if (event.key === "Enter") { - const item = itemByIndex(selectedIndex); - props.onSelectItem(item); - return true; - } - - if (event.key === "Escape") { - props.onClose(); - return true; - } - return false; - }, - - onExit: (_props) => { - if (componentsDisposedOrDisposing) { - return; - } - // onExit, first hide tippy popup so it shows fade-out - // then (after 1 second, actually destroy resources) - componentsDisposedOrDisposing = true; - const popupToDestroy = popup[0]; - const componentToDestroy = component; - popupToDestroy.hide(); - setTimeout(() => { - popupToDestroy.destroy(); - componentToDestroy.destroy(); - }, 1000); - }, - }; -} diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts b/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts index 67b9372c51..adef7aa8b5 100644 --- a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts +++ b/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts @@ -1,13 +1,15 @@ import { Editor, Range } from "@tiptap/core"; -import { escapeRegExp, groupBy } from "lodash"; -import { Plugin, PluginKey, Selection } from "prosemirror-state"; -import { Decoration, DecorationSet } from "prosemirror-view"; +import { escapeRegExp } from "lodash"; +import { EditorState, Plugin, PluginKey, Selection } from "prosemirror-state"; +import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; import { findBlock } from "../../../extensions/Blocks/helpers/findBlock"; -import SuggestionItem from "./SuggestionItem"; - -import createRenderer, { - SuggestionRendererProps, -} from "./SuggestionListReactRenderer"; +import { + SuggestionsMenu, + SuggestionsMenuDynamicParams, + SuggestionsMenuFactory, + SuggestionsMenuStaticParams, +} from "./SuggestionsMenuFactoryTypes"; +import { SuggestionItem } from "./SuggestionItem"; export type SuggestionPluginOptions = { /** @@ -27,6 +29,8 @@ export type SuggestionPluginOptions = { */ char: string; + suggestionsMenuFactory: SuggestionsMenuFactory; + /** * The callback that gets executed when an item is selected by the user. * @@ -44,6 +48,24 @@ export type SuggestionPluginOptions = { allow?: (props: { editor: Editor; range: Range }) => boolean; }; +type SuggestionPluginState = { + active: boolean; + range: Range | null; + query: string | null; + notFoundCount: number; + items: T[]; + selectedItemIndex: number; + type: string; + decorationId: string | null; +}; + +type SuggestionPluginViewOptions = { + editor: Editor; + pluginKey: PluginKey; + onSelectItem: (props: { item: T; editor: Editor; range: Range }) => void; + suggestionsMenuFactory: SuggestionsMenuFactory; +}; + export type MenuType = "slash" | "drag"; /** @@ -85,6 +107,105 @@ export function findCommandBeforeCursor( }; } +class SuggestionPluginView { + editor: Editor; + pluginKey: PluginKey; + + suggestionsMenu: SuggestionsMenu; + + pluginState: SuggestionPluginState; + itemCallback: (item: T) => void; + + constructor({ + editor, + pluginKey, + onSelectItem: selectItemCallback = () => {}, + suggestionsMenuFactory, + }: SuggestionPluginViewOptions) { + this.editor = editor; + this.pluginKey = pluginKey; + + this.pluginState = { + active: false, + range: null, + query: null, + notFoundCount: 0, + items: [], + selectedItemIndex: 0, + type: "slash", + decorationId: null, + }; + + this.itemCallback = (item: T) => + selectItemCallback({ + item: item, + editor: editor, + range: this.pluginState.range as Range, + }); + + this.suggestionsMenu = suggestionsMenuFactory(this.getStaticParams()); + } + + update(view: EditorView, prevState: EditorState) { + const prev = this.pluginKey.getState(prevState); + const next = this.pluginKey.getState(view.state); + + // See how the state changed + const started = !prev.active && next.active; + const stopped = prev.active && !next.active; + // TODO: Currently also true for cases in which an update isn't needed so selected list item index updates still + // cause the view to update. May need to be more strict. + const changed = prev.active && next.active; + + // Cancel when suggestion isn't active + if (!started && !changed && !stopped) { + return; + } + + this.pluginState = stopped ? prev : next; + + if (stopped) { + this.suggestionsMenu.hide(); + + // Listener stops focus moving to the menu on click. + this.suggestionsMenu.element!.removeEventListener("mousedown", (event) => + event.preventDefault() + ); + } + + if (changed) { + this.suggestionsMenu.render(this.getDynamicParams(), false); + } + + if (started) { + this.suggestionsMenu.render(this.getDynamicParams(), true); + + // Listener stops focus moving to the menu on click. + this.suggestionsMenu.element!.addEventListener("mousedown", (event) => + event.preventDefault() + ); + } + } + + getStaticParams(): SuggestionsMenuStaticParams { + return { + itemCallback: (item: T) => this.itemCallback(item), + }; + } + + getDynamicParams(): SuggestionsMenuDynamicParams { + const decorationNode = document.querySelector( + `[data-decoration-id="${this.pluginState.decorationId}"]` + ); + + return { + items: this.pluginState.items, + selectedItemIndex: this.pluginState.selectedItemIndex, + queryStartBoundingBox: decorationNode!.getBoundingClientRect(), + }; + } +} + /** * A ProseMirror plugin for suggestions, designed to make '/'-commands possible as well as mentions. * @@ -102,6 +223,7 @@ export function createSuggestionPlugin({ pluginKey, editor, char, + suggestionsMenuFactory, onSelectItem: selectItemCallback = () => {}, items = () => [], }: SuggestionPluginOptions) { @@ -110,109 +232,42 @@ export function createSuggestionPlugin({ throw new Error("'char' should be a single character"); } - const renderer = createRenderer(editor); + const deactivate = (view: EditorView) => { + view.dispatch(view.state.tr.setMeta(pluginKey, { deactivate: true })); + }; // Plugin key is passed in parameter so it can be exported and used in draghandle return new Plugin({ key: pluginKey, - filterTransaction(transaction) { - // prevent blurring when clicking with the mouse inside the popup menu - const blurMeta = transaction.getMeta("blur"); - if (blurMeta?.event.relatedTarget) { - const c = renderer.getComponent(); - if (c?.contains(blurMeta.event.relatedTarget)) { - return false; - } - } - return true; - }, - - view() { - return { - update: async (view, prevState) => { - const prev = this.key?.getState(prevState); - const next = this.key?.getState(view.state); - - // See how the state changed - const started = !prev.active && next.active; - const stopped = prev.active && !next.active; - const changed = !started && !stopped && prev.query !== next.query; - - // Cancel when suggestion isn't active - if (!started && !changed && !stopped) { - return; - } - - const state = stopped ? prev : next; - const decorationNode = document.querySelector( - `[data-decoration-id="${state.decorationId}"]` - ); - - const groups: { [groupName: string]: T[] } = groupBy( - state.items, - "groupName" - ); - - const deactivate = () => { - view.dispatch( - view.state.tr.setMeta(pluginKey, { deactivate: true }) - ); - }; - - const rendererProps: SuggestionRendererProps = { - groups: changed || started ? groups : {}, - count: state.items.length, - onSelectItem: (item: T) => { - deactivate(); - selectItemCallback({ - item, - editor, - range: state.range, - }); - }, - // virtual node for popper.js or tippy.js - // this can be used for building popups without a DOM node - clientRect: decorationNode - ? () => decorationNode.getBoundingClientRect() - : null, - onClose: () => { - deactivate(); - renderer.onExit?.(rendererProps); - }, - }; - - if (stopped) { - renderer.onExit?.(rendererProps); - } - - if (changed) { - renderer.onUpdate?.(rendererProps); - } - - if (started) { - renderer.onStart?.(rendererProps); - } + view: (view: EditorView) => + new SuggestionPluginView({ + editor: editor, + pluginKey: pluginKey, + onSelectItem: (props: { item: T; editor: Editor; range: Range }) => { + deactivate(view); + selectItemCallback(props); }, - }; - }, + suggestionsMenuFactory: suggestionsMenuFactory, + }), state: { // Initialize the plugin's internal state. - init() { + init(): SuggestionPluginState { return { active: false, - range: {} as any, // TODO - query: null as string | null, + range: null, // TODO + query: null, notFoundCount: 0, items: [] as T[], + selectedItemIndex: 0, type: "slash", - decorationId: null as string | null, + decorationId: null, }; }, // Apply changes to the plugin state from a view transaction. - apply(transaction, prev, _oldState, _newState) { + apply(transaction, prev, _oldState, newState) { const { selection } = transaction; const next = { ...prev }; @@ -221,6 +276,26 @@ export function createSuggestionPlugin({ return next; } + // Handles transactions created by navigating the menu using the up/down arrow keys. + if ( + transaction.getMeta(pluginKey)?.selectedItemIndexChanged !== undefined + ) { + let newIndex = + transaction.getMeta(pluginKey).selectedItemIndexChanged; + + if (newIndex < 0) { + newIndex = prev.items.length - 1; + } + + if (newIndex >= prev.items.length) { + newIndex = 0; + } + + next.selectedItemIndex = newIndex; + + return next; + } + if ( // only show popup if selection is a blinking cursor selection.from === selection.to && @@ -232,7 +307,7 @@ export function createSuggestionPlugin({ !transaction.getMeta("pointer") ) { // Reset active state if we just left the previous suggestion range (e.g.: key arrows moving before /) - if (prev.active && selection.from <= prev.range.from) { + if (prev.active && selection.from <= prev.range!.from) { next.active = false; } else if (transaction.getMeta(pluginKey)?.activate) { // Start showing suggestions. activate has been set after typing a "/" (or whatever the specified character is), so let's create the decoration and initialize @@ -247,13 +322,14 @@ export function createSuggestionPlugin({ next.query = ""; next.active = true; next.type = transaction.getMeta(pluginKey)?.type; + next.selectedItemIndex = 0; } else if (prev.active) { // Try to match against where our cursor currently is // if the type is slash we get the command after the character // otherwise we get the whole query const match = findCommandBeforeCursor( prev.type === "slash" ? char : "", - selection + newState.selection ); if (!match) { throw new Error("active but no match (suggestions)"); @@ -263,6 +339,7 @@ export function createSuggestionPlugin({ next.active = true; next.decorationId = prev.decorationId; next.query = match.query; + next.selectedItemIndex = 0; } } else { next.active = false; @@ -275,7 +352,7 @@ export function createSuggestionPlugin({ } else { // Update the "notFoundCount", // which indicates how many characters have been typed after showing no results - if (next.range.to > prev.range.to) { + if (next.range!.to > prev.range!.to) { // Text has been entered (selection moved to right), but still no items found, update Count next.notFoundCount = prev.notFoundCount + 1; } else { @@ -293,7 +370,7 @@ export function createSuggestionPlugin({ // Make sure to empty the range if suggestion is inactive if (!next.active) { next.decorationId = null; - next.range = {}; + next.range = null; next.query = null; next.notFoundCount = 0; next.items = []; @@ -319,12 +396,51 @@ export function createSuggestionPlugin({ // return true to cancel the original event, as we insert / ourselves return true; } - return false; + } else { + const { items, range, selectedItemIndex } = pluginKey.getState( + view.state + ); + + if (event.key === "ArrowUp") { + view.dispatch( + view.state.tr.setMeta(pluginKey, { + selectedItemIndexChanged: selectedItemIndex - 1, + }) + ); + return true; + } + + if (event.key === "ArrowDown") { + view.dispatch( + view.state.tr.setMeta(pluginKey, { + selectedItemIndexChanged: selectedItemIndex + 1, + }) + ); + return true; + } + + if (event.key === "Enter") { + deactivate(view); + selectItemCallback({ + item: items[selectedItemIndex], + editor: editor, + range: range, + }); + return true; + } + + if (event.key === "Escape") { + deactivate(view); + return true; + } } - // pass the key event onto the renderer (to handle arrow keys, enter and escape) - // return true if the event got handled by the renderer or false otherwise - return renderer.onKeyDown?.(event) || false; + return false; + }, + + // Hides menu in cases where mouse click does not cause an editor state change. + handleClick(view) { + deactivate(view); }, // Setup decorator on the currently active suggestion. diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionsMenuFactoryTypes.ts b/packages/core/src/shared/plugins/suggestion/SuggestionsMenuFactoryTypes.ts new file mode 100644 index 0000000000..390a17bb4e --- /dev/null +++ b/packages/core/src/shared/plugins/suggestion/SuggestionsMenuFactoryTypes.ts @@ -0,0 +1,21 @@ +import { EditorElement, ElementFactory } from "../../EditorElement"; +import { SuggestionItem } from "./SuggestionItem"; + +export type SuggestionsMenuStaticParams = { + itemCallback: (item: T) => void; +}; + +export type SuggestionsMenuDynamicParams = { + items: T[]; + selectedItemIndex: number; + + queryStartBoundingBox: DOMRect; +}; + +export type SuggestionsMenu = EditorElement< + SuggestionsMenuDynamicParams +>; +export type SuggestionsMenuFactory = ElementFactory< + SuggestionsMenuStaticParams, + SuggestionsMenuDynamicParams +>; diff --git a/packages/core/src/shared/plugins/suggestion/components/SuggestionGroup.tsx b/packages/core/src/shared/plugins/suggestion/components/SuggestionGroup.tsx deleted file mode 100644 index 716721fdcd..0000000000 --- a/packages/core/src/shared/plugins/suggestion/components/SuggestionGroup.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Menu } from "@mantine/core"; -import SuggestionItem from "../SuggestionItem"; -import { SuggestionGroupItem } from "./SuggestionGroupItem"; - -type SuggestionGroupProps = { - /** - * Name of the group - */ - name: string; - - /** - * The list of items - */ - items: T[]; - - /** - * Index of the selected item in this group; relative to this item group (so 0 refers to the first item in this group) - * This should be 'undefined' if none of the items in this group are selected - */ - selectedIndex?: number; - - /** - * Callback that gets executed when an item is clicked on. - */ - clickItem: (item: T) => void; -}; - -export function SuggestionGroup( - props: SuggestionGroupProps -) { - return ( - <> - {props.name} - {props.items.map((item, index) => { - return ( - - ); - })} - - ); -} diff --git a/packages/core/src/shared/plugins/suggestion/components/SuggestionList.tsx b/packages/core/src/shared/plugins/suggestion/components/SuggestionList.tsx deleted file mode 100644 index 7b4a043126..0000000000 --- a/packages/core/src/shared/plugins/suggestion/components/SuggestionList.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { createStyles, Menu } from "@mantine/core"; -import { SuggestionGroup } from "./SuggestionGroup"; -import SuggestionItem from "../SuggestionItem"; - -export type SuggestionListProps = { - // Object containing all suggestion items, grouped by their `groupName`. - groups: { - [groupName: string]: T[]; - }; - - //The total number of suggestion-items - count: number; - - // This callback gets executed whenever an item is clicked on - onSelectItem: (item: T) => void; - - // The index of the item that is currently selected (but not yet clicked on) - selectedIndex: number; -}; - -// Stateless component that renders the suggestion list -export function SuggestionList( - props: SuggestionListProps -) { - const { classes } = createStyles({ root: {} })(undefined, { - name: "SuggestionList", - }); - - const renderedGroups = []; - - let currentGroupIndex = 0; - - for (const groupName in props.groups) { - const items = props.groups[groupName]; - - renderedGroups.push( - = currentGroupIndex - ? props.selectedIndex - currentGroupIndex - : undefined - } - clickItem={props.onSelectItem}> - ); - - currentGroupIndex += items.length; - } - - return ( - - - {renderedGroups.length > 0 ? ( - renderedGroups - ) : ( - No match found - )} - - - - // doesn't work well yet, maybe https://github.com/atomiks/tippyjs-react/issues/173 - // We now render the tippy element manually in SuggestionListReactRenderer - // - // - // {renderedGroups.length > 0 ? ( - // renderedGroups - // ) : ( - //
- // )} - //
- //
- // } - // interactive={false}> - //
- // - ); -} diff --git a/packages/core/src/utils.ts b/packages/core/src/shared/utils.ts similarity index 100% rename from packages/core/src/utils.ts rename to packages/core/src/shared/utils.ts diff --git a/packages/core/src/useEditor.ts b/packages/core/src/useEditor.ts deleted file mode 100644 index c058ee58e8..0000000000 --- a/packages/core/src/useEditor.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { EditorOptions, useEditor as useEditorTiptap } from "@tiptap/react"; - -import { DependencyList } from "react"; -import { getBlockNoteExtensions } from "./BlockNoteExtensions"; -import styles from "./editor.module.css"; -import rootStyles from "./root.module.css"; - -type BlockNoteEditorOptions = EditorOptions & { - enableBlockNoteExtensions: boolean; - disableHistoryExtension: boolean; -}; - -const blockNoteExtensions = getBlockNoteExtensions(); - -const blockNoteOptions = { - enableInputRules: true, - enablePasteRules: true, - enableCoreExtensions: false, -}; - -/** - * Main hook for importing a BlockNote editor into a react project - */ -export const useEditor = ( - options: Partial = {}, - deps: DependencyList = [] -) => { - const extensions = options.disableHistoryExtension - ? blockNoteExtensions.filter((e) => e.name !== "history") - : blockNoteExtensions; - - const tiptapOptions = { - ...blockNoteOptions, - ...options, - extensions: - options.enableBlockNoteExtensions === false - ? options.extensions - : [...(options.extensions || []), ...extensions], - editorProps: { - attributes: { - ...(options.editorProps?.attributes || {}), - class: [ - styles.bnEditor, - rootStyles.bnRoot, - (options.editorProps?.attributes as any)?.class || "", - ].join(" "), - }, - }, - }; - return useEditorTiptap(tiptapOptions, deps); -}; diff --git a/packages/core/vite.config.bundled.ts b/packages/core/vite.config.bundled.ts index 9a6ebab09c..7ca00d3dd8 100644 --- a/packages/core/vite.config.bundled.ts +++ b/packages/core/vite.config.bundled.ts @@ -21,8 +21,8 @@ export default defineConfig({ // Provide global variables to use in the UMD build // for externalized deps globals: { - react: "React", - "react-dom": "ReactDOM", + // react: "React", + // "react-dom": "ReactDOM", }, }, }, diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index b7bd0b0704..ff80ac1d93 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -20,10 +20,7 @@ export default defineConfig({ output: { // Provide global variables to use in the UMD build // for externalized deps - globals: { - react: "React", - "react-dom": "ReactDOM", - }, + globals: {}, }, }, }, diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000000..8f22317a2b --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,64 @@ +{ + "name": "@blocknote/react", + "homepage": "https://github.com/yousefed/blocknote", + "private": false, + "version": "0.1.2", + "type": "module", + "source": "src/index.ts", + "types": "./types/src/index.d.ts", + "main": "./dist/blocknote-react.umd.cjs", + "module": "./dist/blocknote-react.js", + "exports": { + ".": { + "import": "./dist/blocknote-react.js", + "require": "./dist/blocknote-react.umd.cjs" + }, + "./style.css": { + "import": "./dist/style.css", + "require": "./dist/style.css" + } + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "build-bundled": "tsc && vite build --config vite.config.bundled.ts && git checkout tmp-releases && rm -rf ../../release && mv ../../release-tmp ../../release", + "preview": "vite preview", + "lint": "eslint src --max-warnings 0" + }, + "dependencies": { + "@mantine/core": "^5.6.1", + "@blocknote/core": "^0.1.2", + "@tippyjs/react": "^4.2.6", + "@tiptap/react": "^2.0.0-beta.207", + "react-icons": "^4.3.1" + }, + "devDependencies": { + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.9", + "eslint": "^8.10.0", + "eslint-config-react-app": "^7.0.0", + "prettier": "^2.7.1", + "typescript": "^4.5.4", + "vite": "^3.0.5", + "vite-plugin-eslint": "^1.7.0", + "@vitejs/plugin-react": "^1.0.7" + }, + "peerDependencies": { + "react": "^18", + "react-dom": "^18" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ], + "rules": { + "curly": 1 + } + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "gitHead": "37614ab348dcc7faa830a9a88437b37197a2162d" +} diff --git a/packages/core/src/BlockNoteTheme.ts b/packages/react/src/BlockNoteTheme.ts similarity index 88% rename from packages/core/src/BlockNoteTheme.ts rename to packages/react/src/BlockNoteTheme.ts index 61b69bd9fc..bb17186a7a 100644 --- a/packages/core/src/BlockNoteTheme.ts +++ b/packages/react/src/BlockNoteTheme.ts @@ -112,7 +112,23 @@ export const BlockNoteTheme: MantineThemeOverride = { }, }), }, - SuggestionList: { + Tooltip: { + styles: (theme) => ({ + root: { + color: theme.colors.brandFinal[2], + backgroundColor: theme.colors.brandFinal, + border: `1px solid ${theme.colors.brandFinal[1]}`, + borderRadius: "6px", + boxShadow: `0px 4px 8px ${theme.colors.brandFinal[2]}, 0px 0px 1px ${theme.colors.brandFinal[2]}`, + padding: "4px 10px", + textAlign: "center", + "div ~ div": { + color: theme.colors.brandFinal[4], + }, + }, + }), + }, + SlashMenu: { styles: (theme) => ({ root: { // ...theme.other.defaultMenuStyles(theme), diff --git a/packages/react/src/BlockNoteView.tsx b/packages/react/src/BlockNoteView.tsx new file mode 100644 index 0000000000..0c7a5e176b --- /dev/null +++ b/packages/react/src/BlockNoteView.tsx @@ -0,0 +1,6 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { EditorContent } from "@tiptap/react"; + +export function BlockNoteView(props: { editor: BlockNoteEditor | null }) { + return ; +} diff --git a/packages/react/src/BlockSideMenu/BlockSideMenuFactory.tsx b/packages/react/src/BlockSideMenu/BlockSideMenuFactory.tsx new file mode 100644 index 0000000000..f284511642 --- /dev/null +++ b/packages/react/src/BlockSideMenu/BlockSideMenuFactory.tsx @@ -0,0 +1,57 @@ +import { + BlockSideMenu, + BlockSideMenuDynamicParams, + BlockSideMenuFactory, + BlockSideMenuStaticParams, +} from "@blocknote/core"; +import { BlockSideMenu as ReactSideBlockMenu } from "./components/BlockSideMenu"; +import { createRoot, Root } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { BlockNoteTheme } from "../BlockNoteTheme"; +import Tippy from "@tippyjs/react"; + +export const ReactBlockSideMenuFactory: BlockSideMenuFactory = ( + staticParams: BlockSideMenuStaticParams +): BlockSideMenu => { + // We don't use the document body as a root as it would cause multiple React roots to be created on a single element + // if other menu factories do the same. + const rootElement = document.createElement("div"); + // rootElement.className = rootStyles.bnRoot; + let root: Root | undefined; + + function getComponent(dynamicParams: BlockSideMenuDynamicParams) { + return ( + + } + duration={0} + getReferenceClientRect={() => dynamicParams.blockBoundingBox} + hideOnClick={false} + interactive={true} + offset={[0, 0]} + placement={"left"} + showOnCreate={true} + trigger={"manual"} + /> + + ); + } + + return { + element: rootElement, + render: (dynamicParams: BlockSideMenuDynamicParams, isHidden: boolean) => { + if (isHidden) { + document.body.appendChild(rootElement); + root = createRoot(rootElement); + } + + root!.render(getComponent(dynamicParams)); + }, + hide: () => { + root!.unmount(); + + rootElement.remove(); + }, + }; +}; diff --git a/packages/react/src/BlockSideMenu/components/BlockSideMenu.tsx b/packages/react/src/BlockSideMenu/components/BlockSideMenu.tsx new file mode 100644 index 0000000000..9ea03e45e0 --- /dev/null +++ b/packages/react/src/BlockSideMenu/components/BlockSideMenu.tsx @@ -0,0 +1,69 @@ +import { AiOutlinePlus, MdDragIndicator } from "react-icons/all"; +import { ActionIcon, createStyles, Group, Menu } from "@mantine/core"; +import { useEffect, useRef } from "react"; + +export type BlockSideMenuProps = { + addBlock: () => void; + deleteBlock: () => void; + blockDragStart: (event: DragEvent) => void; + blockDragEnd: () => void; + freezeMenu: () => void; + unfreezeMenu: () => void; +}; + +export const BlockSideMenu = (props: BlockSideMenuProps) => { + const { classes } = createStyles({ root: {} })(undefined, { + name: "DragHandleMenu", + }); + + const dragHandleRef = useRef(null); + + useEffect(() => { + const dragHandle = dragHandleRef.current; + + if (dragHandle instanceof HTMLDivElement) { + dragHandle.addEventListener("dragstart", props.blockDragStart); + dragHandle.addEventListener("dragend", props.blockDragEnd); + + return () => { + dragHandle.removeEventListener("dragstart", props.blockDragStart); + dragHandle.removeEventListener("dragend", props.blockDragEnd); + }; + } + + return; + }, [props.blockDragEnd, props.blockDragStart]); + + return ( + + + { + { + props.addBlock(); + }} + /> + } + + + +
+ + {} + +
+
+ + Delete + +
+
+ ); +}; diff --git a/packages/core/src/EditorContent.tsx b/packages/react/src/Editor/EditorContent.tsx similarity index 100% rename from packages/core/src/EditorContent.tsx rename to packages/react/src/Editor/EditorContent.tsx diff --git a/packages/react/src/FormattingToolbar/FormattingToolbarFactory.tsx b/packages/react/src/FormattingToolbar/FormattingToolbarFactory.tsx new file mode 100644 index 0000000000..14a57ef4b7 --- /dev/null +++ b/packages/react/src/FormattingToolbar/FormattingToolbarFactory.tsx @@ -0,0 +1,62 @@ +import { createRoot, Root } from "react-dom/client"; +import { + FormattingToolbar, + FormattingToolbarFactory, + FormattingToolbarStaticParams, + FormattingToolbarDynamicParams, +} from "@blocknote/core"; +import { MantineProvider } from "@mantine/core"; +import Tippy from "@tippyjs/react"; +import { FormattingToolbar as ReactFormattingToolbar } from "./components/FormattingToolbar"; +import { BlockNoteTheme } from "../BlockNoteTheme"; +// import rootStyles from "../../../core/src/root.module.css"; + +export const ReactFormattingToolbarFactory: FormattingToolbarFactory = ( + staticParams: FormattingToolbarStaticParams +): FormattingToolbar => { + // We don't use the document body as a root as it would cause multiple React roots to be created on a single element + // if other menu factories do the same. + const rootElement = document.createElement("div"); + // rootElement.className = rootStyles.bnRoot; + let root: Root | undefined; + + function getComponent(dynamicParams: FormattingToolbarDynamicParams) { + return ( + + + } + duration={0} + getReferenceClientRect={() => dynamicParams.selectionBoundingBox} + hideOnClick={false} + interactive={true} + placement={"top-start"} + showOnCreate={true} + trigger={"manual"} + /> + + ); + } + + return { + element: rootElement, + render: ( + dynamicParams: FormattingToolbarDynamicParams, + isHidden: boolean + ) => { + if (isHidden) { + document.body.appendChild(rootElement); + root = createRoot(rootElement); + } + + root!.render(getComponent(dynamicParams)); + }, + hide: () => { + root!.unmount(); + + rootElement.remove(); + }, + }; +}; diff --git a/packages/react/src/FormattingToolbar/components/FormattingToolbar.tsx b/packages/react/src/FormattingToolbar/components/FormattingToolbar.tsx new file mode 100644 index 0000000000..485fd4b575 --- /dev/null +++ b/packages/react/src/FormattingToolbar/components/FormattingToolbar.tsx @@ -0,0 +1,259 @@ +import { + RiBold, + RiH1, + RiH2, + RiH3, + RiIndentDecrease, + RiIndentIncrease, + RiItalic, + RiLink, + RiListOrdered, + RiListUnordered, + RiStrikethrough, + RiText, + RiUnderline, +} from "react-icons/ri"; +import { ToolbarButton } from "../../SharedComponents/Toolbar/components/ToolbarButton"; +import { ToolbarDropdown } from "../../SharedComponents/Toolbar/components/ToolbarDropdown"; +import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar"; +import { formatKeyboardShortcut } from "../../utils"; +import LinkToolbarButton from "./LinkToolbarButton"; +import { BlockContentType } from "@blocknote/core"; + +export type FormattingToolbarProps = { + boldIsActive: boolean; + toggleBold: () => void; + italicIsActive: boolean; + toggleItalic: () => void; + underlineIsActive: boolean; + toggleUnderline: () => void; + strikeIsActive: boolean; + toggleStrike: () => void; + hyperlinkIsActive: boolean; + activeHyperlinkUrl: string; + activeHyperlinkText: string; + setHyperlink: (url: string, text?: string) => void; + + activeBlockType: Required; + setBlockType: (type: BlockContentType) => void; +}; + +// TODO: add list options, indentation +export const FormattingToolbar = (props: FormattingToolbarProps) => { + const getActiveMarks = () => { + const activeMarks = new Set(); + + props.boldIsActive && activeMarks.add("bold"); + props.italicIsActive && activeMarks.add("italic"); + props.underlineIsActive && activeMarks.add("underline"); + props.strikeIsActive && activeMarks.add("strike"); + props.hyperlinkIsActive && activeMarks.add("link"); + + return activeMarks; + }; + + const getActiveBlock = () => { + if (props.activeBlockType.name === "headingContent") { + if (props.activeBlockType.attrs["headingLevel"] === "1") { + return { + text: "Heading 1", + icon: RiH1, + }; + } + + if (props.activeBlockType.attrs["headingLevel"] === "2") { + return { + text: "Heading 2", + icon: RiH2, + }; + } + + if (props.activeBlockType.attrs["headingLevel"] === "3") { + return { + text: "Heading 3", + icon: RiH3, + }; + } + } + + if (props.activeBlockType.name === "listItemContent") { + if (props.activeBlockType.attrs["listItemType"] === "unordered") { + return { + text: "Bullet List", + icon: RiListUnordered, + }; + } else { + return { + text: "Ordered List", + icon: RiListOrdered, + }; + } + } + + return { + text: "Text", + icon: RiText, + }; + }; + + const activeMarks = getActiveMarks(); + const activeBlock = getActiveBlock(); + + return ( + + props.setBlockType({ name: "textContent" }), + text: "Text", + icon: RiText, + isSelected: props.activeBlockType.name === "textContent", + }, + { + onClick: () => + props.setBlockType({ + name: "headingContent", + attrs: { headingLevel: "1" }, + }), + text: "Heading 1", + icon: RiH1, + isSelected: + props.activeBlockType.name === "headingContent" && + props.activeBlockType.attrs["headingLevel"] === "1", + }, + { + onClick: () => + props.setBlockType({ + name: "headingContent", + attrs: { headingLevel: "2" }, + }), + text: "Heading 2", + icon: RiH2, + isSelected: + props.activeBlockType.name === "headingContent" && + props.activeBlockType.attrs["headingLevel"] === "2", + }, + { + onClick: () => + props.setBlockType({ + name: "headingContent", + attrs: { headingLevel: "3" }, + }), + text: "Heading 3", + icon: RiH3, + isSelected: + props.activeBlockType.name === "headingContent" && + props.activeBlockType.attrs["headingLevel"] === "3", + }, + { + onClick: () => + props.setBlockType({ + name: "listItemContent", + attrs: { listItemType: "unordered" }, + }), + text: "Bullet List", + icon: RiListUnordered, + isSelected: + props.activeBlockType.name === "listItemContent" && + props.activeBlockType.attrs["listItemType"] === "unordered", + }, + { + onClick: () => + props.setBlockType({ + name: "listItemContent", + attrs: { listItemType: "ordered" }, + }), + text: "Numbered List", + icon: RiListOrdered, + isSelected: + props.activeBlockType.name === "listItemContent" && + props.activeBlockType.attrs["listItemType"] === "ordered", + }, + ]} + /> + + + + + { + // props.editor.view.focus(); + // props.editor.commands.sinkListItem("block"); + }} + isDisabled={ + // !props.editor.can().sinkListItem("block") + true + } + mainTooltip="Indent" + secondaryTooltip={formatKeyboardShortcut("Tab")} + icon={RiIndentIncrease} + /> + + { + // props.editor.view.focus(); + // props.editor.commands.liftListItem("block"); + }} + isDisabled={ + // !props.editor.can().command(({ state }) => { + // const block = findBlock(state.selection); + // if (!block) { + // return false; + // } + // // If the depth is greater than 2 you can lift + // return block.depth > 2; + // }) + true + } + mainTooltip="Decrease Indent" + secondaryTooltip={formatKeyboardShortcut("Shift+Tab")} + icon={RiIndentDecrease} + /> + + + {/* { + const comment = this.props.commentStore.createComment(); + props.editor.chain().focus().setComment(comment.id).run(); + }} + styleDetails={comment} + /> */} + + ); +}; diff --git a/packages/react/src/FormattingToolbar/components/LinkToolbarButton.tsx b/packages/react/src/FormattingToolbar/components/LinkToolbarButton.tsx new file mode 100644 index 0000000000..3ca28f6a21 --- /dev/null +++ b/packages/react/src/FormattingToolbar/components/LinkToolbarButton.tsx @@ -0,0 +1,49 @@ +import Tippy from "@tippyjs/react"; +import { useCallback, useState } from "react"; +import { + ToolbarButton, + ToolbarButtonProps, +} from "../../SharedComponents/Toolbar/components/ToolbarButton"; +import { EditHyperlinkMenu } from "../../HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu"; + +type HyperlinkButtonProps = ToolbarButtonProps & { + hyperlinkIsActive: boolean; + activeHyperlinkUrl: string; + activeHyperlinkText: string; + setHyperlink: (url: string, text?: string) => void; +}; + +/** + * The link menu button opens a tooltip on click + */ +export const LinkToolbarButton = (props: HyperlinkButtonProps) => { + const [creationMenu, setCreationMenu] = useState(); + + // TODO: review code; does this pattern still make sense? + const updateCreationMenu = useCallback(() => { + setCreationMenu( + + ); + }, [props]); + + return ( + { + updateCreationMenu(); + }} + interactive={true} + maxWidth={500}> + + + ); +}; + +export default LinkToolbarButton; diff --git a/packages/core/src/extensions/Hyperlinks/menus/EditHyperlinkMenu.tsx b/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx similarity index 99% rename from packages/core/src/extensions/Hyperlinks/menus/EditHyperlinkMenu.tsx rename to packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx index aa931c57e5..cd5898dc8a 100644 --- a/packages/core/src/extensions/Hyperlinks/menus/EditHyperlinkMenu.tsx +++ b/packages/react/src/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx @@ -14,12 +14,13 @@ export type EditHyperlinkMenuProps = { * Provides input fields for setting the hyperlink URL and title. */ export const EditHyperlinkMenu = (props: EditHyperlinkMenuProps) => { - const [url, setUrl] = useState(props.url); - const [title, setTitle] = useState(props.text); const { classes } = createStyles({ root: {} })(undefined, { name: "EditHyperlinkMenu", }); + const [url, setUrl] = useState(props.url); + const [title, setTitle] = useState(props.text); + return ( (null); - - useEffect(() => { - setTimeout(() => { - props.autofocus && inputRef.current?.focus(); - }); - }, [props.autofocus]); - function handleEnter(event: KeyboardEvent) { if (event.key === "Enter") { event.preventDefault(); @@ -29,12 +21,12 @@ export function EditHyperlinkMenuItemInput( return ( props.onChange(event.currentTarget.value)} onKeyDown={handleEnter} placeholder={props.placeholder} - ref={inputRef} /> ); } diff --git a/packages/react/src/HyperlinkToolbar/HyperlinkToolbarFactory.tsx b/packages/react/src/HyperlinkToolbar/HyperlinkToolbarFactory.tsx new file mode 100644 index 0000000000..c8f6c1e6ec --- /dev/null +++ b/packages/react/src/HyperlinkToolbar/HyperlinkToolbarFactory.tsx @@ -0,0 +1,61 @@ +import { createRoot, Root } from "react-dom/client"; +import { + HyperlinkToolbar, + HyperlinkToolbarDynamicParams, + HyperlinkToolbarFactory, + HyperlinkToolbarStaticParams, +} from "@blocknote/core"; +import { MantineProvider } from "@mantine/core"; +import Tippy from "@tippyjs/react"; +import { HyperlinkToolbar as ReactHyperlinkToolbar } from "./components/HyperlinkToolbar"; +import { BlockNoteTheme } from "../BlockNoteTheme"; +// import rootStyles from "../../../core/src/root.module.css"; + +export const ReactHyperlinkToolbarFactory: HyperlinkToolbarFactory = ( + staticParams: HyperlinkToolbarStaticParams +): HyperlinkToolbar => { + // We don't use the document body as a root as it would cause multiple React roots to be created on a single element + // if other UI factories do the same. + const rootElement = document.createElement("div"); + let root: Root | undefined; + + function getComponent(dynamicParams: HyperlinkToolbarDynamicParams) { + return ( + + + } + duration={0} + getReferenceClientRect={() => dynamicParams.boundingBox} + hideOnClick={false} + interactive={true} + placement={"top"} + showOnCreate={true} + trigger={"manual"} + /> + + ); + } + + return { + element: rootElement, + render: ( + dynamicParams: HyperlinkToolbarDynamicParams, + isHidden: boolean + ) => { + if (isHidden) { + document.body.appendChild(rootElement); + root = createRoot(rootElement); + } + + root!.render(getComponent(dynamicParams)); + }, + hide: () => { + root!.unmount(); + + rootElement.remove(); + }, + }; +}; diff --git a/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbar.tsx b/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbar.tsx new file mode 100644 index 0000000000..f2ce16b66b --- /dev/null +++ b/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbar.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { EditHyperlinkMenu } from "../EditHyperlinkMenu/components/EditHyperlinkMenu"; +import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar"; +import { ToolbarButton } from "../../SharedComponents/Toolbar/components/ToolbarButton"; +import { RiExternalLinkFill, RiLinkUnlink } from "react-icons/ri"; +// import rootStyles from "../../../root.module.css"; + +export type HyperlinkToolbarProps = { + url: string; + text: string; + editHyperlink: (url: string, text: string) => void; + deleteHyperlink: () => void; +}; + +/** + * Main menu component for the hyperlink extension. + * Renders a toolbar that appears on hyperlink hover. + */ +export const HyperlinkToolbar = (props: HyperlinkToolbarProps) => { + const [isEditing, setIsEditing] = useState(false); + + if (isEditing) { + return ( + + ); + } + + return ( + + setIsEditing(true)}> + Edit Link + + { + window.open(props.url, "_blank"); + }} + icon={RiExternalLinkFill} + /> + + + ); +}; diff --git a/packages/core/src/shared/components/toolbar/Toolbar.tsx b/packages/react/src/SharedComponents/Toolbar/components/Toolbar.tsx similarity index 100% rename from packages/core/src/shared/components/toolbar/Toolbar.tsx rename to packages/react/src/SharedComponents/Toolbar/components/Toolbar.tsx diff --git a/packages/core/src/shared/components/toolbar/ToolbarButton.tsx b/packages/react/src/SharedComponents/Toolbar/components/ToolbarButton.tsx similarity index 90% rename from packages/core/src/shared/components/toolbar/ToolbarButton.tsx rename to packages/react/src/SharedComponents/Toolbar/components/ToolbarButton.tsx index fe457e8253..61767afe40 100644 --- a/packages/core/src/shared/components/toolbar/ToolbarButton.tsx +++ b/packages/react/src/SharedComponents/Toolbar/components/ToolbarButton.tsx @@ -1,8 +1,7 @@ import { ActionIcon, Button } from "@mantine/core"; import Tippy from "@tippyjs/react"; import { forwardRef } from "react"; -import { TooltipContent } from "../tooltip/TooltipContent"; -import React from "react"; +import { TooltipContent } from "../../Tooltip/components/TooltipContent"; import { IconType } from "react-icons"; export type ToolbarButtonProps = { @@ -16,7 +15,7 @@ export type ToolbarButtonProps = { }; /** - * Helper for basic buttons that show in the inline bubble menu. + * Helper for basic buttons that show in the formatting toolbar. */ export const ToolbarButton = forwardRef((props: ToolbarButtonProps, ref) => { const ButtonIcon = props.icon; diff --git a/packages/core/src/shared/components/toolbar/ToolbarDropdown.tsx b/packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdown.tsx similarity index 100% rename from packages/core/src/shared/components/toolbar/ToolbarDropdown.tsx rename to packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdown.tsx diff --git a/packages/core/src/shared/components/toolbar/ToolbarDropdownItem.tsx b/packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdownItem.tsx similarity index 100% rename from packages/core/src/shared/components/toolbar/ToolbarDropdownItem.tsx rename to packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdownItem.tsx diff --git a/packages/core/src/shared/components/toolbar/ToolbarDropdownTarget.tsx b/packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdownTarget.tsx similarity index 100% rename from packages/core/src/shared/components/toolbar/ToolbarDropdownTarget.tsx rename to packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdownTarget.tsx diff --git a/packages/react/src/SharedComponents/Tooltip/components/TooltipContent.tsx b/packages/react/src/SharedComponents/Tooltip/components/TooltipContent.tsx new file mode 100644 index 0000000000..ddd5782822 --- /dev/null +++ b/packages/react/src/SharedComponents/Tooltip/components/TooltipContent.tsx @@ -0,0 +1,19 @@ +import { createStyles, Stack, Text } from "@mantine/core"; + +export const TooltipContent = (props: { + mainTooltip: string; + secondaryTooltip?: string; +}) => { + const { classes } = createStyles({ root: {} })(undefined, { + name: "Tooltip", + }); + + return ( + + {props.mainTooltip} + {props.secondaryTooltip && ( + {props.secondaryTooltip} + )} + + ); +}; diff --git a/packages/react/src/SlashMenu/SlashMenuFactory.tsx b/packages/react/src/SlashMenu/SlashMenuFactory.tsx new file mode 100644 index 0000000000..add31771ec --- /dev/null +++ b/packages/react/src/SlashMenu/SlashMenuFactory.tsx @@ -0,0 +1,63 @@ +import { createRoot, Root } from "react-dom/client"; +import { + SlashMenuItem, + SuggestionsMenu, + SuggestionsMenuDynamicParams, + SuggestionsMenuFactory, + SuggestionsMenuStaticParams, +} from "@blocknote/core"; +import { MantineProvider } from "@mantine/core"; +import Tippy from "@tippyjs/react"; +import { SlashMenu } from "./components/SlashMenu"; +import { BlockNoteTheme } from "../BlockNoteTheme"; +// import rootStyles from "../../../core/src/root.module.css"; + +export const ReactSlashMenuFactory: SuggestionsMenuFactory = ( + staticParams: SuggestionsMenuStaticParams +): SuggestionsMenu => { + // We don't use the document body as a root as it would cause multiple React roots to be created on a single element + // if other menu factories do the same. + const rootElement = document.createElement("div"); + // rootElement.className = rootStyles.bnRoot; + let root: Root | undefined; + + function getComponent( + dynamicParams: SuggestionsMenuDynamicParams + ) { + return ( + + } + duration={0} + getReferenceClientRect={() => dynamicParams.queryStartBoundingBox} + hideOnClick={false} + interactive={true} + placement={"bottom-start"} + showOnCreate={true} + trigger={"manual"} + /> + + ); + } + + return { + element: rootElement as HTMLElement, + render: ( + dynamicParams: SuggestionsMenuDynamicParams, + isHidden: boolean + ) => { + if (isHidden) { + document.body.appendChild(rootElement); + root = createRoot(rootElement); + } + + root!.render(getComponent(dynamicParams)); + }, + hide: () => { + root!.unmount(); + + rootElement.remove(); + }, + }; +}; diff --git a/packages/react/src/SlashMenu/components/SlashMenu.tsx b/packages/react/src/SlashMenu/components/SlashMenu.tsx new file mode 100644 index 0000000000..9aa81f1058 --- /dev/null +++ b/packages/react/src/SlashMenu/components/SlashMenu.tsx @@ -0,0 +1,108 @@ +import { SlashMenuItem } from "@blocknote/core"; +import { createStyles, Menu } from "@mantine/core"; +import { SlashMenuItem as ReactSlashMenuItem } from "./SlashMenuItem"; + +export type SlashMenuProps = { + items: SlashMenuItem[]; + selectedItemIndex: number; + itemCallback: (item: SlashMenuItem) => void; +}; + +export function SlashMenu(props: SlashMenuProps) { + const { classes } = createStyles({ root: {} })(undefined, { + name: "SlashMenu", + }); + + const headingGroup: SlashMenuItem[] = []; + const basicBlockGroup: SlashMenuItem[] = []; + + for (const item of props.items) { + if (item.name === "Heading") { + headingGroup.push(item); + } + + if (item.name === "Heading 2") { + headingGroup.push(item); + } + + if (item.name === "Heading 3") { + headingGroup.push(item); + } + + if (item.name === "Numbered List") { + basicBlockGroup.push(item); + } + + if (item.name === "Bullet List") { + basicBlockGroup.push(item); + } + + if (item.name === "Paragraph") { + basicBlockGroup.push(item); + } + } + + const renderedItems: any[] = []; + let index = 0; + + if (headingGroup.length > 0) { + renderedItems.push( + {"Headings"} + ); + + for (const item of headingGroup) { + renderedItems.push( + props.itemCallback(item)} + /> + ); + index++; + } + } + + if (basicBlockGroup.length > 0) { + renderedItems.push( + {"Basic Blocks"} + ); + + for (const item of basicBlockGroup) { + renderedItems.push( + props.itemCallback(item)} + /> + ); + index++; + } + } + + return ( + + + {renderedItems.length > 0 ? ( + renderedItems + ) : ( + No match found + )} + + + ); +} diff --git a/packages/core/src/shared/plugins/suggestion/components/SuggestionGroupItem.tsx b/packages/react/src/SlashMenu/components/SlashMenuItem.tsx similarity index 62% rename from packages/core/src/shared/plugins/suggestion/components/SuggestionGroupItem.tsx rename to packages/react/src/SlashMenu/components/SlashMenuItem.tsx index 11967985a0..5fbc5aeb8f 100644 --- a/packages/core/src/shared/plugins/suggestion/components/SuggestionGroupItem.tsx +++ b/packages/react/src/SlashMenu/components/SlashMenuItem.tsx @@ -1,27 +1,35 @@ -import SuggestionItem from "../SuggestionItem"; -import { useEffect, useRef } from "react"; import { Badge, createStyles, Menu, Stack, Text } from "@mantine/core"; +import { useEffect, useRef } from "react"; +import { IconType } from "react-icons"; + +import { + RiH1, + RiH2, + RiH3, + RiListOrdered, + RiListUnordered, + RiText, +} from "react-icons/ri"; const MIN_LEFT_MARGIN = 5; -export type SuggestionGroupItemProps = { - item: T; - index: number; - selectedIndex?: number; - clickItem: (item: T) => void; +export type SlashMenuItemProps = { + name: string; + hint: string | undefined; + shortcut?: string; + isSelected: boolean; + set: () => void; }; -export function SuggestionGroupItem( - props: SuggestionGroupItemProps -) { +export function SlashMenuItem(props: SlashMenuItemProps) { const itemRef = useRef(null); const { classes } = createStyles({ root: {} })(undefined, { name: "SuggestionListItem", }); function isSelected() { - const isKeyboardSelected = - props.selectedIndex !== undefined && props.selectedIndex === props.index; + const isKeyboardSelected = props.isSelected; + // props.selectedIndex !== undefined && props.selectedIndex === props.index; const isMouseSelected = itemRef.current?.matches(":hover"); return isKeyboardSelected || isMouseSelected; @@ -53,29 +61,54 @@ export function SuggestionGroupItem( } }); - const Icon = props.item.icon; + // TODO: rearchitect, this is hacky + let Icon: IconType | undefined; + switch (props.name) { + case "Heading": + Icon = RiH1; + break; + case "Heading 2": + Icon = RiH2; + break; + + case "Heading 3": + Icon = RiH3; + break; + case "Numbered List": + Icon = RiListOrdered; + break; + case "Bullet List": + Icon = RiListUnordered; + break; + case "Paragraph": + Icon = RiText; + break; + default: + break; + } return ( } - onClick={() => props.clickItem(props.item)} + onClick={props.set} + closeMenuOnClick={false} // Ensures an item selected with both mouse & keyboard doesn't get deselected on mouse leave. onMouseLeave={() => { setTimeout(() => { updateSelection(); - }); + }, 1); }} ref={itemRef} rightSection={ - props.item.shortcut && {props.item.shortcut} + props.shortcut && {props.shortcut} }> {/*Might need separate classes.*/} - {props.item.name} + {props.name} - {props.item.hint} + {props.hint} ); diff --git a/packages/react/src/hooks/useBlockNote.ts b/packages/react/src/hooks/useBlockNote.ts new file mode 100644 index 0000000000..e907de768d --- /dev/null +++ b/packages/react/src/hooks/useBlockNote.ts @@ -0,0 +1,63 @@ +import { BlockNoteEditor, BlockNoteEditorOptions } from "@blocknote/core"; +import { DependencyList, useEffect, useState } from "react"; +import { ReactFormattingToolbarFactory } from "../FormattingToolbar/FormattingToolbarFactory"; +import { ReactHyperlinkToolbarFactory } from "../HyperlinkToolbar/HyperlinkToolbarFactory"; +import { ReactSlashMenuFactory } from "../SlashMenu/SlashMenuFactory"; +import { ReactBlockSideMenuFactory } from "../BlockSideMenu/BlockSideMenuFactory"; + +//based on https://github.com/ueberdosis/tiptap/blob/main/packages/react/src/useEditor.ts + +function useForceUpdate() { + const [, setValue] = useState(0); + + return () => setValue((value) => value + 1); +} + +/** + * Main hook for importing a BlockNote editor into a React project + */ +export const useBlockNote = ( + options: Partial = {}, + deps: DependencyList = [] +) => { + const [editor, setEditor] = useState(null); + const forceUpdate = useForceUpdate(); + + useEffect(() => { + let isMounted = true; + let newOptions = { ...options }; + if (!newOptions.uiFactories) { + newOptions = { + ...newOptions, + uiFactories: { + formattingToolbarFactory: ReactFormattingToolbarFactory, + hyperlinkToolbarFactory: ReactHyperlinkToolbarFactory, + slashMenuFactory: ReactSlashMenuFactory, + blockSideMenuFactory: ReactBlockSideMenuFactory, + }, + }; + } + console.log("create new blocknote instance"); + const instance = new BlockNoteEditor(newOptions); + + setEditor(instance); + + instance.tiptapEditor.on("transaction", () => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (isMounted) { + forceUpdate(); + } + }); + }); + }); + + return () => { + instance.tiptapEditor.destroy(); + isMounted = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return editor; +}; diff --git a/packages/core/src/shared/hooks/useEditorForceUpdate.tsx b/packages/react/src/hooks/useEditorForceUpdate.tsx similarity index 100% rename from packages/core/src/shared/hooks/useEditorForceUpdate.tsx rename to packages/react/src/hooks/useEditorForceUpdate.tsx diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 0000000000..01fcd48d2b --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,8 @@ +// TODO: review directories +export * from "./BlockNoteView"; +export * from "./BlockSideMenu/BlockSideMenuFactory"; +export * from "./FormattingToolbar/FormattingToolbarFactory"; +export * from "./hooks/useBlockNote"; +export * from "./hooks/useEditorForceUpdate"; +export * from "./HyperlinkToolbar/HyperlinkToolbarFactory"; +export * from "./SlashMenu/SlashMenuFactory"; diff --git a/packages/react/src/tsconfig.json b/packages/react/src/tsconfig.json new file mode 100644 index 0000000000..8841d64b1c --- /dev/null +++ b/packages/react/src/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "jsx": "react-jsx", + "strict": true, + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "noEmit": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "outDir": "dist", + "declaration": true, + "declarationDir": "types", + "composite": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/packages/react/src/types/styles.d.ts b/packages/react/src/types/styles.d.ts new file mode 100644 index 0000000000..f57bdaee63 --- /dev/null +++ b/packages/react/src/types/styles.d.ts @@ -0,0 +1 @@ +declare module "*.module.css"; \ No newline at end of file diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts new file mode 100644 index 0000000000..a035199358 --- /dev/null +++ b/packages/react/src/utils.ts @@ -0,0 +1,12 @@ +export const isAppleOS = () => + /Mac/.test(navigator.platform) || + (/AppleWebKit/.test(navigator.userAgent) && + /Mobile\/\w+/.test(navigator.userAgent)); + +export function formatKeyboardShortcut(shortcut: string) { + if (isAppleOS()) { + return shortcut.replace("Mod", "⌘"); + } else { + return shortcut.replace("Mod", "Ctrl"); + } +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 0000000000..8841d64b1c --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "jsx": "react-jsx", + "strict": true, + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "noEmit": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "outDir": "dist", + "declaration": true, + "declarationDir": "types", + "composite": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts new file mode 100644 index 0000000000..4d66118a1e --- /dev/null +++ b/packages/react/vite.config.ts @@ -0,0 +1,35 @@ +import react from "@vitejs/plugin-react"; +import * as path from "path"; +import { defineConfig } from "vite"; +import pkg from "./package.json"; +// import eslintPlugin from "vite-plugin-eslint"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + build: { + sourcemap: true, + lib: { + entry: path.resolve(__dirname, "src/index.ts"), + name: "blocknote-react", + fileName: "blocknote-react", + }, + rollupOptions: { + // make sure to externalize deps that shouldn't be bundled + // into your library + external: Object.keys({ + ...pkg.dependencies, + ...pkg.peerDependencies, + ...pkg.devDependencies, + }), + output: { + // Provide global variables to use in the UMD build + // for externalized deps + globals: { + react: "React", + "react-dom": "ReactDOM", + }, + }, + }, + }, +}); diff --git a/tests/end-to-end/dragdrop/dragdrop.test.ts b/tests/end-to-end/dragdrop/dragdrop.test.ts index ac671cb08f..9a1c5f9b1a 100644 --- a/tests/end-to-end/dragdrop/dragdrop.test.ts +++ b/tests/end-to-end/dragdrop/dragdrop.test.ts @@ -9,6 +9,7 @@ import { import { compareDocToSnapshot, focusOnEditor } from "../../utils/editor"; import { insertHeading, insertParagraph } from "../../utils/copypaste"; import { dragAndDropBlock } from "../../utils/mouse"; +import { showMouseCursor } from "../../utils/debug"; test.beforeEach(async ({ page }) => { await page.goto(BASE_URL, { waitUntil: "networkidle" }); @@ -24,6 +25,7 @@ test.describe("Check Block Dragging Functionality", () => { "Playwright doesn't correctly simulate drag events in Firefox." ); await focusOnEditor(page); + await showMouseCursor(page); await insertHeading(page, 1); await insertHeading(page, 2); @@ -31,6 +33,7 @@ test.describe("Check Block Dragging Functionality", () => { const dragTarget = await page.locator(H_ONE_BLOCK_SELECTOR); const dropTarget = await page.locator(H_TWO_BLOCK_SELECTOR); + await page.pause(); await dragAndDropBlock(page, dragTarget, dropTarget, false); await page.pause(); @@ -47,6 +50,7 @@ test.describe("Check Block Dragging Functionality", () => { "Playwright doesn't correctly simulate drag events in Firefox." ); await focusOnEditor(page); + await showMouseCursor(page); await insertHeading(page, 1); await insertParagraph(page); @@ -63,16 +67,19 @@ test.describe("Check Block Dragging Functionality", () => { // Dragging first heading into next nested element. let dragTarget = await page.locator(H_ONE_BLOCK_SELECTOR); let dropTarget = await page.locator(H_TWO_BLOCK_SELECTOR); + await page.pause(); await dragAndDropBlock(page, dragTarget, dropTarget, true); // Dragging second heading into next nested element. dragTarget = await page.locator(H_TWO_BLOCK_SELECTOR); dropTarget = await page.locator(H_THREE_BLOCK_SELECTOR); + await page.pause(); await dragAndDropBlock(page, dragTarget, dropTarget, true); // Dragging third heading into outside nesting. dragTarget = await page.locator(H_THREE_BLOCK_SELECTOR); dropTarget = await page.locator(BLOCK_SELECTOR).last(); + await page.pause(); await dragAndDropBlock(page, dragTarget, dropTarget, true); await page.pause(); diff --git a/tests/end-to-end/draghandle/draghandle.test.ts b/tests/end-to-end/draghandle/draghandle.test.ts index f9c924ea35..9c26505dfa 100644 --- a/tests/end-to-end/draghandle/draghandle.test.ts +++ b/tests/end-to-end/draghandle/draghandle.test.ts @@ -42,6 +42,7 @@ test.describe("Check Draghandle functionality", () => { await page.keyboard.type("Hover over this text"); const heading = await page.locator(H_ONE_BLOCK_SELECTOR); await moveMouseOverElement(page, heading); + await page.waitForSelector(DRAG_HANDLE_SELECTOR); }); @@ -117,6 +118,7 @@ test.describe("Check Draghandle functionality", () => { const heading = await page.locator(H_ONE_BLOCK_SELECTOR); await moveMouseOverElement(page, heading); await page.click(DRAG_HANDLE_ADD_SELECTOR); + await page.waitForSelector(SLASH_MENU_SELECTOR); }); @@ -125,6 +127,7 @@ test.describe("Check Draghandle functionality", () => { await page.keyboard.type("Hover over this text"); await hoverAndAddBlockFromDragHandle(page, H_ONE_BLOCK_SELECTOR, "h2"); await page.waitForSelector(DRAG_HANDLE_SELECTOR, { state: "detached" }); + await page.waitForSelector(DRAG_HANDLE_ADD_SELECTOR, { state: "detached" }); }); @@ -156,7 +159,6 @@ test.describe("Check Draghandle functionality", () => { await page.waitForSelector(H_TWO_BLOCK_SELECTOR, { state: "detached" }); await page.waitForSelector(H_THREE_BLOCK_SELECTOR); - // Compare doc object snapshot await compareDocToSnapshot(page, "dragHandleDocStructure"); }); diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-firefox-linux.png b/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-firefox-linux.png index 3960ede100..33cc6b28d4 100644 Binary files a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-firefox-linux.png and b/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-firefox-linux.png differ diff --git a/tests/utils/const.ts b/tests/utils/const.ts index 2a8e271e56..0ab5e9ba0d 100644 --- a/tests/utils/const.ts +++ b/tests/utils/const.ts @@ -18,6 +18,6 @@ export const DRAG_HANDLE_SELECTOR = `[data-test="dragHandle"]`; export const DRAG_HANDLE_ADD_SELECTOR = `[data-test="dragHandleAdd"]`; export const DRAG_HANDLE_MENU_SELECTOR = `.mantine-DragHandleMenu-root`; -export const SLASH_MENU_SELECTOR = `.mantine-SuggestionList-root`; +export const SLASH_MENU_SELECTOR = `.mantine-SlashMenu-root`; export const TYPE_DELAY = 10; diff --git a/tests/utils/mouse.ts b/tests/utils/mouse.ts index ba324446bc..308b4f39b7 100644 --- a/tests/utils/mouse.ts +++ b/tests/utils/mouse.ts @@ -15,6 +15,14 @@ async function getElementRightCoords(page: Page, element: Locator) { return { x: boundingBox.x + boundingBox.width - 1, y: centerY }; } +async function getElementCenterCoords(page: Page, element: Locator) { + const boundingBox = await element.boundingBox(); + const centerX = boundingBox.x + boundingBox.width / 2; + const centerY = boundingBox.y + boundingBox.height / 2; + + return { x: centerX, y: centerY }; +} + export async function moveMouseOverElement(page: Page, element: Locator) { const boundingBox = await element.boundingBox(); const coords = { x: boundingBox.x, y: boundingBox.y }; @@ -31,7 +39,10 @@ export async function dragAndDropBlock( await page.waitForSelector(DRAG_HANDLE_SELECTOR); const dragHandle = await page.locator(DRAG_HANDLE_SELECTOR); - await moveMouseOverElement(page, dragHandle); + const dragHandleCenterCoords = await getElementCenterCoords(page, dragHandle); + await page.mouse.move(dragHandleCenterCoords.x, dragHandleCenterCoords.y, { + steps: 5, + }); await page.mouse.down(); const dropTargetCoords = dropAbove