diff --git a/.eslintrc.js b/.eslintrc.js index 5e4576df7e..983f7ae31e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,7 @@ +const typeScriptExtensions = [".ts", ".cts", ".mts", ".tsx"]; + +const allExtensions = [...typeScriptExtensions, ".js", ".jsx"]; + module.exports = { root: true, extends: [ @@ -8,6 +12,18 @@ module.exports = { ], parser: "@typescript-eslint/parser", plugins: ["import", "@typescript-eslint"], + settings: { + "import/extensions": allExtensions, + "import/external-module-folders": ["node_modules", "node_modules/@types"], + "import/parsers": { + "@typescript-eslint/parser": typeScriptExtensions, + }, + "import/resolver": { + node: { + extensions: allExtensions, + }, + }, + }, rules: { curly: 1, "import/no-extraneous-dependencies": [ @@ -22,5 +38,20 @@ module.exports = { // would be nice to enable these rules later, but they are too noisy right now "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/ban-ts-comment": "off", + "import/no-cycle": "error", + // doesn't work: + // "import/no-restricted-paths": [ + // "error", + // { + // zones: [ + // { + // target: "./src/**/*", + // from: "./types/**/*", + // message: "Import from this module to types is not allowed.", + // }, + // ], + // }, + // ], }, }; diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d81f8225e0..b62b934ecb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,9 +5,6 @@ on: - main pull_request: types: [opened, synchronize, reopened, edited] - branches: - - main - - "project/**" jobs: build: @@ -70,13 +67,14 @@ jobs: run: npx playwright install --with-deps - name: Run Playwright tests + working-directory: ./tests run: npx playwright test - uses: actions/upload-artifact@v3 if: always() with: name: playwright-report - path: playwright-report/ + path: tests/playwright-report/ retention-days: 30 - name: Upload webpack stats artifact (editor) diff --git a/examples/editor/src/App.tsx b/examples/editor/examples/basic/App.tsx similarity index 69% rename from examples/editor/src/App.tsx rename to examples/editor/examples/basic/App.tsx index 128cdee58a..0c7ba3ccb4 100644 --- a/examples/editor/src/App.tsx +++ b/examples/editor/examples/basic/App.tsx @@ -1,19 +1,14 @@ -// import logo from './logo.svg' +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; import "@blocknote/core/style.css"; import { BlockNoteView, useBlockNote } from "@blocknote/react"; -import styles from "./App.module.css"; -import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; -function App() { +export function App() { const editor = useBlockNote({ - onEditorContentChange: (editor) => { - console.log(editor.topLevelBlocks); - }, domAttributes: { editor: { - class: styles.editor, + class: "editor", "data-test": "editor", }, }, @@ -23,7 +18,7 @@ function App() { // Give tests a way to get prosemirror instance (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; - return ; + return ; } export default App; diff --git a/examples/editor/examples/collaboration/App.tsx b/examples/editor/examples/collaboration/App.tsx new file mode 100644 index 0000000000..083cec29ca --- /dev/null +++ b/examples/editor/examples/collaboration/App.tsx @@ -0,0 +1,47 @@ +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; +import "@blocknote/core/style.css"; +import { BlockNoteView, useBlockNote } from "@blocknote/react"; + +import YPartyKitProvider from "y-partykit/provider"; +import * as Y from "yjs"; + +const doc = new Y.Doc(); + +const provider = new YPartyKitProvider( + "blocknote-dev.yousefed.partykit.dev", + // use a unique name as a "room" for your application: + "your-project-name", + doc +); + +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +export function App() { + const editor = useBlockNote({ + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + collaboration: { + // The Yjs Provider responsible for transporting updates: + provider, + // Where to store BlockNote data in the Y.Doc: + fragment: doc.getXmlFragment("document-storesss"), + // Information (name and color) for this user: + user: { + name: "My Username", + color: "#ff0000", + }, + }, + uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, + }); + + // Give tests a way to get prosemirror instance + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + + return ; +} + +export default App; diff --git a/examples/editor/examples/react-custom-blocks/App.tsx b/examples/editor/examples/react-custom-blocks/App.tsx new file mode 100644 index 0000000000..16ed570114 --- /dev/null +++ b/examples/editor/examples/react-custom-blocks/App.tsx @@ -0,0 +1,158 @@ +import { defaultBlockSpecs, defaultProps } from "@blocknote/core"; +import "@blocknote/core/style.css"; +import { + BlockNoteView, + createReactBlockSpec, + useBlockNote, +} from "@blocknote/react"; +import "../vanilla-custom-blocks/style.css"; + +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +// The types of alerts that users can choose from +const alertTypes = { + warning: { + icon: "⚠️", + color: "#e69819", + backgroundColor: "#fff6e6", + }, + error: { + icon: "⛔", + color: "#d80d0d", + backgroundColor: "#ffe6e6", + }, + info: { + icon: "ℹ️", + color: "#507aff", + backgroundColor: "#e6ebff", + }, + success: { + icon: "✅", + color: "#0bc10b", + backgroundColor: "#e6ffe6", + }, +}; + +export const alertBlock = createReactBlockSpec( + { + type: "alert", + propSchema: { + textAlignment: defaultProps.textAlignment, + textColor: defaultProps.textColor, + type: { + default: "warning" as const, + values: ["warning", "error", "info", "success"] as const, + }, + }, + content: "inline", + }, + { + render: (props) => ( +
+ +
+
+ ), + } +); + +const simpleImageBlock = createReactBlockSpec( + { + type: "simpleImage", + propSchema: { + src: { + default: + "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", + }, + }, + content: "none", + }, + { + render: (props) => ( + placeholder + ), + } +); + +export const bracketsParagraphBlock = createReactBlockSpec( + { + type: "bracketsParagraph", + content: "inline", + propSchema: { + ...defaultProps, + }, + }, + { + render: (props) => ( +
+
{"["}
+ {"{"} +
+ {"}"} +
{"]"}
+
+ ), + } +); + +export function ReactCustomBlocks() { + const editor = useBlockNote({ + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + blockSpecs: { + ...defaultBlockSpecs, + alert: alertBlock, + simpleImage: simpleImageBlock, + bracketsParagraph: bracketsParagraphBlock, + }, + initialContent: [ + { + type: "alert", + props: { + type: "success", + }, + content: "Alert", + }, + { + type: "simpleImage", + props: { + src: "https://t3.ftcdn.net/jpg/02/48/42/64/360_F_248426448_NVKLywWqArG2ADUxDq6QprtIzsF82dMF.jpg", + }, + }, + { + type: "bracketsParagraph", + content: "Brackets Paragraph", + }, + ], + }); + + // Give tests a way to get prosemirror instance + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + + return ; +} diff --git a/examples/editor/examples/react-custom-inline-content/App.tsx b/examples/editor/examples/react-custom-inline-content/App.tsx new file mode 100644 index 0000000000..2c2bc05add --- /dev/null +++ b/examples/editor/examples/react-custom-inline-content/App.tsx @@ -0,0 +1,89 @@ +import { defaultInlineContentSpecs } from "@blocknote/core"; +import "@blocknote/core/style.css"; +import { + BlockNoteView, + createReactInlineContentSpec, + useBlockNote, +} from "@blocknote/react"; + +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +const mention = createReactInlineContentSpec( + { + type: "mention", + propSchema: { + user: { + default: "", + }, + }, + content: "none", + }, + { + render: (props) => { + return @{props.inlineContent.props.user}; + }, + } +); + +const tag = createReactInlineContentSpec( + { + type: "tag", + propSchema: {}, + content: "styled", + }, + { + render: (props) => { + return ( + + # + + ); + }, + } +); + +export function ReactInlineContent() { + const editor = useBlockNote({ + inlineContentSpecs: { + mention, + tag, + ...defaultInlineContentSpecs, + }, + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + initialContent: [ + { + type: "paragraph", + content: [ + "I enjoy working with ", + { + type: "mention", + props: { + user: "Matthew", + }, + content: undefined, + } as any, + ], + }, + { + type: "paragraph", + content: [ + "I love ", + { + type: "tag", + content: "BlockNote", + } as any, + ], + }, + ], + }); + + // Give tests a way to get prosemirror instance + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + + return ; +} diff --git a/examples/editor/examples/react-custom-styles/App.tsx b/examples/editor/examples/react-custom-styles/App.tsx new file mode 100644 index 0000000000..beee30f385 --- /dev/null +++ b/examples/editor/examples/react-custom-styles/App.tsx @@ -0,0 +1,139 @@ +import { + BlockNoteEditor, + DefaultBlockSchema, + DefaultInlineContentSchema, + defaultStyleSpecs, +} from "@blocknote/core"; +import "@blocknote/core/style.css"; +import { + BlockNoteView, + createReactStyleSpec, + FormattingToolbarPositioner, + Toolbar, + ToolbarButton, + useActiveStyles, + useBlockNote, +} from "@blocknote/react"; + +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +const small = createReactStyleSpec( + { + type: "small", + propSchema: "boolean", + }, + { + render: (props) => { + return ; + }, + } +); + +const fontSize = createReactStyleSpec( + { + type: "fontSize", + propSchema: "string", + }, + { + render: (props) => { + return ( + + ); + }, + } +); + +type MyEditorType = BlockNoteEditor< + DefaultBlockSchema, + DefaultInlineContentSchema, + { + small: (typeof small)["config"]; + fontSize: (typeof fontSize)["config"]; + } +>; + +const CustomFormattingToolbar = (props: { editor: MyEditorType }) => { + const activeStyles = useActiveStyles(props.editor); + + return ( + + { + props.editor.toggleStyles({ + small: true, + }); + }} + isSelected={activeStyles.small}> + Small + + { + props.editor.toggleStyles({ + fontSize: "30px", + }); + }} + isSelected={!!activeStyles.fontSize}> + Font size + + + ); +}; + +const customReactStyles = { + ...defaultStyleSpecs, + small, + fontSize, +}; + +export function ReactStyles() { + const editor = useBlockNote( + { + styleSpecs: customReactStyles, + onEditorContentChange: (editor) => { + console.log(editor.topLevelBlocks); + }, + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + initialContent: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "large text", + styles: { + fontSize: "30px", + }, + }, + { + type: "text", + text: "small text", + styles: { + small: true, + }, + }, + ], + }, + ], + }, + [] + ); + + // Give tests a way to get prosemirror instance + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + + return ( + + + + ); +} diff --git a/examples/editor/examples/vanilla-custom-blocks/App.tsx b/examples/editor/examples/vanilla-custom-blocks/App.tsx new file mode 100644 index 0000000000..dadb8b9c67 --- /dev/null +++ b/examples/editor/examples/vanilla-custom-blocks/App.tsx @@ -0,0 +1,220 @@ +import { + createBlockSpec, + defaultBlockSpecs, + defaultProps, +} from "@blocknote/core"; +import "@blocknote/core/style.css"; +import { BlockNoteView, useBlockNote } from "@blocknote/react"; +import "./style.css"; + +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +// The types of alerts that users can choose from +const alertTypes = { + warning: { + icon: "⚠️", + color: "#e69819", + backgroundColor: "#fff6e6", + }, + error: { + icon: "⛔", + color: "#d80d0d", + backgroundColor: "#ffe6e6", + }, + info: { + icon: "ℹ️", + color: "#507aff", + backgroundColor: "#e6ebff", + }, + success: { + icon: "✅", + color: "#0bc10b", + backgroundColor: "#e6ffe6", + }, +}; + +const alertBlock = createBlockSpec( + { + type: "alert", + propSchema: { + textAlignment: defaultProps.textAlignment, + textColor: defaultProps.textColor, + type: { + default: "warning" as const, + values: ["warning", "error", "info", "success"] as const, + }, + }, + content: "inline", + }, + { + render: (block, editor) => { + const alert = document.createElement("div"); + alert.className = "alert"; + alert.style.backgroundColor = + alertTypes[block.props.type].backgroundColor; + + const dropdown = document.createElement("select"); + dropdown.contentEditable = "false"; + dropdown.addEventListener("change", () => { + // TODO: Something is not quite right with the typing seems like + editor.updateBlock(block, { + type: "alert", + props: { type: dropdown.value as keyof typeof alertTypes }, + }); + }); + dropdown.options.add( + new Option( + alertTypes["warning"].icon, + "warning", + block.props.type === "warning", + block.props.type === "warning" + ) + ); + dropdown.options.add( + new Option( + alertTypes["error"].icon, + "error", + block.props.type === "error", + block.props.type === "error" + ) + ); + dropdown.options.add( + new Option( + alertTypes["info"].icon, + "info", + block.props.type === "info", + block.props.type === "info" + ) + ); + dropdown.options.add( + new Option( + alertTypes["success"].icon, + "success", + block.props.type === "success", + block.props.type === "success" + ) + ); + alert.appendChild(dropdown); + + const inlineContent = document.createElement("div"); + inlineContent.style.flexGrow = "1"; + + alert.appendChild(inlineContent); + + return { + dom: alert, + contentDOM: inlineContent, + }; + }, + } +); + +const simpleImageBlock = createBlockSpec( + { + type: "simpleImage", + propSchema: { + src: { + default: + "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", + }, + }, + content: "none", + }, + { + render: (block) => { + const image = document.createElement("img"); + image.className = "simple-image"; + image.src = block.props.src; + image.alt = "placeholder"; + + return { + dom: image, + }; + }, + } +); + +const bracketsParagraphBlock = createBlockSpec( + { + type: "bracketsParagraph", + content: "inline", + propSchema: { + ...defaultProps, + }, + }, + { + render: () => { + const bracketsParagraph = document.createElement("div"); + bracketsParagraph.className = "brackets-paragraph"; + + const leftBracket = document.createElement("div"); + leftBracket.contentEditable = "false"; + leftBracket.innerText = "["; + bracketsParagraph.appendChild(leftBracket); + const leftCurlyBracket = document.createElement("span"); + leftCurlyBracket.contentEditable = "false"; + leftCurlyBracket.innerText = "{"; + bracketsParagraph.appendChild(leftCurlyBracket); + + const inlineContent = document.createElement("div"); + inlineContent.className = "inline-content"; + + bracketsParagraph.appendChild(inlineContent); + + const rightCurlyBracket = document.createElement("span"); + rightCurlyBracket.contentEditable = "false"; + rightCurlyBracket.innerText = "}"; + bracketsParagraph.appendChild(rightCurlyBracket); + const rightBracket = document.createElement("div"); + rightBracket.contentEditable = "false"; + rightBracket.innerText = "]"; + bracketsParagraph.appendChild(rightBracket); + + return { + dom: bracketsParagraph, + contentDOM: inlineContent, + }; + }, + } +); + +export function CustomBlocks() { + const editor = useBlockNote({ + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + blockSpecs: { + ...defaultBlockSpecs, + alert: alertBlock, + bracketsParagraph: bracketsParagraphBlock, + simpleImage: simpleImageBlock, + }, + initialContent: [ + { + type: "alert", + props: { + type: "success", + }, + content: ["Alert"], + }, + { + type: "simpleImage", + props: { + src: "https://t3.ftcdn.net/jpg/02/48/42/64/360_F_248426448_NVKLywWqArG2ADUxDq6QprtIzsF82dMF.jpg", + }, + }, + { + type: "bracketsParagraph", + content: "Brackets Paragraph", + }, + ], + }); + + // Give tests a way to get prosemirror instance + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + + return ; +} diff --git a/examples/editor/examples/vanilla-custom-blocks/style.css b/examples/editor/examples/vanilla-custom-blocks/style.css new file mode 100644 index 0000000000..38747f13bc --- /dev/null +++ b/examples/editor/examples/vanilla-custom-blocks/style.css @@ -0,0 +1,17 @@ +.alert, .brackets-paragraph { + display: flex; + justify-content: center; + align-items: center; + flex-grow: 1; + height: 48px; + padding: 4px; + max-width: 100%; +} + +.simple-image { + width: 100%; +} + +.inline-content { + flex-grow: 1; +} \ No newline at end of file diff --git a/examples/editor/examples/vanilla-custom-inline-content/App.tsx b/examples/editor/examples/vanilla-custom-inline-content/App.tsx new file mode 100644 index 0000000000..1b47f06db2 --- /dev/null +++ b/examples/editor/examples/vanilla-custom-inline-content/App.tsx @@ -0,0 +1,98 @@ +import { + createInlineContentSpec, + defaultInlineContentSpecs, +} from "@blocknote/core"; +import "@blocknote/core/style.css"; +import { BlockNoteView, useBlockNote } from "@blocknote/react"; + +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +const mention = createInlineContentSpec( + { + type: "mention", + propSchema: { + user: { + default: "", + }, + }, + content: "none", + }, + { + render: (inlineContent) => { + const mention = document.createElement("span"); + mention.textContent = `@${inlineContent.props.user}`; + + return { + dom: mention, + }; + }, + } +); + +const tag = createInlineContentSpec( + { + type: "tag", + propSchema: {}, + content: "styled", + }, + { + render: () => { + const tag = document.createElement("span"); + tag.textContent = "#"; + + const content = document.createElement("span"); + tag.appendChild(content); + + return { + dom: tag, + contentDOM: content, + }; + }, + } +); + +export function InlineContent() { + const editor = useBlockNote({ + inlineContentSpecs: { + mention, + tag, + ...defaultInlineContentSpecs, + }, + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + initialContent: [ + { + type: "paragraph", + content: [ + "I enjoy working with ", + { + type: "mention", + props: { + user: "Matthew", + }, + content: undefined, + } as any, + ], + }, + { + type: "paragraph", + content: [ + "I love ", + { + type: "tag", + content: "BlockNote", + } as any, + ], + }, + ], + }); + + // Give tests a way to get prosemirror instance + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + + return ; +} diff --git a/examples/editor/examples/vanilla-custom-styles/App.tsx b/examples/editor/examples/vanilla-custom-styles/App.tsx new file mode 100644 index 0000000000..ce92919452 --- /dev/null +++ b/examples/editor/examples/vanilla-custom-styles/App.tsx @@ -0,0 +1,146 @@ +import { + BlockNoteEditor, + createStyleSpec, + DefaultBlockSchema, + DefaultInlineContentSchema, + defaultStyleSpecs, +} from "@blocknote/core"; +import "@blocknote/core/style.css"; +import { + BlockNoteView, + FormattingToolbarPositioner, + Toolbar, + ToolbarButton, + useActiveStyles, + useBlockNote, +} from "@blocknote/react"; + +type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; + +const small = createStyleSpec( + { + type: "small", + propSchema: "boolean", + }, + { + render: () => { + const small = document.createElement("small"); + + return { + dom: small, + contentDOM: small, + }; + }, + } +); + +const fontSize = createStyleSpec( + { + type: "fontSize", + propSchema: "string", + }, + { + render: (value) => { + const span = document.createElement("span"); + span.style.fontSize = value; + + return { + dom: span, + contentDOM: span, + }; + }, + } +); + +type MyEditorType = BlockNoteEditor< + DefaultBlockSchema, + DefaultInlineContentSchema, + { + small: (typeof small)["config"]; + fontSize: (typeof fontSize)["config"]; + } +>; + +const CustomFormattingToolbar = (props: { editor: MyEditorType }) => { + const activeStyles = useActiveStyles(props.editor); + + return ( + + { + props.editor.toggleStyles({ + small: true, + }); + }} + isSelected={activeStyles.small}> + Small + + { + props.editor.toggleStyles({ + fontSize: "30px", + }); + }} + isSelected={!!activeStyles.fontSize}> + Font size + + + ); +}; + +export function Styles() { + const editor = useBlockNote( + { + styleSpecs: { + ...defaultStyleSpecs, + small, + fontSize, + }, + onEditorContentChange: (editor) => { + console.log(editor.topLevelBlocks); + }, + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + initialContent: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "large text", + styles: { + fontSize: "30px", + }, + }, + { + type: "text", + text: "small text", + styles: { + small: true, + }, + }, + ], + }, + ], + }, + [] + ); + + // Give tests a way to get prosemirror instance + (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; + + return ( + + + + ); +} diff --git a/examples/editor/package.json b/examples/editor/package.json index a912b3dd69..302ac8f095 100644 --- a/examples/editor/package.json +++ b/examples/editor/package.json @@ -11,8 +11,12 @@ "dependencies": { "@blocknote/core": "^0.9.6", "@blocknote/react": "^0.9.6", + "@mantine/core": "^5.6.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "y-partykit": "^0.0.0-4c022c1", + "yjs": "^13.6.10" }, "devDependencies": { "@types/react": "^18.0.25", diff --git a/examples/editor/serve.json b/examples/editor/serve.json new file mode 100644 index 0000000000..b07c32029e --- /dev/null +++ b/examples/editor/serve.json @@ -0,0 +1,3 @@ +{ + "rewrites": [{ "source": "/*", "destination": "/index.html" }] +} diff --git a/examples/editor/src/App.module.css b/examples/editor/src/App.css similarity index 50% rename from examples/editor/src/App.module.css rename to examples/editor/src/App.css index 8a90b5cd3f..8918687e58 100644 --- a/examples/editor/src/App.module.css +++ b/examples/editor/src/App.css @@ -2,3 +2,12 @@ margin: 0 calc((100% - 731px) / 2); height: 100%; } + +body { + margin: 0; +} + +.root { + height: 100%; + width: 100%; +} diff --git a/examples/editor/src/main.tsx b/examples/editor/src/main.tsx index e09ee19456..fa77e23551 100644 --- a/examples/editor/src/main.tsx +++ b/examples/editor/src/main.tsx @@ -1,13 +1,136 @@ +import { AppShell, Navbar, ScrollArea } from "@mantine/core"; import React from "react"; import { createRoot } from "react-dom/client"; -import App from "./App"; +import { + Link, + Outlet, + RouterProvider, + createBrowserRouter, +} from "react-router-dom"; + +import { App } from "../examples/basic/App"; +import { ReactCustomBlocks } from "../examples/react-custom-blocks/App"; +import { ReactInlineContent } from "../examples/react-custom-inline-content/App"; +import { ReactStyles } from "../examples/react-custom-styles/App"; +import { CustomBlocks } from "../examples/vanilla-custom-blocks/App"; +import { InlineContent } from "../examples/vanilla-custom-inline-content/App"; +import "./style.css"; window.React = React; +const editors = [ + { + title: "Basic", + path: "/simple", + component: App, + }, + { + title: "React custom styles", + path: "/react-styles", + component: ReactStyles, + }, + { + title: "Vanilla Inline content", + path: "/inline-content", + component: InlineContent, + }, + { + title: "React inline content", + path: "/react-inline-content", + component: ReactInlineContent, + }, + { + title: "Vanilla custom blocks", + path: "/custom-blocks", + component: CustomBlocks, + }, + { + title: "React custom blocks", + path: "/react-blocks", + component: ReactCustomBlocks, + }, +]; + +function Root() { + // const linkStyles = (theme) => ({ + // root: { + // // background: "red", + // ...theme.fn.hover({ + // backgroundColor: "#dfdfdd", + // }), + + // "&[data-active]": { + // backgroundColor: "rgba(0, 0, 0, 0.04)", + // }, + // }, + // // "root:hover": { background: "blue" }, + // }); + return ( + + + {editors.map((editor, i) => ( +
+ {editor.title} +
+ ))} + + {/* manitne } + // rightSection={} + /> + } + // rightSection={} + /> */} +
+ + ) + } + header={<>} + // header={
+ // {/* Header content */} + //
} + styles={(theme) => ({ + main: { + backgroundColor: "white", + // theme.colorScheme === "dark" + // ? theme.colors.dark[8] + // : theme.colors.gray[0], + }, + })}> + +
+ ); +} +const router = createBrowserRouter([ + { + path: "/", + element: , + children: editors.map((editor) => ({ + path: editor.path, + element: , + })), + }, +]); + const root = createRoot(document.getElementById("root")!); root.render( // TODO: StrictMode is causing duplicate mounts and conflicts with collaboration // - + // + // ); diff --git a/examples/editor/src/style.css b/examples/editor/src/style.css new file mode 100644 index 0000000000..6e003ac1f3 --- /dev/null +++ b/examples/editor/src/style.css @@ -0,0 +1,14 @@ +.editor { + margin: 0 calc((100% - 731px) / 2); + margin-top: 8px; + /* height: 100%; */ +} + +body { + margin: 0; +} + +/* .root { + height: 100%; + width: 100%; +} */ diff --git a/examples/editor/tsconfig.json b/examples/editor/tsconfig.json index 4f17a5d5b9..41460fa792 100644 --- a/examples/editor/tsconfig.json +++ b/examples/editor/tsconfig.json @@ -17,7 +17,7 @@ "jsx": "react-jsx", "composite": true }, - "include": ["src"], + "include": ["src", "examples"], "references": [ { "path": "./tsconfig.node.json" }, { "path": "../../packages/core/" }, diff --git a/examples/vanilla/src/main.tsx b/examples/vanilla/src/main.tsx index 6f8a84712a..926d39d3fb 100644 --- a/examples/vanilla/src/main.tsx +++ b/examples/vanilla/src/main.tsx @@ -1,11 +1,11 @@ import { BlockNoteEditor } from "@blocknote/core"; import "./index.css"; -import { addSideMenu } from "./ui/addSideMenu"; import { addFormattingToolbar } from "./ui/addFormattingToolbar"; -import { addSlashMenu } from "./ui/addSlashMenu"; import { addHyperlinkToolbar } from "./ui/addHyperlinkToolbar"; +import { addSideMenu } from "./ui/addSideMenu"; +import { addSlashMenu } from "./ui/addSlashMenu"; -const editor = new BlockNoteEditor({ +const editor = BlockNoteEditor.create({ parentElement: document.getElementById("root")!, onEditorContentChange: () => { console.log(editor.topLevelBlocks); diff --git a/examples/vanilla/src/ui/addSlashMenu.ts b/examples/vanilla/src/ui/addSlashMenu.ts index 3ecbd7fc46..936fcbcb75 100644 --- a/examples/vanilla/src/ui/addSlashMenu.ts +++ b/examples/vanilla/src/ui/addSlashMenu.ts @@ -9,8 +9,8 @@ export const addSlashMenu = (editor: BlockNoteEditor) => { let element: HTMLElement; function updateItems( - items: BaseSlashMenuItem[], - onClick: (item: BaseSlashMenuItem) => void, + items: BaseSlashMenuItem[], + onClick: (item: BaseSlashMenuItem) => void, selected: number ) { element.innerHTML = ""; diff --git a/package-lock.json b/package-lock.json index 2215aa4c49..d5a991ec25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,11 +7,10 @@ "name": "root", "workspaces": [ "packages/*", - "examples/*" + "examples/*", + "tests" ], "devDependencies": { - "@playwright/experimental-ct-react": "^1.38.1", - "@playwright/test": "^1.38.1", "@typescript-eslint/eslint-plugin": "^5.5.0", "@typescript-eslint/parser": "^5.5.0", "eslint": "^8.22.0", @@ -19,7 +18,7 @@ "eslint-plugin-import": "^2.28.0", "lerna": "^5.4.0", "patch-package": "^6.4.7", - "typescript": "^5.0.4" + "typescript": "^5.2.2" } }, "examples/editor": { @@ -28,8 +27,12 @@ "dependencies": { "@blocknote/core": "^0.9.6", "@blocknote/react": "^0.9.6", + "@mantine/core": "^5.6.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "y-partykit": "^0.0.0-4c022c1", + "yjs": "^13.6.10" }, "devDependencies": { "@types/react": "^18.0.25", @@ -604,6 +607,23 @@ } } }, + "examples/playground": { + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "next": "14.0.3", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.0.3", + "typescript": "^5" + } + }, "examples/vanilla": { "name": "@blocknote/example-vanilla", "version": "0.9.6", @@ -2836,6 +2856,10 @@ "resolved": "packages/react", "link": true }, + "node_modules/@blocknote/tests": { + "resolved": "tests", + "link": true + }, "node_modules/@codemirror/autocomplete": { "version": "6.7.1", "dev": true, @@ -6171,6 +6195,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@remix-run/router": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz", + "integrity": "sha512-5dMOnVnefRsl4uRnAdoWjtVTdh8e6aZqgM4puy9nmEADH72ck+uXwzpJLEKE9Q6F8ZljNewLgmTfkxUrBdv4WA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@resvg/resvg-wasm": { "version": "2.4.1", "license": "MPL-2.0", @@ -6191,9 +6223,10 @@ } }, "node_modules/@rushstack/eslint-patch": { - "version": "1.3.0", - "dev": true, - "license": "MIT" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.6.0.tgz", + "integrity": "sha512-2/U3GXA6YiPYQDLGwtGlnNgKYBSwCFIHf8Y9LUY5VATHdtbLlU0Y1R3QoBnT0aB4qv/BEiVVsj7LJXoQCgJ2vA==", + "dev": true }, "node_modules/@shuding/opentype.js": { "version": "1.4.0-beta.0", @@ -6425,6 +6458,42 @@ "@tiptap/core": "^2.0.0" } }, + "node_modules/@tiptap/extension-table-cell": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.1.12.tgz", + "integrity": "sha512-hextcfVTdwX8G7s8Q/V6LW2aUhGvPgu1dfV+kVVO42AFHxG+6PIkDOUuHphGajG3Nrs129bjMDWb8jphj38dUg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.1.12.tgz", + "integrity": "sha512-a4WZ5Z7gqQ/QlK8cK2d1ONYdma/J5+yH/0SNtQhkfELoS45GsLJh89OyKO0W0FnY6Mg0RoH1FsoBD+cqm0yazA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.1.12.tgz", + "integrity": "sha512-0kPr+zngQC1YQRcU6+Fl3CpIW/SdJhVJ5qOLpQleXrLPdjmZQd3Z1DXvOSDphYjXCowGPCxeUa++6bo7IoEMJw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, "node_modules/@tiptap/extension-text": { "version": "2.0.3", "license": "MIT", @@ -6564,6 +6633,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash.foreach": { + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@types/lodash.foreach/-/lodash.foreach-4.5.9.tgz", + "integrity": "sha512-vmq0p/FK66PsALXRmK/qsnlLlCpnudvozWYrxJImHujHhXMADdeoPEY10zwmu26437w85wCvdxUqpFi+ALtkiQ==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/lodash.groupby": { + "version": "4.6.9", + "resolved": "https://registry.npmjs.org/@types/lodash.groupby/-/lodash.groupby-4.6.9.tgz", + "integrity": "sha512-z2xtCX2ko7GrqORnnYea4+ksT7jZNAvaOcLd6mP9M7J09RHvJs06W8BGdQQAX8ARef09VQLdeRilSOcfHlDQJQ==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/lodash.merge": { + "version": "4.6.9", + "resolved": "https://registry.npmjs.org/@types/lodash.merge/-/lodash.merge-4.6.9.tgz", + "integrity": "sha512-23sHDPmzd59kUgWyKGiOMO2Qb9YtqRO/x4IhkgNUiPQ1+5MUVqi6bCZeq9nBJ17msjIMbEIO5u+XW4Kz6aGUhQ==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mdast": { "version": "3.0.11", "license": "MIT", @@ -7692,14 +7788,15 @@ "license": "MIT" }, "node_modules/array-includes": { - "version": "3.1.6", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", "is-string": "^1.0.7" }, "engines": { @@ -7718,16 +7815,16 @@ } }, "node_modules/array.prototype.findlastindex": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.2.tgz", - "integrity": "sha512-tb5thFFlUcp7NdNF6/MpDk/1r/4awWG1FIz3YqDf+/zJSTezBb+/5WViH41obXULHVpDzoiCLpJ/ZO9YbJMsdw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.1.3" + "get-intrinsic": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -7737,13 +7834,14 @@ } }, "node_modules/array.prototype.flat": { - "version": "1.3.1", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" }, "engines": { @@ -7754,13 +7852,14 @@ } }, "node_modules/array.prototype.flatmap": { - "version": "1.3.1", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", "es-shim-unscopables": "^1.0.0" }, "engines": { @@ -7782,6 +7881,27 @@ "get-intrinsic": "^1.1.3" } }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/arrify": { "version": "1.0.1", "dev": true, @@ -7813,6 +7933,15 @@ "dev": true, "license": "MIT" }, + "node_modules/asynciterator.prototype": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz", + "integrity": "sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + } + }, "node_modules/asynckit": { "version": "0.4.0", "dev": true, @@ -8226,12 +8355,14 @@ } }, "node_modules/call-bind": { - "version": "1.0.2", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", "dev": true, - "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9099,6 +9230,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "dev": true, @@ -9108,10 +9253,12 @@ } }, "node_modules/define-properties": { - "version": "1.2.0", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, - "license": "MIT", "dependencies": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, @@ -9368,24 +9515,26 @@ } }, "node_modules/es-abstract": { - "version": "1.21.2", + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", "dev": true, - "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "call-bind": "^1.0.5", "es-set-tostringtag": "^2.0.1", "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.2.0", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", "get-symbol-description": "^1.0.0", "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has": "^1.0.3", "has-property-descriptors": "^1.0.0", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", + "hasown": "^2.0.0", "internal-slot": "^1.0.5", "is-array-buffer": "^3.0.2", "is-callable": "^1.2.7", @@ -9393,19 +9542,23 @@ "is-regex": "^1.1.4", "is-shared-array-buffer": "^1.0.2", "is-string": "^1.0.7", - "is-typed-array": "^1.1.10", + "is-typed-array": "^1.1.12", "is-weakref": "^1.0.2", - "object-inspect": "^1.12.3", + "object-inspect": "^1.13.1", "object-keys": "^1.1.1", "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.7", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", "typed-array-length": "^1.0.4", "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.9" + "which-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -9433,6 +9586,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-iterator-helpers": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", + "integrity": "sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==", + "dev": true, + "dependencies": { + "asynciterator.prototype": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.1", + "es-set-tostringtag": "^2.0.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "iterator.prototype": "^1.1.2", + "safe-array-concat": "^1.0.1" + } + }, "node_modules/es-set-tostringtag": { "version": "2.0.1", "dev": true, @@ -9920,6 +10095,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10018,13 +10194,14 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.7", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, - "license": "MIT", "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.11.0", - "resolve": "^1.22.1" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -10077,27 +10254,26 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.28.0.tgz", - "integrity": "sha512-B8s/n+ZluN7sxj9eUf7/pRFERX0r5bnFA2dCaLHy2ZeaQEAz0k+ZZkFWRFHJAqxfxQDx6KLv9LeIki7cFdwW+Q==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", + "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", "dev": true, "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.findlastindex": "^1.2.2", - "array.prototype.flat": "^1.3.1", - "array.prototype.flatmap": "^1.3.1", + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.7", + "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.8.0", - "has": "^1.0.3", - "is-core-module": "^2.12.1", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.fromentries": "^2.0.6", - "object.groupby": "^1.0.0", - "object.values": "^1.1.6", - "resolve": "^1.22.3", + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", "semver": "^6.3.1", "tsconfig-paths": "^3.14.2" }, @@ -10180,14 +10356,16 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.32.2", + "version": "7.33.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", + "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", "dev": true, - "license": "MIT", "dependencies": { "array-includes": "^3.1.6", "array.prototype.flatmap": "^1.3.1", "array.prototype.tosorted": "^1.1.1", "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.0.12", "estraverse": "^5.3.0", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", @@ -10197,7 +10375,7 @@ "object.values": "^1.1.6", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.4", - "semver": "^6.3.0", + "semver": "^6.3.1", "string.prototype.matchall": "^4.0.8" }, "engines": { @@ -10506,9 +10684,10 @@ "license": "MIT" }, "node_modules/fast-glob": { - "version": "3.2.12", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -10772,18 +10951,23 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "license": "MIT" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/function.prototype.name": { - "version": "1.1.5", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" }, "engines": { "node": ">= 0.4" @@ -10842,14 +11026,15 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.1", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "dev": true, - "license": "MIT", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11184,6 +11369,7 @@ }, "node_modules/has": { "version": "1.0.3", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.1" @@ -11259,6 +11445,17 @@ "dev": true, "license": "ISC" }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-embedded": { "version": "2.0.1", "license": "MIT", @@ -11545,6 +11742,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.0.tgz", + "integrity": "sha512-KlClZ3/Qy5UgvpvVvDomGhnQhNWH5INE8GwvSIQ9CWt1K0zbbXrl7eN5bWaafOZgtmO3jMPwUqmrmEwinhPq1w==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "dev": true, @@ -11955,6 +12161,21 @@ "version": "0.2.1", "license": "MIT" }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-bigint": { "version": "1.0.4", "dev": true, @@ -12036,10 +12257,11 @@ } }, "node_modules/is-core-module": { - "version": "2.12.1", - "license": "MIT", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12101,6 +12323,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "dev": true, @@ -12109,6 +12343,21 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "dev": true, @@ -12304,15 +12553,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.10", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", "dev": true, - "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "which-typed-array": "^1.1.11" }, "engines": { "node": ">= 0.4" @@ -12417,6 +12663,19 @@ "node": ">=0.12" } }, + "node_modules/iterator.prototype": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", + "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" + } + }, "node_modules/jake": { "version": "10.8.7", "dev": true, @@ -13068,12 +13327,24 @@ }, "node_modules/lodash": { "version": "4.17.21", - "license": "MIT" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true }, "node_modules/lodash.debounce": { "version": "4.0.8", "license": "MIT" }, + "node_modules/lodash.foreach": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz", + "integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "dev": true, @@ -13086,7 +13357,6 @@ }, "node_modules/lodash.merge": { "version": "4.6.2", - "dev": true, "license": "MIT" }, "node_modules/log-symbols": { @@ -15348,9 +15618,10 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -15409,13 +15680,14 @@ } }, "node_modules/object.fromentries": { - "version": "2.0.6", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -15425,14 +15697,14 @@ } }, "node_modules/object.groupby": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.0.tgz", - "integrity": "sha512-70MWG6NfRH9GnbZOikuhPPYzpUpof9iW2J9E4dW7FXTqPNb6rllE6u39SKwwiNh8lCwX3DDb5OgcKGiEBrTTyw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", - "es-abstract": "^1.21.2", + "es-abstract": "^1.22.1", "get-intrinsic": "^1.2.1" } }, @@ -15469,13 +15741,14 @@ } }, "node_modules/object.values": { - "version": "1.1.6", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -16373,7 +16646,9 @@ } }, "node_modules/postcss": { - "version": "8.4.24", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "funding": [ { @@ -16389,7 +16664,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -16779,7 +17053,8 @@ }, "node_modules/react-dom": { "version": "18.2.0", - "license": "MIT", + "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", "scheduler": "^0.23.0" @@ -16807,11 +17082,41 @@ "node": ">=0.10.0" } }, - "node_modules/react-textarea-autosize": { - "version": "8.3.4", - "license": "MIT", + "node_modules/react-router": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.20.0.tgz", + "integrity": "sha512-pVvzsSsgUxxtuNfTHC4IxjATs10UaAtvLGVSA1tbUE4GDaOSU1Esu2xF5nWLz7KPiMuW8BJWuPFdlGYJ7/rW0w==", "dependencies": { - "@babel/runtime": "^7.10.2", + "@remix-run/router": "1.13.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.20.0.tgz", + "integrity": "sha512-CbcKjEyiSVpA6UtCHOIYLUYn/UJfwzp55va4yEfpk7JBN3GPqWfHrdLkAvNCcpXr8QoihcDMuk0dzWZxtlB/mQ==", + "dependencies": { + "@remix-run/router": "1.13.0", + "react-router": "6.20.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-textarea-autosize": { + "version": "8.3.4", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.2", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, @@ -17148,6 +17453,26 @@ "node": ">=8" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", + "integrity": "sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "globalthis": "^1.0.3", + "which-builtin-type": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regenerate": { "version": "1.4.2", "dev": true, @@ -17177,13 +17502,14 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.0", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", - "functions-have-names": "^1.2.3" + "set-function-name": "^2.0.0" }, "engines": { "node": ">= 0.4" @@ -17226,6 +17552,156 @@ "jsesc": "bin/jsesc" } }, + "node_modules/rehype-format": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.0.tgz", + "integrity": "sha512-kM4II8krCHmUhxrlvzFSptvaWh280Fr7UGNJU5DCMuvmAwGCNmGfi9CvFAQK6JDjsNoRMWQStglK3zKJH685Wg==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "rehype-minify-whitespace": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/@types/hast": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.3.tgz", + "integrity": "sha512-2fYGlaDy/qyLlhidX42wAH0KBi2TCjKMH8CHmBXgRlJ3Y+OXTiqsPQ6IWarZKwF1JoUcAJdPogv1d4b0COTpmQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/rehype-format/node_modules/@types/unist": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", + "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" + }, + "node_modules/rehype-format/node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-is-body-ok-link": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.0.tgz", + "integrity": "sha512-VFHY5bo2nY8HiV6nir2ynmEB1XkxzuUffhEGeVx7orbu/B1KaGyeGgMZldvMVx5xWrDlLLG/kQ6YkJAMkBEx0w==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/rehype-minify-whitespace": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-minify-whitespace/-/rehype-minify-whitespace-6.0.0.tgz", + "integrity": "sha512-i9It4YHR0Sf3GsnlR5jFUKXRr9oayvEk9GKQUkwZv6hs70OH9q3OCZrq9PpLvIGKt3W+JxBOxCidNVpH/6rWdA==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format/node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-minify-whitespace": { "version": "5.0.1", "license": "MIT", @@ -17355,11 +17831,11 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.3", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.3.tgz", - "integrity": "sha512-P8ur/gp/AmbEzjr729bZnLjXK5Z+4P0zhIJgBgzqRih7hL7BOukHGtSTA3ACMY467GRFz3duQsi0bDZdR7DKdw==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dependencies": { - "is-core-module": "^2.12.0", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -17542,6 +18018,24 @@ "node": ">=6" } }, + "node_modules/safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "dev": true, @@ -17751,6 +18245,35 @@ "dev": true, "license": "ISC" }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shallow-clone": { "version": "3.0.1", "dev": true, @@ -18041,13 +18564,14 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.7", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -18057,26 +18581,28 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.6", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/string.prototype.trimstart": { - "version": "1.0.6", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -18517,6 +19043,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typed-array-length": { "version": "1.0.4", "dev": true, @@ -18544,15 +19121,16 @@ } }, "node_modules/typescript": { - "version": "5.0.4", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12.20" + "node": ">=14.17" } }, "node_modules/uc.micro": { @@ -19353,6 +19931,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-builtin-type": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.3.tgz", + "integrity": "sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==", + "dev": true, + "dependencies": { + "function.prototype.name": "^1.1.5", + "has-tostringtag": "^1.0.0", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.0.2", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-collection": { "version": "1.0.1", "dev": true, @@ -19368,16 +19972,16 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.9", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", "dev": true, - "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "call-bind": "^1.0.4", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -19658,6 +20262,18 @@ "node": ">=0.4" } }, + "node_modules/y-partykit": { + "version": "0.0.0-4c022c1", + "resolved": "https://registry.npmjs.org/y-partykit/-/y-partykit-0.0.0-4c022c1.tgz", + "integrity": "sha512-DC4+2SdYjp4TfgcPjZFmHiMrhBkmBgapAN/KQ2ZlnSUYGnFtZZ11+Mkk6bAMCmwRKYKWA0lwVjznd7jpsoQe8g==", + "dependencies": { + "lib0": "^0.2.86", + "lodash.debounce": "^4.0.8", + "react": "^18.2.0", + "y-protocols": "^1.0.6", + "yjs": "^13.6.8" + } + }, "node_modules/y-prosemirror": { "version": "1.0.20", "license": "MIT", @@ -19800,15 +20416,19 @@ "@tiptap/extension-link": "^2.0.3", "@tiptap/extension-paragraph": "^2.0.3", "@tiptap/extension-strike": "^2.0.3", + "@tiptap/extension-table-cell": "^2.0.3", + "@tiptap/extension-table-header": "^2.0.3", + "@tiptap/extension-table-row": "^2.0.3", "@tiptap/extension-text": "^2.0.3", "@tiptap/extension-underline": "^2.0.3", "@tiptap/pm": "^2.0.3", "hast-util-from-dom": "^4.2.0", - "lodash": "^4.17.21", "prosemirror-model": "^1.18.3", "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.3.4", "prosemirror-transform": "^1.7.2", "prosemirror-view": "^1.31.4", + "rehype-format": "^5.0.0", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", @@ -19824,7 +20444,6 @@ }, "devDependencies": { "@types/hast": "^2.3.4", - "@types/lodash": "^4.14.179", "@types/uuid": "^8.3.4", "eslint": "^8.10.0", "jsdom": "^21.1.0", @@ -19917,6 +20536,18 @@ "node": "^10 || ^12 || >=14" } }, + "packages/core/node_modules/prosemirror-tables": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.3.4.tgz", + "integrity": "sha512-z6uLSQ1BLC3rgbGwZmpfb+xkdvD7W/UOsURDfognZFYaTtc0gsk7u/t71Yijp2eLflVpffMk6X0u0+u+MMDvIw==", + "dependencies": { + "prosemirror-keymap": "^1.1.2", + "prosemirror-model": "^1.8.1", + "prosemirror-state": "^1.3.1", + "prosemirror-transform": "^1.2.1", + "prosemirror-view": "^1.13.3" + } + }, "packages/core/node_modules/rollup": { "version": "3.27.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.27.0.tgz", @@ -20001,13 +20632,19 @@ "@tippyjs/react": "^4.2.6", "@tiptap/core": "^2.0.3", "@tiptap/react": "^2.0.3", - "lodash": "^4.17.21", - "react": "^18.2.0", + "lodash.foreach": "^4.5.0", + "lodash.groupby": "^4.6.0", + "lodash.merge": "^4.6.2", + "react": "^18", + "react-dom": "^18.2.0", "react-icons": "^4.3.1", "tippy.js": "^6.3.7", "use-prefers-color-scheme": "^1.1.3" }, "devDependencies": { + "@types/lodash.foreach": "^4.5.9", + "@types/lodash.groupby": "^4.6.9", + "@types/lodash.merge": "^4.6.9", "@types/react": "^18.0.25", "@types/react-dom": "^18.0.9", "@vitejs/plugin-react": "^4.0.4", @@ -20017,7 +20654,8 @@ "typescript": "^5.0.4", "vite": "^4.4.8", "vite-plugin-eslint": "^1.8.1", - "vite-plugin-externalize-deps": "^0.7.0" + "vite-plugin-externalize-deps": "^0.7.0", + "vitest": "^0.34.1" }, "peerDependencies": { "react": "^18", @@ -20608,13 +21246,31 @@ } }, "packages/website/node_modules/y-partykit": { - "version": "0.0.0-4d484bc", - "license": "ISC", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/y-partykit/-/y-partykit-0.0.10.tgz", + "integrity": "sha512-DFzaQbbgUm4jZx8oj3uHigRUKw0sYvq7QQlw/Kq+yOrci+AD0b38cgyWEs5TWYQDCC2a7Wqs33oiJ5YgOCDqVg==", "dependencies": { - "lib0": "^0.2.60", + "lib0": "^0.2.86", "lodash.debounce": "^4.0.8", - "y-protocols": "^1.0.5", - "yjs": "^13.5.44" + "react": "^18.2.0", + "y-protocols": "^1.0.6", + "yjs": "^13.6.8" + } + }, + "tests": { + "name": "@blocknote/tests", + "version": "0.9.6", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@blocknote/core": "^0.9.6", + "@blocknote/react": "^0.9.6", + "@playwright/experimental-ct-react": "^1.38.1", + "@playwright/test": "^1.38.1", + "eslint": "^8.10.0", + "react-icons": "^4.3.1" } } } diff --git a/package.json b/package.json index 74b0a5febe..bbb18ef9ea 100644 --- a/package.json +++ b/package.json @@ -3,37 +3,32 @@ "private": true, "workspaces": [ "packages/*", - "examples/*" + "examples/*", + "tests" ], "devDependencies": { - "@playwright/experimental-ct-react": "^1.38.1", - "@playwright/test": "^1.38.1", "eslint": "^8.22.0", "eslint-plugin-import": "^2.28.0", "eslint-config-react-app": "^7.0.0", "lerna": "^5.4.0", "patch-package": "^6.4.7", - "typescript": "^5.0.4", + "typescript": "^5.2.2", "@typescript-eslint/parser": "^5.5.0", "@typescript-eslint/eslint-plugin": "^5.5.0" }, "scripts": { "start": "lerna run --stream --scope @blocknote/example-editor dev", - "start:built": "npx serve examples/editor/dist", - "test:updateSnaps": "docker run --rm -e RUN_IN_DOCKER=true --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.38.1-focal npx playwright test -u", + "start:built": "npx serve examples/editor/dist -c ../serve.json", "build": "lerna run --stream build --concurrency 1", "build:site": "lerna run --stream docs:build --concurrency 1", "lint": "lerna run --stream lint", "bootstrap": "lerna bootstrap --ci -- --force && patch-package", "install-new-packages": "lerna bootstrap -- --force && patch-package", - "playwright": "npx playwright test", "test": "lerna run --stream test", "install-playwright": "npx playwright install --with-deps", "deploy": "lerna publish -- --access public", "prepublishOnly": "npm run build && cp README.md packages/core/README.md && cp README.md packages/react/README.md", - "postpublish": "rm -rf packages/core/README.md && rm -rf packages/react/README.md", - "test-ct": "playwright test -c playwright-ct.config.ts --headed", - "test-ct:updateSnaps": "docker run --rm -e RUN_IN_DOCKER=true --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.35.1-focal npm install && playwright test -c playwright-ct.config.ts -u" + "postpublish": "rm -rf packages/core/README.md && rm -rf packages/react/README.md" }, "overrides": { "react": "18.2.0", diff --git a/packages/core/package.json b/packages/core/package.json index 49d777f92d..aeb2147afa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -66,18 +66,22 @@ "@tiptap/extension-link": "^2.0.3", "@tiptap/extension-paragraph": "^2.0.3", "@tiptap/extension-strike": "^2.0.3", + "@tiptap/extension-table-cell": "^2.0.3", + "@tiptap/extension-table-header": "^2.0.3", + "@tiptap/extension-table-row": "^2.0.3", "@tiptap/extension-text": "^2.0.3", "@tiptap/extension-underline": "^2.0.3", "@tiptap/pm": "^2.0.3", "hast-util-from-dom": "^4.2.0", - "lodash": "^4.17.21", "prosemirror-model": "^1.18.3", "prosemirror-state": "^1.4.3", "prosemirror-transform": "^1.7.2", "prosemirror-view": "^1.31.4", + "prosemirror-tables": "^1.3.4", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", + "rehype-format": "^5.0.0", "remark-gfm": "^3.0.1", "remark-parse": "^10.0.1", "remark-rehype": "^10.1.0", @@ -90,7 +94,6 @@ }, "devDependencies": { "@types/hast": "^2.3.4", - "@types/lodash": "^4.14.179", "@types/uuid": "^8.3.4", "eslint": "^8.10.0", "jsdom": "^21.1.0", diff --git a/packages/core/src/api/README.md b/packages/core/src/api/README.md new file mode 100644 index 0000000000..2f7465ed67 --- /dev/null +++ b/packages/core/src/api/README.md @@ -0,0 +1,8 @@ +### @blocknote/core/src/api + +Implements the BlockNote API surface + +- `blockManipulation`: API to insert / update / remove blocks +- `exporters`: exporting to HTML / markdown / other formats +- `nodeConversions`: internal API for converting between BlockNote Schema (Blocks) and Prosemirror (Nodes) +- `parsers`: importing from HTML / markdown / other formats \ No newline at end of file diff --git a/packages/core/src/api/blockManipulation/blockManipulation.test.ts b/packages/core/src/api/blockManipulation/blockManipulation.test.ts index 790962015a..f7d0521e2a 100644 --- a/packages/core/src/api/blockManipulation/blockManipulation.test.ts +++ b/packages/core/src/api/blockManipulation/blockManipulation.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { Block, BlockNoteEditor, PartialBlock } from "../.."; +import { + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, +} from "../../blocks/defaultBlocks"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { Block, PartialBlock } from "../../schema/blocks/types"; let editor: BlockNoteEditor; @@ -14,16 +20,28 @@ function waitForEditor() { }); } -let singleBlock: PartialBlock; - -let multipleBlocks: PartialBlock[]; - -let insert: (placement: "before" | "nested" | "after") => Block[]; +let singleBlock: PartialBlock< + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema +>; + +let multipleBlocks: PartialBlock< + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema +>[]; + +let insert: ( + placement: "before" | "nested" | "after" +) => Block< + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema +>[]; beforeEach(() => { - (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; - - editor = new BlockNoteEditor(); + editor = BlockNoteEditor.create(); singleBlock = { type: "paragraph", @@ -76,8 +94,52 @@ beforeEach(() => { afterEach(() => { editor._tiptapEditor.destroy(); editor = undefined as any; +}); - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; +describe("Test strong typing", () => { + it("checks that block types are inferred correctly", () => { + try { + editor.updateBlock( + { id: "sdf" }, + { + // @ts-expect-error invalid type + type: "non-existing", + } + ); + } catch (e) { + // id doesn't exists, which is fine, this is a compile-time check + } + }); + + it("checks that block props are inferred correctly", () => { + try { + editor.updateBlock( + { id: "sdf" }, + { + type: "paragraph", + props: { + // @ts-expect-error level not suitable for paragraph + level: 1, + }, + } + ); + } catch (e) { + // id doesn't exists, which is fine, this is a compile-time check + } + try { + editor.updateBlock( + { id: "sdf" }, + { + type: "heading", + props: { + level: 1, + }, + } + ); + } catch (e) { + // id doesn't exists, which is fine, this is a compile-time check + } + }); }); describe("Inserting Blocks with Different Placements", () => { diff --git a/packages/core/src/api/blockManipulation/blockManipulation.ts b/packages/core/src/api/blockManipulation/blockManipulation.ts index 3b763f85aa..6f528b9b14 100644 --- a/packages/core/src/api/blockManipulation/blockManipulation.ts +++ b/packages/core/src/api/blockManipulation/blockManipulation.ts @@ -1,30 +1,42 @@ import { Editor } from "@tiptap/core"; import { Node } from "prosemirror-model"; + +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { BlockIdentifier, BlockSchema, + InlineContentSchema, PartialBlock, -} from "../../extensions/Blocks/api/blockTypes"; + StyleSchema, +} from "../../schema"; import { blockToNode } from "../nodeConversions/nodeConversions"; -import { getNodeById } from "../util/nodeUtil"; - -export function insertBlocks( - blocksToInsert: PartialBlock[], +import { getNodeById } from "../nodeUtil"; + +export function insertBlocks< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, placement: "before" | "after" | "nested" = "before", - editor: Editor + editor: BlockNoteEditor ): void { + const ttEditor = editor._tiptapEditor; + const id = typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id; const nodesToInsert: Node[] = []; for (const blockSpec of blocksToInsert) { - nodesToInsert.push(blockToNode(blockSpec, editor.schema)); + nodesToInsert.push( + blockToNode(blockSpec, ttEditor.schema, editor.styleSchema) + ); } let insertionPos = -1; - const { node, posBeforeNode } = getNodeById(id, editor.state.doc); + const { node, posBeforeNode } = getNodeById(id, ttEditor.state.doc); if (placement === "before") { insertionPos = posBeforeNode; @@ -39,13 +51,13 @@ export function insertBlocks( if (node.childCount < 2) { insertionPos = posBeforeNode + node.firstChild!.nodeSize + 1; - const blockGroupNode = editor.state.schema.nodes["blockGroup"].create( + const blockGroupNode = ttEditor.state.schema.nodes["blockGroup"].create( {}, nodesToInsert ); - editor.view.dispatch( - editor.state.tr.insert(insertionPos, blockGroupNode) + ttEditor.view.dispatch( + ttEditor.state.tr.insert(insertionPos, blockGroupNode) ); return; @@ -54,12 +66,16 @@ export function insertBlocks( insertionPos = posBeforeNode + node.firstChild!.nodeSize + 2; } - editor.view.dispatch(editor.state.tr.insert(insertionPos, nodesToInsert)); + ttEditor.view.dispatch(ttEditor.state.tr.insert(insertionPos, nodesToInsert)); } -export function updateBlock( +export function updateBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( blockToUpdate: BlockIdentifier, - update: PartialBlock, + update: PartialBlock, editor: Editor ) { const id = @@ -116,11 +132,15 @@ export function removeBlocks( } } -export function replaceBlocks( +export function replaceBlocks< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( blocksToRemove: BlockIdentifier[], - blocksToInsert: PartialBlock[], - editor: Editor + blocksToInsert: PartialBlock[], + editor: BlockNoteEditor ) { insertBlocks(blocksToInsert, blocksToRemove[0], "before", editor); - removeBlocks(blocksToRemove, editor); + removeBlocks(blocksToRemove, editor._tiptapEditor); } diff --git a/packages/core/src/api/exporters/copyExtension.ts b/packages/core/src/api/exporters/copyExtension.ts new file mode 100644 index 0000000000..0326e24b5c --- /dev/null +++ b/packages/core/src/api/exporters/copyExtension.ts @@ -0,0 +1,68 @@ +import { Extension } from "@tiptap/core"; +import { Plugin } from "prosemirror-state"; + +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; +import { createExternalHTMLExporter } from "./html/externalHTMLExporter"; +import { createInternalHTMLSerializer } from "./html/internalHTMLSerializer"; +import { cleanHTMLToMarkdown } from "./markdown/markdownExporter"; + +export const createCopyToClipboardExtension = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor +) => + Extension.create<{ editor: BlockNoteEditor }, undefined>({ + name: "copyToClipboard", + addProseMirrorPlugins() { + const tiptap = this.editor; + const schema = this.editor.schema; + return [ + new Plugin({ + props: { + handleDOMEvents: { + copy(_view, event) { + // Stops the default browser copy behaviour. + event.preventDefault(); + event.clipboardData!.clearData(); + + const selectedFragment = + tiptap.state.selection.content().content; + + const internalHTMLSerializer = createInternalHTMLSerializer( + schema, + editor + ); + const internalHTML = + internalHTMLSerializer.serializeProseMirrorFragment( + selectedFragment + ); + + const externalHTMLExporter = createExternalHTMLExporter( + schema, + editor + ); + const externalHTML = + externalHTMLExporter.exportProseMirrorFragment( + selectedFragment + ); + + const plainText = cleanHTMLToMarkdown(externalHTML); + + // TODO: Writing to other MIME types not working in Safari for + // some reason. + event.clipboardData!.setData("blocknote/html", internalHTML); + event.clipboardData!.setData("text/html", externalHTML); + event.clipboardData!.setData("text/plain", plainText); + + // Prevent default PM handler to be called + return true; + }, + }, + }, + }), + ]; + }, + }); diff --git a/packages/core/src/api/exporters/html/__snapshots__/complex/misc/external.html b/packages/core/src/api/exporters/html/__snapshots__/complex/misc/external.html new file mode 100644 index 0000000000..c6f43c11b1 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/complex/misc/external.html @@ -0,0 +1 @@ +

Heading 2

Paragraph

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/complex/misc/internal.html b/packages/core/src/api/exporters/html/__snapshots__/complex/misc/internal.html new file mode 100644 index 0000000000..efec8f89d3 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/complex/misc/internal.html @@ -0,0 +1 @@ +

Heading 2

Paragraph

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/external.html new file mode 100644 index 0000000000..1930c65a95 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/external.html @@ -0,0 +1 @@ +

Hello World

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/internal.html new file mode 100644 index 0000000000..46cfe14e45 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/basic/internal.html @@ -0,0 +1 @@ +

Custom Paragraph

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/external.html new file mode 100644 index 0000000000..d1017bf473 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/external.html @@ -0,0 +1 @@ +

Hello World

Hello World

Hello World

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/internal.html new file mode 100644 index 0000000000..7688d5c7c0 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/nested/internal.html @@ -0,0 +1 @@ +

Custom Paragraph

Nested Custom Paragraph 1

Nested Custom Paragraph 2

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/external.html new file mode 100644 index 0000000000..1930c65a95 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/external.html @@ -0,0 +1 @@ +

Hello World

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/internal.html new file mode 100644 index 0000000000..aec9bca191 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/customParagraph/styled/internal.html @@ -0,0 +1 @@ +

Plain Red Text Blue Background Mixed Colors

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html new file mode 100644 index 0000000000..bc3cb38f5c --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/external.html @@ -0,0 +1 @@ +

This is text with a custom fontSize

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html new file mode 100644 index 0000000000..717b1ad7d4 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/fontSize/basic/internal.html @@ -0,0 +1 @@ +

This is text with a custom fontSize

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/external.html new file mode 100644 index 0000000000..d9af93c752 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/external.html @@ -0,0 +1 @@ +

Text1
Text2

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/internal.html new file mode 100644 index 0000000000..a88858f652 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/basic/internal.html @@ -0,0 +1 @@ +

Text1
Text2

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/external.html new file mode 100644 index 0000000000..bb3c90b25c --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/external.html @@ -0,0 +1 @@ +

Link1
Link2

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/internal.html new file mode 100644 index 0000000000..f710f08741 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/between-links/internal.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/external.html new file mode 100644 index 0000000000..755d65be05 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/external.html @@ -0,0 +1 @@ +

Text1

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/internal.html new file mode 100644 index 0000000000..d441ef69af --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/end/internal.html @@ -0,0 +1 @@ +

Text1

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/external.html new file mode 100644 index 0000000000..70d35a5d8c --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/external.html @@ -0,0 +1 @@ +

Link1
Link1

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/internal.html new file mode 100644 index 0000000000..eb0b99808d --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/link/internal.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/external.html new file mode 100644 index 0000000000..db553727c0 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/external.html @@ -0,0 +1 @@ +

Text1
Text2
Text3

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/internal.html new file mode 100644 index 0000000000..5ae6ac8b30 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/multiple/internal.html @@ -0,0 +1 @@ +

Text1
Text2
Text3

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/external.html new file mode 100644 index 0000000000..82093bacd3 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/external.html @@ -0,0 +1 @@ +


\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/internal.html new file mode 100644 index 0000000000..c78443c0ac --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/only/internal.html @@ -0,0 +1 @@ +


\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/external.html new file mode 100644 index 0000000000..550b2b88d2 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/external.html @@ -0,0 +1 @@ +


Text1

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/internal.html new file mode 100644 index 0000000000..436596e499 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/start/internal.html @@ -0,0 +1 @@ +


Text1

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/external.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/external.html new file mode 100644 index 0000000000..193b4d61aa --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/external.html @@ -0,0 +1 @@ +

Text1
Text2

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/internal.html b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/internal.html new file mode 100644 index 0000000000..f08d9c579f --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/hardbreak/styles/internal.html @@ -0,0 +1 @@ +

Text1
Text2

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/basic/external.html new file mode 100644 index 0000000000..f214a9a441 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/image/basic/external.html @@ -0,0 +1 @@ +
Caption
\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html new file mode 100644 index 0000000000..080ccf3ce4 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/image/basic/internal.html @@ -0,0 +1 @@ +
placeholder

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/button/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/button/external.html new file mode 100644 index 0000000000..de77120ebf --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/image/button/external.html @@ -0,0 +1 @@ +

Add Image

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html new file mode 100644 index 0000000000..39de1869c4 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/image/button/internal.html @@ -0,0 +1 @@ +

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/image/nested/external.html new file mode 100644 index 0000000000..1a4a0986a2 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/image/nested/external.html @@ -0,0 +1 @@ +
Caption
Caption
\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html new file mode 100644 index 0000000000..5c81aa0f04 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/image/nested/internal.html @@ -0,0 +1 @@ +
placeholder

placeholder

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html new file mode 100644 index 0000000000..8876f46341 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/external.html @@ -0,0 +1 @@ +

WebsiteWebsite2

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/internal.html b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/internal.html new file mode 100644 index 0000000000..e11c631cac --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/link/adjacent/internal.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/link/basic/external.html new file mode 100644 index 0000000000..1b68f7c926 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/link/basic/external.html @@ -0,0 +1 @@ +

Website

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/link/basic/internal.html new file mode 100644 index 0000000000..5d7d50c2bc --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/link/basic/internal.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/link/styled/external.html new file mode 100644 index 0000000000..36a369a5e4 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/link/styled/external.html @@ -0,0 +1 @@ +

Website

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/link/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/link/styled/internal.html new file mode 100644 index 0000000000..84e54b7e4a --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/link/styled/internal.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html new file mode 100644 index 0000000000..ac270828c3 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/external.html @@ -0,0 +1 @@ +

I enjoy working with @Matthew

diff --git a/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html new file mode 100644 index 0000000000..fa3e3e8414 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/mention/basic/internal.html @@ -0,0 +1 @@ +

I enjoy working with @Matthew

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/external.html new file mode 100644 index 0000000000..76bbb30e4d --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/external.html @@ -0,0 +1 @@ +

Paragraph

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/internal.html new file mode 100644 index 0000000000..7a7fe019c2 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/basic/internal.html @@ -0,0 +1 @@ +

Paragraph

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html new file mode 100644 index 0000000000..c659260f6e --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/external.html @@ -0,0 +1 @@ +

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/internal.html new file mode 100644 index 0000000000..96547312cd --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/empty/internal.html @@ -0,0 +1 @@ +

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/external.html new file mode 100644 index 0000000000..9dc893acc1 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/external.html @@ -0,0 +1 @@ +

Paragraph

Nested Paragraph 1

Nested Paragraph 2

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/internal.html new file mode 100644 index 0000000000..79557fb3a3 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/nested/internal.html @@ -0,0 +1 @@ +

Paragraph

Nested Paragraph 1

Nested Paragraph 2

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/external.html new file mode 100644 index 0000000000..49d98e41d3 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/external.html @@ -0,0 +1 @@ +

Plain Red Text Blue Background Mixed Colors

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/internal.html new file mode 100644 index 0000000000..fa01c74894 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paragraph/styled/internal.html @@ -0,0 +1 @@ +

Plain Red Text Blue Background Mixed Colors

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-basic-block-types.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-basic-block-types.json new file mode 100644 index 0000000000..2d11e081f6 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-basic-block-types.json @@ -0,0 +1,140 @@ +[ + { + "id": "1", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Heading 1", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Heading 2", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 3 + }, + "content": [ + { + "type": "text", + "text": "Heading 3", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph", + "styles": {} + } + ], + "children": [] + }, + { + "id": "5", + "type": "image", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "url": "exampleURL", + "caption": "Image Caption", + "width": 512 + }, + "children": [] + }, + { + "id": "6", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "None ", + "styles": {} + }, + { + "type": "text", + "text": "Bold ", + "styles": { + "bold": true + } + }, + { + "type": "text", + "text": "Italic ", + "styles": { + "italic": true + } + }, + { + "type": "text", + "text": "Underline ", + "styles": { + "underline": true + } + }, + { + "type": "text", + "text": "Strikethrough ", + "styles": { + "strike": true + } + }, + { + "type": "text", + "text": "All", + "styles": { + "bold": true, + "italic": true, + "underline": true, + "strike": true + } + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-deep-nested-content.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-deep-nested-content.json new file mode 100644 index 0000000000..ae11e36cb7 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-deep-nested-content.json @@ -0,0 +1,240 @@ +[ + { + "id": "1", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Outer 1 Div Before", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " Outer 2 Div Before", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " Outer 3 Div Before", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " Outer 4 Div Before", + "styles": {} + } + ], + "children": [] + }, + { + "id": "5", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Heading 1", + "styles": {} + } + ], + "children": [] + }, + { + "id": "6", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Heading 2", + "styles": {} + } + ], + "children": [] + }, + { + "id": "7", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 3 + }, + "content": [ + { + "type": "text", + "text": "Heading 3", + "styles": {} + } + ], + "children": [] + }, + { + "id": "8", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph", + "styles": {} + } + ], + "children": [] + }, + { + "id": "9", + "type": "image", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "url": "exampleURL", + "caption": "Image Caption", + "width": 512 + }, + "children": [] + }, + { + "id": "10", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bold", + "styles": { + "bold": true + } + }, + { + "type": "text", + "text": " ", + "styles": {} + }, + { + "type": "text", + "text": "Italic", + "styles": { + "italic": true + } + }, + { + "type": "text", + "text": " ", + "styles": {} + }, + { + "type": "text", + "text": "Underline", + "styles": { + "underline": true + } + }, + { + "type": "text", + "text": " ", + "styles": {} + }, + { + "type": "text", + "text": "Strikethrough", + "styles": { + "strike": true + } + }, + { + "type": "text", + "text": " ", + "styles": {} + }, + { + "type": "text", + "text": "All", + "styles": { + "bold": true, + "italic": true, + "underline": true, + "strike": true + } + } + ], + "children": [] + }, + { + "id": "11", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": " Outer 4 Div After Outer 3 Div After Outer 2 Div After Outer 1 Div After", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-div-with-inline-content.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-div-with-inline-content.json new file mode 100644 index 0000000000..d06969a05f --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-div-with-inline-content.json @@ -0,0 +1,91 @@ +[ + { + "id": "1", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "None ", + "styles": {} + }, + { + "type": "text", + "text": "Bold ", + "styles": { + "bold": true + } + }, + { + "type": "text", + "text": "Italic ", + "styles": { + "italic": true + } + }, + { + "type": "text", + "text": "Underline ", + "styles": { + "underline": true + } + }, + { + "type": "text", + "text": "Strikethrough ", + "styles": { + "strike": true + } + }, + { + "type": "text", + "text": "All", + "styles": { + "bold": true, + "italic": true, + "underline": true, + "strike": true + } + } + ], + "children": [] + }, + { + "id": "2", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Div", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Paragraph", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-divs.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-divs.json new file mode 100644 index 0000000000..33f2f5010b --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-divs.json @@ -0,0 +1,19 @@ +[ + { + "id": "1", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Single Div", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-fake-image-caption.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-fake-image-caption.json new file mode 100644 index 0000000000..86a0cb8168 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-fake-image-caption.json @@ -0,0 +1,31 @@ +[ + { + "id": "1", + "type": "image", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "url": "exampleURL", + "caption": "", + "width": 512 + }, + "children": [] + }, + { + "id": "2", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Image Caption", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-mixed-nested-lists.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-mixed-nested-lists.json new file mode 100644 index 0000000000..1acc524e82 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-mixed-nested-lists.json @@ -0,0 +1,70 @@ +[ + { + "id": "1", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "numberedListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Numbered List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "numberedListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Numbered List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json new file mode 100644 index 0000000000..6c5bcf5056 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json @@ -0,0 +1,70 @@ +[ + { + "id": "1", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists.json b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists.json new file mode 100644 index 0000000000..6c5bcf5056 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/paste/parse-nested-lists.json @@ -0,0 +1,70 @@ +[ + { + "id": "1", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Nested Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/basic/external.html new file mode 100644 index 0000000000..92ba7801cb --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/basic/external.html @@ -0,0 +1 @@ +

Custom Paragraph

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/basic/internal.html new file mode 100644 index 0000000000..d8f1f1cf02 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/basic/internal.html @@ -0,0 +1 @@ +

Custom Paragraph

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/external.html new file mode 100644 index 0000000000..8ca20343ba --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/external.html @@ -0,0 +1 @@ +

Custom Paragraph

Nested Custom Paragraph 1

Nested Custom Paragraph 2

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/internal.html new file mode 100644 index 0000000000..8dfb4fdd8a --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/nested/internal.html @@ -0,0 +1 @@ +

Custom Paragraph

Nested Custom Paragraph 1

Nested Custom Paragraph 2

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/external.html new file mode 100644 index 0000000000..684688bb9d --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/external.html @@ -0,0 +1 @@ +

Plain Red Text Blue Background Mixed Colors

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/internal.html new file mode 100644 index 0000000000..c0922dcb84 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleCustomParagraph/styled/internal.html @@ -0,0 +1 @@ +

Plain Red Text Blue Background Mixed Colors

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/external.html new file mode 100644 index 0000000000..b9aa7c2551 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/external.html @@ -0,0 +1 @@ +
placeholder

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/internal.html new file mode 100644 index 0000000000..305da277ef --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/basic/internal.html @@ -0,0 +1 @@ +
placeholder

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/external.html new file mode 100644 index 0000000000..68a66d027a --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/external.html @@ -0,0 +1 @@ +

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/internal.html new file mode 100644 index 0000000000..07a332a5f4 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/button/internal.html @@ -0,0 +1 @@ +

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/external.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/external.html new file mode 100644 index 0000000000..c1cd943c29 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/external.html @@ -0,0 +1 @@ +
placeholder

placeholder

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/internal.html b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/internal.html new file mode 100644 index 0000000000..114116544a --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/simpleImage/nested/internal.html @@ -0,0 +1 @@ +
placeholder

placeholder

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html new file mode 100644 index 0000000000..50ef98b2ce --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/small/basic/external.html @@ -0,0 +1 @@ +

This is a small text

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html new file mode 100644 index 0000000000..2c4b0446df --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/small/basic/internal.html @@ -0,0 +1 @@ +

This is a small text

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html new file mode 100644 index 0000000000..c243b63eb2 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/external.html @@ -0,0 +1 @@ +

I love #BlockNote

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html new file mode 100644 index 0000000000..dcb80c2f33 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/tag/basic/internal.html @@ -0,0 +1 @@ +

I love #BlockNote

\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/externalHTMLExporter.ts b/packages/core/src/api/exporters/html/externalHTMLExporter.ts new file mode 100644 index 0000000000..43b591b610 --- /dev/null +++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts @@ -0,0 +1,98 @@ +import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model"; +import rehypeParse from "rehype-parse"; +import rehypeStringify from "rehype-stringify"; +import { unified } from "unified"; + +import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; +import { + BlockSchema, + InlineContentSchema, + PartialBlock, + StyleSchema, +} from "../../../schema"; +import { blockToNode } from "../../nodeConversions/nodeConversions"; +import { + serializeNodeInner, + serializeProseMirrorFragment, +} from "./util/sharedHTMLConversion"; +import { simplifyBlocks } from "./util/simplifyBlocksRehypePlugin"; + +// Used to export BlockNote blocks and ProseMirror nodes to HTML for use outside +// the editor. Blocks are exported using the `toExternalHTML` method in their +// `blockSpec`, or `toInternalHTML` if `toExternalHTML` is not defined. +// +// The HTML created by this serializer is different to what's rendered by the +// editor to the DOM. This also means that data is likely to be lost when +// converting back to original blocks. The differences in the output HTML are: +// 1. It doesn't include the `blockGroup` and `blockContainer` wrappers meaning +// that nesting is not preserved for non-list-item blocks. +// 2. `li` items in the output HTML are wrapped in `ul` or `ol` elements. +// 3. While nesting for list items is preserved, other types of blocks nested +// inside a list are un-nested and a new list is created after them. +// 4. The HTML is wrapped in a single `div` element. +// +// The serializer has 2 main methods: +// `exportBlocks`: Exports an array of blocks to HTML. +// `exportFragment`: Exports a ProseMirror fragment to HTML. This is mostly +// useful if you want to export a selection which may not start/end at the +// start/end of a block. +export interface ExternalHTMLExporter< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> { + exportBlocks: (blocks: PartialBlock[]) => string; + exportProseMirrorFragment: (fragment: Fragment) => string; +} + +export const createExternalHTMLExporter = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + schema: Schema, + editor: BlockNoteEditor +): ExternalHTMLExporter => { + const serializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { + serializeNodeInner: ( + node: Node, + options: { document?: Document } + ) => HTMLElement; + // TODO: Should not be async, but is since we're using a rehype plugin to + // convert internal HTML to external HTML. + exportProseMirrorFragment: (fragment: Fragment) => string; + exportBlocks: (blocks: PartialBlock[]) => string; + }; + + serializer.serializeNodeInner = ( + node: Node, + options: { document?: Document } + ) => serializeNodeInner(node, options, serializer, editor, true); + + // Like the `internalHTMLSerializer`, also uses `serializeProseMirrorFragment` + // but additionally runs it through the `simplifyBlocks` rehype plugin to + // convert the internal HTML to external. + serializer.exportProseMirrorFragment = (fragment) => { + const externalHTML = unified() + .use(rehypeParse, { fragment: true }) + .use(simplifyBlocks, { + orderedListItemBlockTypes: new Set(["numberedListItem"]), + unorderedListItemBlockTypes: new Set(["bulletListItem"]), + }) + .use(rehypeStringify) + .processSync(serializeProseMirrorFragment(fragment, serializer)); + + return externalHTML.value as string; + }; + + serializer.exportBlocks = (blocks: PartialBlock[]) => { + const nodes = blocks.map((block) => + blockToNode(block, schema, editor.styleSchema) + ); + const blockGroup = schema.nodes["blockGroup"].create(null, nodes); + + return serializer.exportProseMirrorFragment(Fragment.from(blockGroup)); + }; + + return serializer; +}; diff --git a/packages/core/src/api/exporters/html/htmlConversion.test.ts b/packages/core/src/api/exporters/html/htmlConversion.test.ts new file mode 100644 index 0000000000..6671037f19 --- /dev/null +++ b/packages/core/src/api/exporters/html/htmlConversion.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; + +import { addIdsToBlocks, partialBlocksToBlocksForTesting } from "../../.."; +import { BlockSchema, PartialBlock } from "../../../schema/blocks/types"; +import { InlineContentSchema } from "../../../schema/inlineContent/types"; +import { StyleSchema } from "../../../schema/styles/types"; +import { customBlocksTestCases } from "../../testUtil/cases/customBlocks"; +import { customInlineContentTestCases } from "../../testUtil/cases/customInlineContent"; +import { customStylesTestCases } from "../../testUtil/cases/customStyles"; +import { defaultSchemaTestCases } from "../../testUtil/cases/defaultSchema"; +import { createExternalHTMLExporter } from "./externalHTMLExporter"; +import { createInternalHTMLSerializer } from "./internalHTMLSerializer"; + +async function convertToHTMLAndCompareSnapshots< + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor, + blocks: PartialBlock[], + snapshotDirectory: string, + snapshotName: string +) { + addIdsToBlocks(blocks); + const serializer = createInternalHTMLSerializer( + editor._tiptapEditor.schema, + editor + ); + const internalHTML = serializer.serializeBlocks(blocks); + const internalHTMLSnapshotPath = + "./__snapshots__/" + + snapshotDirectory + + "/" + + snapshotName + + "/internal.html"; + expect(internalHTML).toMatchFileSnapshot(internalHTMLSnapshotPath); + + // turn the internalHTML back into blocks, and make sure no data was lost + const fullBlocks = partialBlocksToBlocksForTesting( + editor.blockSchema, + blocks + ); + const parsed = await editor.tryParseHTMLToBlocks(internalHTML); + + expect(parsed).toStrictEqual(fullBlocks); + + // Create the "external" HTML, which is a cleaned up HTML representation, but lossy + const exporter = createExternalHTMLExporter( + editor._tiptapEditor.schema, + editor + ); + const externalHTML = exporter.exportBlocks(blocks); + const externalHTMLSnapshotPath = + "./__snapshots__/" + + snapshotDirectory + + "/" + + snapshotName + + "/external.html"; + expect(externalHTML).toMatchFileSnapshot(externalHTMLSnapshotPath); +} + +const testCases = [ + defaultSchemaTestCases, + customBlocksTestCases, + customStylesTestCases, + customInlineContentTestCases, +]; + +describe("Test HTML conversion", () => { + for (const testCase of testCases) { + describe("Case: " + testCase.name, () => { + let editor: BlockNoteEditor; + + beforeEach(() => { + editor = testCase.createEditor(); + }); + + afterEach(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; + }); + + for (const document of testCase.documents) { + // eslint-disable-next-line no-loop-func + it("Convert " + document.name + " to HTML", async () => { + const nameSplit = document.name.split("/"); + await convertToHTMLAndCompareSnapshots( + editor, + document.blocks, + nameSplit[0], + nameSplit[1] + ); + }); + } + }); + } +}); diff --git a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts new file mode 100644 index 0000000000..a635819caa --- /dev/null +++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts @@ -0,0 +1,80 @@ +import { DOMSerializer, Fragment, Node, Schema } from "prosemirror-model"; +import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; +import { + BlockSchema, + InlineContentSchema, + PartialBlock, + StyleSchema, +} from "../../../schema"; +import { blockToNode } from "../../nodeConversions/nodeConversions"; +import { + serializeNodeInner, + serializeProseMirrorFragment, +} from "./util/sharedHTMLConversion"; + +// Used to serialize BlockNote blocks and ProseMirror nodes to HTML without +// losing data. Blocks are exported using the `toInternalHTML` method in their +// `blockSpec`. +// +// The HTML created by this serializer is the same as what's rendered by the +// editor to the DOM. This means that it retains the same structure as the +// editor, including the `blockGroup` and `blockContainer` wrappers. This also +// means that it can be converted back to the original blocks without any data +// loss. +// +// The serializer has 2 main methods: +// `serializeFragment`: Serializes a ProseMirror fragment to HTML. This is +// mostly useful if you want to serialize a selection which may not start/end at +// the start/end of a block. +// `serializeBlocks`: Serializes an array of blocks to HTML. +export interface InternalHTMLSerializer< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> { + // TODO: Ideally we would expand the BlockNote API to support partial + // selections so we don't need this. + serializeProseMirrorFragment: (fragment: Fragment) => string; + serializeBlocks: (blocks: PartialBlock[]) => string; +} + +export const createInternalHTMLSerializer = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + schema: Schema, + editor: BlockNoteEditor +): InternalHTMLSerializer => { + const serializer = DOMSerializer.fromSchema(schema) as DOMSerializer & { + serializeNodeInner: ( + node: Node, + options: { document?: Document } + ) => HTMLElement; + serializeBlocks: (blocks: PartialBlock[]) => string; + serializeProseMirrorFragment: ( + fragment: Fragment, + options?: { document?: Document | undefined } | undefined, + target?: HTMLElement | DocumentFragment | undefined + ) => string; + }; + + serializer.serializeNodeInner = ( + node: Node, + options: { document?: Document } + ) => serializeNodeInner(node, options, serializer, editor, false); + + serializer.serializeProseMirrorFragment = (fragment: Fragment) => + serializeProseMirrorFragment(fragment, serializer); + + serializer.serializeBlocks = (blocks: PartialBlock[]) => { + const nodes = blocks.map((block) => + blockToNode(block, schema, editor.styleSchema) + ); + const blockGroup = schema.nodes["blockGroup"].create(null, nodes); + + return serializer.serializeProseMirrorFragment(Fragment.from(blockGroup)); + }; + + return serializer; +}; diff --git a/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts new file mode 100644 index 0000000000..4c89a45ebe --- /dev/null +++ b/packages/core/src/api/exporters/html/util/sharedHTMLConversion.ts @@ -0,0 +1,128 @@ +import { DOMSerializer, Fragment, Node } from "prosemirror-model"; + +import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor"; +import { + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../../../schema"; +import { nodeToBlock } from "../../../nodeConversions/nodeConversions"; + +function doc(options: { document?: Document }) { + return options.document || window.document; +} + +// Used to implement `serializeNodeInner` for the `internalHTMLSerializer` and +// `externalHTMLExporter`. Changes how the content of `blockContainer` nodes is +// serialized vs the default `DOMSerializer` implementation. For the +// `blockContent` node, the `toInternalHTML` or `toExternalHTML` function of its +// corresponding block is used for serialization instead of the node's +// `renderHTML` method. +export const serializeNodeInner = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + node: Node, + options: { document?: Document }, + serializer: DOMSerializer, + editor: BlockNoteEditor, + toExternalHTML: boolean +) => { + if (!serializer.nodes[node.type.name]) { + throw new Error("Serializer is missing a node type: " + node.type.name); + } + const { dom, contentDOM } = DOMSerializer.renderSpec( + doc(options), + serializer.nodes[node.type.name](node) + ); + + if (contentDOM) { + if (node.isLeaf) { + throw new RangeError("Content hole not allowed in a leaf node spec"); + } + + // Handles converting `blockContainer` nodes to HTML. + if (node.type.name === "blockContainer") { + const blockContentNode = + node.childCount > 0 && + node.firstChild!.type.spec.group === "blockContent" + ? node.firstChild! + : undefined; + const blockGroupNode = + node.childCount > 0 && node.lastChild!.type.spec.group === "blockGroup" + ? node.lastChild! + : undefined; + + // Converts `blockContent` node using the custom `blockSpec`'s + // `toExternalHTML` or `toInternalHTML` function. + // Note: While `blockContainer` nodes should always contain a + // `blockContent` node according to the schema, PM Fragments don't always + // conform to the schema. This is unintuitive but important as it occurs + // when copying only nested blocks. + if (blockContentNode !== undefined) { + const impl = + editor.blockImplementations[blockContentNode.type.name] + .implementation; + const toHTML = toExternalHTML + ? impl.toExternalHTML + : impl.toInternalHTML; + const blockContent = toHTML( + nodeToBlock( + node, + editor.blockSchema, + editor.inlineContentSchema, + editor.styleSchema, + editor.blockCache + ), + editor as any + ); + + // Converts inline nodes in the `blockContent` node's content to HTML + // using their `renderHTML` methods. + if (blockContent.contentDOM !== undefined) { + if (node.isLeaf) { + throw new RangeError( + "Content hole not allowed in a leaf node spec" + ); + } + + blockContent.contentDOM.appendChild( + serializer.serializeFragment(blockContentNode.content, options) + ); + } + + contentDOM.appendChild(blockContent.dom); + } + + // Converts `blockGroup` node to HTML using its `renderHTML` method. + if (blockGroupNode !== undefined) { + serializer.serializeFragment( + Fragment.from(blockGroupNode), + options, + contentDOM + ); + } + } else { + // Converts the node normally, i.e. using its `renderHTML method`. + serializer.serializeFragment(node.content, options, contentDOM); + } + } + + return dom as HTMLElement; +}; + +// Used to implement `serializeProseMirrorFragment` for the +// `internalHTMLSerializer` and `externalHTMLExporter`. Does basically the same +// thing as `serializer.serializeFragment`, but takes fewer arguments and +// returns a string instead, to make it easier to use. +export const serializeProseMirrorFragment = ( + fragment: Fragment, + serializer: DOMSerializer +) => { + const internalHTML = serializer.serializeFragment(fragment); + const parent = document.createElement("div"); + parent.appendChild(internalHTML); + + return parent.innerHTML; +}; diff --git a/packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts b/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts similarity index 91% rename from packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts rename to packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts index 13fa69e783..ba025dc5b0 100644 --- a/packages/core/src/api/formatConversions/simplifyBlocksRehypePlugin.ts +++ b/packages/core/src/api/exporters/html/util/simplifyBlocksRehypePlugin.ts @@ -22,6 +22,19 @@ export function simplifyBlocks(options: SimplifyBlocksOptions) { ]); const simplifyBlocksHelper = (tree: HASTParent) => { + // Checks whether blocks in the tree are wrapped by a parent `blockGroup` + // element, in which case the `blockGroup`'s children are lifted out, and it + // is removed. + if ( + tree.children.length === 1 && + (tree.children[0] as HASTElement).properties?.["dataNodeType"] === + "blockGroup" + ) { + const blockGroup = tree.children[0] as HASTElement; + tree.children.pop(); + tree.children.push(...blockGroup.children); + } + let numChildElements = tree.children.length; let activeList: HASTElement | undefined; diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/complex/misc/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/complex/misc/markdown.md new file mode 100644 index 0000000000..c0e8ed3d9b --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/complex/misc/markdown.md @@ -0,0 +1,5 @@ +## **Heading ***~~2~~* + +Paragraph + +* diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/basic/markdown.md new file mode 100644 index 0000000000..557db03de9 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/basic/markdown.md @@ -0,0 +1 @@ +Hello World diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/nested/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/nested/markdown.md new file mode 100644 index 0000000000..f4f110c5fb --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/nested/markdown.md @@ -0,0 +1,5 @@ +Hello World + +Hello World + +Hello World diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/styled/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/styled/markdown.md new file mode 100644 index 0000000000..557db03de9 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/customParagraph/styled/markdown.md @@ -0,0 +1 @@ +Hello World diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/fontSize/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/fontSize/basic/markdown.md new file mode 100644 index 0000000000..a14913bf9b --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/fontSize/basic/markdown.md @@ -0,0 +1 @@ +This is text with a custom fontSize diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/basic/markdown.md new file mode 100644 index 0000000000..0fe906b288 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/basic/markdown.md @@ -0,0 +1,2 @@ +Text1\ +Text2 diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/between-links/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/between-links/markdown.md new file mode 100644 index 0000000000..3f74feb726 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/between-links/markdown.md @@ -0,0 +1,2 @@ +[Link1](https://www.website.com)\ +[Link2](https://www.website2.com) diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/end/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/end/markdown.md new file mode 100644 index 0000000000..9d80a6ba66 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/end/markdown.md @@ -0,0 +1 @@ +Text1 diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/link/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/link/markdown.md new file mode 100644 index 0000000000..95a590abea --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/link/markdown.md @@ -0,0 +1,2 @@ +[Link1](https://www.website.com)\ +[Link1](https://www.website.com) diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/multiple/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/multiple/markdown.md new file mode 100644 index 0000000000..f7e9c54f1f --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/multiple/markdown.md @@ -0,0 +1,3 @@ +Text1\ +Text2\ +Text3 diff --git a/packages/core/src/shared/EditorElement.ts b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/only/markdown.md similarity index 100% rename from packages/core/src/shared/EditorElement.ts rename to packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/only/markdown.md diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/start/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/start/markdown.md new file mode 100644 index 0000000000..9d80a6ba66 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/start/markdown.md @@ -0,0 +1 @@ +Text1 diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/styles/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/styles/markdown.md new file mode 100644 index 0000000000..f92fc1d40e --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/hardbreak/styles/markdown.md @@ -0,0 +1,2 @@ +Text1\ +**Text2** diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/image/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/image/basic/markdown.md new file mode 100644 index 0000000000..dda13c76fa --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/image/basic/markdown.md @@ -0,0 +1,3 @@ +![](exampleURL) + +Caption diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/image/button/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/image/button/markdown.md new file mode 100644 index 0000000000..4f8610b831 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/image/button/markdown.md @@ -0,0 +1 @@ +Add Image diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/image/nested/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/image/nested/markdown.md new file mode 100644 index 0000000000..d2d1ce4de4 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/image/nested/markdown.md @@ -0,0 +1,7 @@ +![](exampleURL) + +Caption + +![](exampleURL) + +Caption diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/link/adjacent/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/link/adjacent/markdown.md new file mode 100644 index 0000000000..4fe44186fa --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/link/adjacent/markdown.md @@ -0,0 +1 @@ +[Website](https://www.website.com)[Website2](https://www.website2.com) diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/link/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/link/basic/markdown.md new file mode 100644 index 0000000000..bc9d83b3da --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/link/basic/markdown.md @@ -0,0 +1 @@ +[Website](https://www.website.com) diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/link/styled/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/link/styled/markdown.md new file mode 100644 index 0000000000..ad7b143e27 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/link/styled/markdown.md @@ -0,0 +1 @@ +**[Web](https://www.website.com)**[site](https://www.website.com) diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/mention/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/mention/basic/markdown.md new file mode 100644 index 0000000000..b6a2ae25b3 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/mention/basic/markdown.md @@ -0,0 +1 @@ +I enjoy working with @Matthew diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/basic/markdown.md new file mode 100644 index 0000000000..07e18e6d30 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/basic/markdown.md @@ -0,0 +1 @@ +Paragraph diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/empty/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/empty/markdown.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/nested/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/nested/markdown.md new file mode 100644 index 0000000000..af7d1348ad --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/nested/markdown.md @@ -0,0 +1,5 @@ +Paragraph + +Nested Paragraph 1 + +Nested Paragraph 2 diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/styled/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/styled/markdown.md new file mode 100644 index 0000000000..4f45e63c5c --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/paragraph/styled/markdown.md @@ -0,0 +1 @@ +Plain Red Text Blue Background Mixed Colors diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/basic/markdown.md new file mode 100644 index 0000000000..fd50b044f8 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/basic/markdown.md @@ -0,0 +1 @@ +Custom Paragraph diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/nested/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/nested/markdown.md new file mode 100644 index 0000000000..147effc747 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/nested/markdown.md @@ -0,0 +1,5 @@ +Custom Paragraph + +Nested Custom Paragraph 1 + +Nested Custom Paragraph 2 diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/styled/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/styled/markdown.md new file mode 100644 index 0000000000..4f45e63c5c --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/simpleCustomParagraph/styled/markdown.md @@ -0,0 +1 @@ +Plain Red Text Blue Background Mixed Colors diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/basic/markdown.md new file mode 100644 index 0000000000..e90136ab90 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/basic/markdown.md @@ -0,0 +1 @@ +![placeholder](exampleURL) diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/button/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/button/markdown.md new file mode 100644 index 0000000000..d642ea87c6 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/button/markdown.md @@ -0,0 +1 @@ +![placeholder]() diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/nested/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/nested/markdown.md new file mode 100644 index 0000000000..7d84311ed4 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/simpleImage/nested/markdown.md @@ -0,0 +1,3 @@ +![placeholder](exampleURL) + +![placeholder](exampleURL) diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/small/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/small/basic/markdown.md new file mode 100644 index 0000000000..02738ab95b --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/small/basic/markdown.md @@ -0,0 +1 @@ +This is a small text diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/tag/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/tag/basic/markdown.md new file mode 100644 index 0000000000..8adc77839a --- /dev/null +++ b/packages/core/src/api/exporters/markdown/__snapshots__/tag/basic/markdown.md @@ -0,0 +1 @@ +I love #BlockNote diff --git a/packages/core/src/api/exporters/markdown/markdownExporter.test.ts b/packages/core/src/api/exporters/markdown/markdownExporter.test.ts new file mode 100644 index 0000000000..a4f391bc11 --- /dev/null +++ b/packages/core/src/api/exporters/markdown/markdownExporter.test.ts @@ -0,0 +1,85 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; +import { BlockSchema, PartialBlock } from "../../../schema/blocks/types"; +import { InlineContentSchema } from "../../../schema/inlineContent/types"; +import { StyleSchema } from "../../../schema/styles/types"; +import { partialBlocksToBlocksForTesting } from "../../testUtil/partialBlockTestUtil"; +import { customBlocksTestCases } from "../../testUtil/cases/customBlocks"; +import { customInlineContentTestCases } from "../../testUtil/cases/customInlineContent"; +import { customStylesTestCases } from "../../testUtil/cases/customStyles"; +import { defaultSchemaTestCases } from "../../testUtil/cases/defaultSchema"; + +async function convertToMarkdownAndCompareSnapshots< + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor, + blocks: PartialBlock[], + snapshotDirectory: string, + snapshotName: string +) { + const fullBlocks = partialBlocksToBlocksForTesting( + editor.blockSchema, + blocks + ); + const md = await editor.blocksToMarkdownLossy(fullBlocks); + const snapshotPath = + "./__snapshots__/" + + snapshotDirectory + + "/" + + snapshotName + + "/markdown.md"; + + // vitest empty snapshots are broken on CI. might be fixed on next vitest, use workaround for now + if (!md.length && process.env.CI) { + if ( + fs.readFileSync(path.join(__dirname, snapshotPath), "utf8").length === 0 + ) { + // both are empty, so it's fine + return; + } + } + expect(md).toMatchFileSnapshot(snapshotPath); +} + +const testCases = [ + defaultSchemaTestCases, + customBlocksTestCases, + customStylesTestCases, + customInlineContentTestCases, +]; + +describe("markdownExporter", () => { + for (const testCase of testCases) { + describe("Case: " + testCase.name, () => { + let editor: BlockNoteEditor; + + beforeEach(() => { + editor = testCase.createEditor(); + }); + + afterEach(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; + }); + + for (const document of testCase.documents) { + // eslint-disable-next-line no-loop-func + it("Convert " + document.name + " to HTML", async () => { + const nameSplit = document.name.split("/"); + await convertToMarkdownAndCompareSnapshots( + editor, + document.blocks, + nameSplit[0], + nameSplit[1] + ); + }); + } + }); + } +}); diff --git a/packages/core/src/api/exporters/markdown/markdownExporter.ts b/packages/core/src/api/exporters/markdown/markdownExporter.ts new file mode 100644 index 0000000000..841ff6381e --- /dev/null +++ b/packages/core/src/api/exporters/markdown/markdownExporter.ts @@ -0,0 +1,42 @@ +import { Schema } from "prosemirror-model"; +import rehypeParse from "rehype-parse"; +import rehypeRemark from "rehype-remark"; +import remarkGfm from "remark-gfm"; +import remarkStringify from "remark-stringify"; +import { unified } from "unified"; +import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor"; +import { + Block, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../../schema"; +import { createExternalHTMLExporter } from "../html/externalHTMLExporter"; +import { removeUnderlines } from "./removeUnderlinesRehypePlugin"; + +export function cleanHTMLToMarkdown(cleanHTMLString: string) { + const markdownString = unified() + .use(rehypeParse, { fragment: true }) + .use(removeUnderlines) + .use(rehypeRemark) + .use(remarkGfm) + .use(remarkStringify) + .processSync(cleanHTMLString); + + return markdownString.value as string; +} + +export function blocksToMarkdown< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + blocks: Block[], + schema: Schema, + editor: BlockNoteEditor +): string { + const exporter = createExternalHTMLExporter(schema, editor); + const externalHTML = exporter.exportBlocks(blocks); + + return cleanHTMLToMarkdown(externalHTML); +} diff --git a/packages/core/src/api/formatConversions/removeUnderlinesRehypePlugin.ts b/packages/core/src/api/exporters/markdown/removeUnderlinesRehypePlugin.ts similarity index 100% rename from packages/core/src/api/formatConversions/removeUnderlinesRehypePlugin.ts rename to packages/core/src/api/exporters/markdown/removeUnderlinesRehypePlugin.ts diff --git a/packages/core/src/api/formatConversions/__snapshots__/formatConversions.test.ts.snap b/packages/core/src/api/formatConversions/__snapshots__/formatConversions.test.ts.snap deleted file mode 100644 index a3342cc7df..0000000000 --- a/packages/core/src/api/formatConversions/__snapshots__/formatConversions.test.ts.snap +++ /dev/null @@ -1,346 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Complex Block/HTML/Markdown Conversions > Convert complex blocks to HTML 1`] = `"

Heading 1

Heading 2

Heading 3

Paragraph

Paragraph

Paragraph

  • Bullet List Item

  • Bullet List Item

    • Bullet List Item

      • Bullet List Item

      Paragraph

      1. Numbered List Item

      2. Numbered List Item

      3. Numbered List Item

        1. Numbered List Item

      • Bullet List Item

    • Bullet List Item

  • Bullet List Item

"`; - -exports[`Complex Block/HTML/Markdown Conversions > Convert complex blocks to Markdown 1`] = ` -"# Heading 1 - -## Heading 2 - -### Heading 3 - -Paragraph - -P**ara***grap*h - -Para~~grap~~h - -* Bullet List Item - -* Bullet List Item - - * Bullet List Item - - * Bullet List Item - - Paragraph - - 1. Numbered List Item - - 2. Numbered List Item - - 3. Numbered List Item - - 1. Numbered List Item - - * Bullet List Item - - * Bullet List Item - -* Bullet List Item -" -`; - -exports[`Nested Block/HTML/Markdown Conversions > Convert nested blocks to HTML 1`] = `"

Heading

Paragraph

  • Bullet List Item

    1. Numbered List Item

"`; - -exports[`Nested Block/HTML/Markdown Conversions > Convert nested blocks to Markdown 1`] = ` -"# Heading - -Paragraph - -* Bullet List Item - - 1. Numbered List Item -" -`; - -exports[`Non-Nested Block/HTML/Markdown Conversions > Convert non-nested HTML to blocks 1`] = ` -[ - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Heading", - "type": "text", - }, - ], - "id": "1", - "props": { - "backgroundColor": "default", - "level": 1, - "textAlignment": "left", - "textColor": "default", - }, - "type": "heading", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Paragraph", - "type": "text", - }, - ], - "id": "2", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Bullet List Item", - "type": "text", - }, - ], - "id": "3", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "bulletListItem", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Numbered List Item", - "type": "text", - }, - ], - "id": "4", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "numberedListItem", - }, -] -`; - -exports[`Non-Nested Block/HTML/Markdown Conversions > Convert non-nested Markdown to blocks 1`] = ` -[ - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Heading", - "type": "text", - }, - ], - "id": "1", - "props": { - "backgroundColor": "default", - "level": 1, - "textAlignment": "left", - "textColor": "default", - }, - "type": "heading", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Paragraph", - "type": "text", - }, - ], - "id": "2", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Bullet List Item", - "type": "text", - }, - ], - "id": "3", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "bulletListItem", - }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Numbered List Item", - "type": "text", - }, - ], - "id": "4", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "numberedListItem", - }, -] -`; - -exports[`Non-Nested Block/HTML/Markdown Conversions > Convert non-nested blocks to HTML 1`] = `"

Heading

Paragraph

  • Bullet List Item

  1. Numbered List Item

"`; - -exports[`Non-Nested Block/HTML/Markdown Conversions > Convert non-nested blocks to Markdown 1`] = ` -"# Heading - -Paragraph - -* Bullet List Item - -1. Numbered List Item -" -`; - -exports[`Styled Block/HTML/Markdown Conversions > Convert styled HTML to blocks 1`] = ` -[ - { - "children": [], - "content": [ - { - "styles": { - "bold": true, - }, - "text": "Bold", - "type": "text", - }, - { - "styles": { - "italic": true, - }, - "text": "Italic", - "type": "text", - }, - { - "styles": { - "underline": true, - }, - "text": "Underline", - "type": "text", - }, - { - "styles": { - "strike": true, - }, - "text": "Strikethrough", - "type": "text", - }, - { - "styles": { - "textColor": "red", - }, - "text": "TextColor", - "type": "text", - }, - { - "styles": { - "backgroundColor": "red", - }, - "text": "BackgroundColor", - "type": "text", - }, - { - "styles": { - "bold": true, - "italic": true, - }, - "text": "Multiple", - "type": "text", - }, - ], - "id": "1", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, -] -`; - -exports[`Styled Block/HTML/Markdown Conversions > Convert styled Markdown to blocks 1`] = ` -[ - { - "children": [], - "content": [ - { - "styles": { - "bold": true, - }, - "text": "Bold", - "type": "text", - }, - { - "styles": { - "italic": true, - }, - "text": "Italic", - "type": "text", - }, - { - "styles": {}, - "text": "Underline", - "type": "text", - }, - { - "styles": { - "strike": true, - }, - "text": "Strikethrough", - "type": "text", - }, - { - "styles": {}, - "text": "TextColorBackgroundColor", - "type": "text", - }, - { - "styles": { - "bold": true, - "italic": true, - }, - "text": "Multiple", - "type": "text", - }, - ], - "id": "1", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, -] -`; - -exports[`Styled Block/HTML/Markdown Conversions > Convert styled blocks to HTML 1`] = `"

BoldItalicUnderlineStrikethroughTextColorBackgroundColorMultiple

"`; - -exports[`Styled Block/HTML/Markdown Conversions > Convert styled blocks to Markdown 1`] = ` -"**Bold***Italic*Underline~~Strikethrough~~TextColorBackgroundColor***Multiple*** -" -`; diff --git a/packages/core/src/api/formatConversions/formatConversions.test.ts b/packages/core/src/api/formatConversions/formatConversions.test.ts deleted file mode 100644 index bcf6714acf..0000000000 --- a/packages/core/src/api/formatConversions/formatConversions.test.ts +++ /dev/null @@ -1,753 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { Block, BlockNoteEditor } from "../.."; -import UniqueID from "../../extensions/UniqueID/UniqueID"; - -let editor: BlockNoteEditor; - -const getNonNestedBlocks = (): Block[] => [ - { - id: UniqueID.options.generateID(), - type: "heading", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - level: 1, - }, - content: [ - { - type: "text", - text: "Heading", - styles: {}, - }, - ], - children: [], - }, - { - id: UniqueID.options.generateID(), - type: "paragraph", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Paragraph", - styles: {}, - }, - ], - children: [], - }, - { - id: UniqueID.options.generateID(), - type: "bulletListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Bullet List Item", - styles: {}, - }, - ], - children: [], - }, - { - id: UniqueID.options.generateID(), - type: "numberedListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Numbered List Item", - styles: {}, - }, - ], - children: [], - }, -]; - -const getNestedBlocks = (): Block[] => [ - { - id: UniqueID.options.generateID(), - type: "heading", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - level: 1, - }, - content: [ - { - type: "text", - text: "Heading", - styles: {}, - }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: "paragraph", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Paragraph", - styles: {}, - }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: "bulletListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Bullet List Item", - styles: {}, - }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: "numberedListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Numbered List Item", - styles: {}, - }, - ], - children: [], - }, - ], - }, - ], - }, - ], - }, -]; - -const getStyledBlocks = (): Block[] => [ - { - id: UniqueID.options.generateID(), - type: "paragraph", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Bold", - styles: { - bold: true, - }, - }, - { - type: "text", - text: "Italic", - styles: { - italic: true, - }, - }, - { - type: "text", - text: "Underline", - styles: { - underline: true, - }, - }, - { - type: "text", - text: "Strikethrough", - styles: { - strike: true, - }, - }, - { - type: "text", - text: "TextColor", - styles: { - textColor: "red", - }, - }, - { - type: "text", - text: "BackgroundColor", - styles: { - backgroundColor: "red", - }, - }, - { - type: "text", - text: "Multiple", - styles: { - bold: true, - italic: true, - }, - }, - ], - children: [], - }, -]; - -const getComplexBlocks = (): Block[] => [ - { - id: UniqueID.options.generateID(), - type: "heading", - props: { - backgroundColor: "red", - textColor: "yellow", - textAlignment: "right", - level: 1, - }, - content: [ - { - type: "text", - text: "Heading 1", - styles: {}, - }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: "heading", - props: { - backgroundColor: "orange", - textColor: "orange", - textAlignment: "center", - level: 2, - }, - content: [ - { - type: "text", - text: "Heading 2", - styles: {}, - }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: "heading", - props: { - backgroundColor: "yellow", - textColor: "red", - textAlignment: "left", - level: 3, - }, - content: [ - { - type: "text", - text: "Heading 3", - styles: {}, - }, - ], - children: [], - }, - ], - }, - ], - }, - { - id: UniqueID.options.generateID(), - type: "paragraph", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Paragraph", - styles: { - textColor: "purple", - backgroundColor: "green", - }, - }, - ], - children: [], - }, - { - id: UniqueID.options.generateID(), - type: "paragraph", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "P", - styles: {}, - }, - { - type: "text", - text: "ara", - styles: { - bold: true, - }, - }, - { - type: "text", - text: "grap", - styles: { - italic: true, - }, - }, - { - type: "text", - text: "h", - styles: {}, - }, - ], - children: [], - }, - { - id: UniqueID.options.generateID(), - type: "paragraph", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "P", - styles: {}, - }, - { - type: "text", - text: "ara", - styles: { - underline: true, - }, - }, - { - type: "text", - text: "grap", - styles: { - strike: true, - }, - }, - { - type: "text", - text: "h", - styles: {}, - }, - ], - children: [], - }, - { - id: UniqueID.options.generateID(), - type: "bulletListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Bullet List Item", - styles: {}, - }, - ], - children: [], - }, - { - id: UniqueID.options.generateID(), - type: "bulletListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Bullet List Item", - styles: {}, - }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: "bulletListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Bullet List Item", - styles: {}, - }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: "bulletListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Bullet List Item", - styles: {}, - }, - ], - children: [], - }, - { - id: UniqueID.options.generateID(), - type: "paragraph", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Paragraph", - styles: {}, - }, - ], - children: [], - }, - { - id: UniqueID.options.generateID(), - type: "numberedListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Numbered List Item", - styles: {}, - }, - ], - children: [], - }, - { - id: UniqueID.options.generateID(), - type: "numberedListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Numbered List Item", - styles: {}, - }, - ], - children: [], - }, - { - id: UniqueID.options.generateID(), - type: "numberedListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Numbered List Item", - styles: {}, - }, - ], - children: [ - { - id: UniqueID.options.generateID(), - type: "numberedListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Numbered List Item", - styles: {}, - }, - ], - children: [], - }, - ], - }, - { - id: UniqueID.options.generateID(), - type: "bulletListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Bullet List Item", - styles: {}, - }, - ], - children: [], - }, - ], - }, - { - id: UniqueID.options.generateID(), - type: "bulletListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Bullet List Item", - styles: {}, - }, - ], - children: [], - }, - ], - }, - { - id: UniqueID.options.generateID(), - type: "bulletListItem", - props: { - backgroundColor: "default", - textColor: "default", - textAlignment: "left", - }, - content: [ - { - type: "text", - text: "Bullet List Item", - styles: {}, - }, - ], - children: [], - }, -]; - -function removeInlineContentClass(html: string) { - return html.replace(/ class="_inlineContent_([a-zA-Z0-9_-])+"/g, ""); -} - -beforeEach(() => { - (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; - - editor = new BlockNoteEditor(); -}); - -afterEach(() => { - editor._tiptapEditor.destroy(); - editor = undefined as any; - - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; -}); - -describe("Non-Nested Block/HTML/Markdown Conversions", () => { - it("Convert non-nested blocks to HTML", async () => { - const output = await editor.blocksToHTML(getNonNestedBlocks()); - - expect(removeInlineContentClass(output)).toMatchSnapshot(); - }); - - it("Convert non-nested blocks to Markdown", async () => { - const output = await editor.blocksToMarkdown(getNonNestedBlocks()); - - expect(output).toMatchSnapshot(); - }); - - it("Convert non-nested HTML to blocks", async () => { - const html = `

Heading

Paragraph

  • Bullet List Item

  1. Numbered List Item

`; - const output = await editor.HTMLToBlocks(html); - - expect(output).toMatchSnapshot(); - }); - - it("Convert non-nested Markdown to blocks", async () => { - const markdown = `# Heading - -Paragraph - -* Bullet List Item - -1. Numbered List Item -`; - const output = await editor.markdownToBlocks(markdown); - - expect(output).toMatchSnapshot(); - }); -}); - -describe("Nested Block/HTML/Markdown Conversions", () => { - it("Convert nested blocks to HTML", async () => { - const output = await editor.blocksToHTML(getNestedBlocks()); - - expect(removeInlineContentClass(output)).toMatchSnapshot(); - }); - - it("Convert nested blocks to Markdown", async () => { - const output = await editor.blocksToMarkdown(getNestedBlocks()); - - expect(output).toMatchSnapshot(); - }); - // // Failing due to nested block parsing bug. - // it("Convert nested HTML to blocks", async () => { - // const html = `

Heading

Paragraph

  • Bullet List Item

    1. Numbered List Item

`; - // const output = await editor.HTMLToBlocks(html); - // - // expect(output).toMatchSnapshot(); - // }); - // // Failing due to nested block parsing bug. - // it("Convert nested Markdown to blocks", async () => { - // const markdown = `# Heading - // - // Paragraph - // - // * Bullet List Item - // - // 1. Numbered List Item - // `; - // const output = await editor.markdownToBlocks(markdown); - // - // expect(output).toMatchSnapshot(); - // }); -}); - -describe("Styled Block/HTML/Markdown Conversions", () => { - it("Convert styled blocks to HTML", async () => { - const output = await editor.blocksToHTML(getStyledBlocks()); - - expect(removeInlineContentClass(output)).toMatchSnapshot(); - }); - - it("Convert styled blocks to Markdown", async () => { - const output = await editor.blocksToMarkdown(getStyledBlocks()); - - expect(output).toMatchSnapshot(); - }); - - it("Convert styled HTML to blocks", async () => { - const html = `

BoldItalicUnderlineStrikethroughTextColorBackgroundColorMultiple

`; - const output = await editor.HTMLToBlocks(html); - - expect(output).toMatchSnapshot(); - }); - - it("Convert styled Markdown to blocks", async () => { - const markdown = `**Bold***Italic*Underline~~Strikethrough~~TextColorBackgroundColor***Multiple***`; - const output = await editor.markdownToBlocks(markdown); - - expect(output).toMatchSnapshot(); - }); -}); - -describe("Complex Block/HTML/Markdown Conversions", () => { - it("Convert complex blocks to HTML", async () => { - const output = await editor.blocksToHTML(getComplexBlocks()); - - expect(removeInlineContentClass(output)).toMatchSnapshot(); - }); - - it("Convert complex blocks to Markdown", async () => { - const output = await editor.blocksToMarkdown(getComplexBlocks()); - - expect(output).toMatchSnapshot(); - }); - // // Failing due to nested block parsing bug. - // it("Convert complex HTML to blocks", async () => { - // const html = `

Heading 1

Heading 2

Heading 3

Paragraph

Paragraph

Paragraph

  • Bullet List Item

  • Bullet List Item

    • Bullet List Item

      • Bullet List Item

      Paragraph

      1. Numbered List Item

      2. Numbered List Item

      3. Numbered List Item

        1. Numbered List Item

      • Bullet List Item

    • Bullet List Item

  • Bullet List Item

`; - // const output = await editor.HTMLToBlocks(html); - // - // expect(output).toMatchSnapshot(); - // }); - // // Failing due to nested block parsing bug. - // it("Convert complex Markdown to blocks", async () => { - // const markdown = `# Heading 1 - // - // ## Heading 2 - // - // ### Heading 3 - // - // Paragraph - // - // P**ara***grap*h - // - // P*ara*~~grap~~h - // - // * Bullet List Item - // - // * Bullet List Item - // - // * Bullet List Item - // - // * Bullet List Item - // - // Paragraph - // - // 1. Numbered List Item - // - // 2. Numbered List Item - // - // 3. Numbered List Item - // - // 1. Numbered List Item - // - // * Bullet List Item - // - // * Bullet List Item - // - // * Bullet List Item - // `; - // const output = await editor.markdownToBlocks(markdown); - // - // expect(output).toMatchSnapshot(); - // }); -}); diff --git a/packages/core/src/api/formatConversions/formatConversions.ts b/packages/core/src/api/formatConversions/formatConversions.ts deleted file mode 100644 index 5e64dfa6b5..0000000000 --- a/packages/core/src/api/formatConversions/formatConversions.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { DOMParser, DOMSerializer, Schema } from "prosemirror-model"; -import rehypeParse from "rehype-parse"; -import rehypeRemark from "rehype-remark"; -import rehypeStringify from "rehype-stringify"; -import remarkGfm from "remark-gfm"; -import remarkParse from "remark-parse"; -import remarkRehype, { defaultHandlers } from "remark-rehype"; -import remarkStringify from "remark-stringify"; -import { unified } from "unified"; -import { Block, BlockSchema } from "../../extensions/Blocks/api/blockTypes"; - -import { blockToNode, nodeToBlock } from "../nodeConversions/nodeConversions"; -import { removeUnderlines } from "./removeUnderlinesRehypePlugin"; -import { simplifyBlocks } from "./simplifyBlocksRehypePlugin"; - -export async function blocksToHTML( - blocks: Block[], - schema: Schema -): Promise { - const htmlParentElement = document.createElement("div"); - const serializer = DOMSerializer.fromSchema(schema); - - for (const block of blocks) { - const node = blockToNode(block, schema); - const htmlNode = serializer.serializeNode(node); - htmlParentElement.appendChild(htmlNode); - } - - const htmlString = await unified() - .use(rehypeParse, { fragment: true }) - .use(simplifyBlocks, { - orderedListItemBlockTypes: new Set(["numberedListItem"]), - unorderedListItemBlockTypes: new Set(["bulletListItem"]), - }) - .use(rehypeStringify) - .process(htmlParentElement.innerHTML); - - return htmlString.value as string; -} - -export async function HTMLToBlocks( - html: string, - blockSchema: BSchema, - schema: Schema -): Promise[]> { - const htmlNode = document.createElement("div"); - htmlNode.innerHTML = html.trim(); - - const parser = DOMParser.fromSchema(schema); - const parentNode = parser.parse(htmlNode); //, { preserveWhitespace: "full" }); - - const blocks: Block[] = []; - - for (let i = 0; i < parentNode.firstChild!.childCount; i++) { - blocks.push(nodeToBlock(parentNode.firstChild!.child(i), blockSchema)); - } - - return blocks; -} - -export async function blocksToMarkdown( - blocks: Block[], - schema: Schema -): Promise { - const markdownString = await unified() - .use(rehypeParse, { fragment: true }) - .use(removeUnderlines) - .use(rehypeRemark) - .use(remarkGfm) - .use(remarkStringify) - .process(await blocksToHTML(blocks, schema)); - - return markdownString.value as string; -} - -// modefied version of https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/code.js -// that outputs a data-language attribute instead of a CSS class (e.g.: language-typescript) -function code(state: any, node: any) { - const value = node.value ? node.value + "\n" : ""; - /** @type {Properties} */ - const properties: any = {}; - - if (node.lang) { - // changed line - properties["data-language"] = node.lang; - } - - // Create ``. - /** @type {Element} */ - let result: any = { - type: "element", - tagName: "code", - properties, - children: [{ type: "text", value }], - }; - - if (node.meta) { - result.data = { meta: node.meta }; - } - - state.patch(node, result); - result = state.applyData(node, result); - - // Create `
`.
-  result = {
-    type: "element",
-    tagName: "pre",
-    properties: {},
-    children: [result],
-  };
-  state.patch(node, result);
-  return result;
-}
-
-export async function markdownToBlocks(
-  markdown: string,
-  blockSchema: BSchema,
-  schema: Schema
-): Promise[]> {
-  const htmlString = await unified()
-    .use(remarkParse)
-    .use(remarkGfm)
-    .use(remarkRehype, {
-      handlers: {
-        ...(defaultHandlers as any),
-        code,
-      },
-    })
-    .use(rehypeStringify)
-    .process(markdown);
-
-  return HTMLToBlocks(htmlString.value as string, blockSchema, schema);
-}
diff --git a/packages/core/src/extensions/Blocks/helpers/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts
similarity index 100%
rename from packages/core/src/extensions/Blocks/helpers/getBlockInfoFromPos.ts
rename to packages/core/src/api/getBlockInfoFromPos.ts
diff --git a/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap b/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap
index 92be1196e5..c273c6e7e4 100644
--- a/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap
+++ b/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap
@@ -1,6 +1,134 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
-exports[`Complex ProseMirror Node Conversions > Convert complex block to node 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: custom inline content schema > Convert mention/basic to/from prosemirror 1`] = `
+{
+  "attrs": {
+    "backgroundColor": "default",
+    "id": "1",
+    "textColor": "default",
+  },
+  "content": [
+    {
+      "attrs": {
+        "textAlignment": "left",
+      },
+      "content": [
+        {
+          "text": "I enjoy working with ",
+          "type": "text",
+        },
+        {
+          "attrs": {
+            "user": "Matthew",
+          },
+          "type": "mention",
+        },
+      ],
+      "type": "paragraph",
+    },
+  ],
+  "type": "blockContainer",
+}
+`;
+
+exports[`Test BlockNote-Prosemirror conversion > Case: custom inline content schema > Convert tag/basic to/from prosemirror 1`] = `
+{
+  "attrs": {
+    "backgroundColor": "default",
+    "id": "1",
+    "textColor": "default",
+  },
+  "content": [
+    {
+      "attrs": {
+        "textAlignment": "left",
+      },
+      "content": [
+        {
+          "text": "I love ",
+          "type": "text",
+        },
+        {
+          "content": [
+            {
+              "text": "BlockNote",
+              "type": "text",
+            },
+          ],
+          "type": "tag",
+        },
+      ],
+      "type": "paragraph",
+    },
+  ],
+  "type": "blockContainer",
+}
+`;
+
+exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Convert fontSize/basic to/from prosemirror 1`] = `
+{
+  "attrs": {
+    "backgroundColor": "default",
+    "id": "1",
+    "textColor": "default",
+  },
+  "content": [
+    {
+      "attrs": {
+        "textAlignment": "left",
+      },
+      "content": [
+        {
+          "marks": [
+            {
+              "attrs": {
+                "stringValue": "18px",
+              },
+              "type": "fontSize",
+            },
+          ],
+          "text": "This is text with a custom fontSize",
+          "type": "text",
+        },
+      ],
+      "type": "paragraph",
+    },
+  ],
+  "type": "blockContainer",
+}
+`;
+
+exports[`Test BlockNote-Prosemirror conversion > Case: custom style schema > Convert small/basic to/from prosemirror 1`] = `
+{
+  "attrs": {
+    "backgroundColor": "default",
+    "id": "1",
+    "textColor": "default",
+  },
+  "content": [
+    {
+      "attrs": {
+        "textAlignment": "left",
+      },
+      "content": [
+        {
+          "marks": [
+            {
+              "type": "small",
+            },
+          ],
+          "text": "This is a small text",
+          "type": "text",
+        },
+      ],
+      "type": "paragraph",
+    },
+  ],
+  "type": "blockContainer",
+}
+`;
+
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert complex/misc to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "blue",
@@ -89,68 +217,91 @@ exports[`Complex ProseMirror Node Conversions > Convert complex block to node 1`
 }
 `;
 
-exports[`Complex ProseMirror Node Conversions > Convert complex node to block 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/basic to/from prosemirror 1`] = `
 {
-  "children": [
+  "attrs": {
+    "backgroundColor": "default",
+    "id": "1",
+    "textColor": "default",
+  },
+  "content": [
     {
-      "children": [],
+      "attrs": {
+        "textAlignment": "left",
+      },
       "content": [
         {
-          "styles": {},
-          "text": "Paragraph",
+          "text": "Text1",
+          "type": "text",
+        },
+        {
+          "type": "hardBreak",
+        },
+        {
+          "text": "Text2",
           "type": "text",
         },
       ],
-      "id": "2",
-      "props": {
-        "backgroundColor": "red",
-        "textAlignment": "left",
-        "textColor": "default",
-      },
       "type": "paragraph",
     },
-    {
-      "children": [],
-      "content": [],
-      "id": "3",
-      "props": {
-        "backgroundColor": "default",
-        "textAlignment": "left",
-        "textColor": "default",
-      },
-      "type": "bulletListItem",
-    },
   ],
+  "type": "blockContainer",
+}
+`;
+
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/between-links to/from prosemirror 1`] = `
+{
+  "attrs": {
+    "backgroundColor": "default",
+    "id": "1",
+    "textColor": "default",
+  },
   "content": [
     {
-      "styles": {
-        "bold": true,
-        "underline": true,
-      },
-      "text": "Heading ",
-      "type": "text",
-    },
-    {
-      "styles": {
-        "italic": true,
-        "strike": true,
+      "attrs": {
+        "textAlignment": "left",
       },
-      "text": "2",
-      "type": "text",
+      "content": [
+        {
+          "marks": [
+            {
+              "attrs": {
+                "class": null,
+                "href": "https://www.website.com",
+                "target": "_blank",
+              },
+              "type": "link",
+            },
+          ],
+          "text": "Link1",
+          "type": "text",
+        },
+        {
+          "type": "hardBreak",
+        },
+        {
+          "marks": [
+            {
+              "attrs": {
+                "class": null,
+                "href": "https://www.website2.com",
+                "target": "_blank",
+              },
+              "type": "link",
+            },
+          ],
+          "text": "Link2",
+          "type": "text",
+        },
+      ],
+      "type": "paragraph",
     },
   ],
-  "id": "1",
-  "props": {
-    "backgroundColor": "blue",
-    "level": 2,
-    "textAlignment": "right",
-    "textColor": "yellow",
-  },
-  "type": "heading",
+  "type": "blockContainer",
 }
 `;
 
-exports[`Simple ProseMirror Node Conversions > Convert simple block to node 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/end to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -162,6 +313,15 @@ exports[`Simple ProseMirror Node Conversions > Convert simple block to node 1`]
       "attrs": {
         "textAlignment": "left",
       },
+      "content": [
+        {
+          "text": "Text1",
+          "type": "text",
+        },
+        {
+          "type": "hardBreak",
+        },
+      ],
       "type": "paragraph",
     },
   ],
@@ -169,21 +329,59 @@ exports[`Simple ProseMirror Node Conversions > Convert simple block to node 1`]
 }
 `;
 
-exports[`Simple ProseMirror Node Conversions > Convert simple node to block 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/link to/from prosemirror 1`] = `
 {
-  "children": [],
-  "content": [],
-  "id": "1",
-  "props": {
+  "attrs": {
     "backgroundColor": "default",
-    "textAlignment": "left",
+    "id": "1",
     "textColor": "default",
   },
-  "type": "paragraph",
+  "content": [
+    {
+      "attrs": {
+        "textAlignment": "left",
+      },
+      "content": [
+        {
+          "marks": [
+            {
+              "attrs": {
+                "class": null,
+                "href": "https://www.website.com",
+                "target": "_blank",
+              },
+              "type": "link",
+            },
+          ],
+          "text": "Link1",
+          "type": "text",
+        },
+        {
+          "type": "hardBreak",
+        },
+        {
+          "marks": [
+            {
+              "attrs": {
+                "class": null,
+                "href": "https://www.website.com",
+                "target": "_blank",
+              },
+              "type": "link",
+            },
+          ],
+          "text": "Link1",
+          "type": "text",
+        },
+      ],
+      "type": "paragraph",
+    },
+  ],
+  "type": "blockContainer",
 }
 `;
 
-exports[`hard breaks > Convert a block with a hard break 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/multiple to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -207,6 +405,13 @@ exports[`hard breaks > Convert a block with a hard break 1`] = `
           "text": "Text2",
           "type": "text",
         },
+        {
+          "type": "hardBreak",
+        },
+        {
+          "text": "Text3",
+          "type": "text",
+        },
       ],
       "type": "paragraph",
     },
@@ -215,7 +420,7 @@ exports[`hard breaks > Convert a block with a hard break 1`] = `
 }
 `;
 
-exports[`hard breaks > Convert a block with a hard break and different styles 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/only to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -229,19 +434,34 @@ exports[`hard breaks > Convert a block with a hard break and different styles 1`
       },
       "content": [
         {
-          "text": "Text1",
-          "type": "text",
+          "type": "hardBreak",
         },
+      ],
+      "type": "paragraph",
+    },
+  ],
+  "type": "blockContainer",
+}
+`;
+
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/start to/from prosemirror 1`] = `
+{
+  "attrs": {
+    "backgroundColor": "default",
+    "id": "1",
+    "textColor": "default",
+  },
+  "content": [
+    {
+      "attrs": {
+        "textAlignment": "left",
+      },
+      "content": [
         {
           "type": "hardBreak",
         },
         {
-          "marks": [
-            {
-              "type": "bold",
-            },
-          ],
-          "text": "Text2",
+          "text": "Text1",
           "type": "text",
         },
       ],
@@ -252,7 +472,7 @@ exports[`hard breaks > Convert a block with a hard break and different styles 1`
 }
 `;
 
-exports[`hard breaks > Convert a block with a hard break at the end 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert hardbreak/styles to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -272,6 +492,15 @@ exports[`hard breaks > Convert a block with a hard break at the end 1`] = `
         {
           "type": "hardBreak",
         },
+        {
+          "marks": [
+            {
+              "type": "bold",
+            },
+          ],
+          "text": "Text2",
+          "type": "text",
+        },
       ],
       "type": "paragraph",
     },
@@ -280,7 +509,7 @@ exports[`hard breaks > Convert a block with a hard break at the end 1`] = `
 }
 `;
 
-exports[`hard breaks > Convert a block with a hard break at the start 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert image/basic to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -290,25 +519,87 @@ exports[`hard breaks > Convert a block with a hard break at the start 1`] = `
   "content": [
     {
       "attrs": {
+        "caption": "Caption",
         "textAlignment": "left",
+        "url": "exampleURL",
+        "width": 256,
       },
+      "type": "image",
+    },
+  ],
+  "type": "blockContainer",
+}
+`;
+
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert image/button to/from prosemirror 1`] = `
+{
+  "attrs": {
+    "backgroundColor": "default",
+    "id": "1",
+    "textColor": "default",
+  },
+  "content": [
+    {
+      "attrs": {
+        "caption": "",
+        "textAlignment": "left",
+        "url": "",
+        "width": 512,
+      },
+      "type": "image",
+    },
+  ],
+  "type": "blockContainer",
+}
+`;
+
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert image/nested to/from prosemirror 1`] = `
+{
+  "attrs": {
+    "backgroundColor": "default",
+    "id": "1",
+    "textColor": "default",
+  },
+  "content": [
+    {
+      "attrs": {
+        "caption": "Caption",
+        "textAlignment": "left",
+        "url": "exampleURL",
+        "width": 256,
+      },
+      "type": "image",
+    },
+    {
       "content": [
         {
-          "type": "hardBreak",
-        },
-        {
-          "text": "Text1",
-          "type": "text",
+          "attrs": {
+            "backgroundColor": "default",
+            "id": "2",
+            "textColor": "default",
+          },
+          "content": [
+            {
+              "attrs": {
+                "caption": "Caption",
+                "textAlignment": "left",
+                "url": "exampleURL",
+                "width": 256,
+              },
+              "type": "image",
+            },
+          ],
+          "type": "blockContainer",
         },
       ],
-      "type": "paragraph",
+      "type": "blockGroup",
     },
   ],
   "type": "blockContainer",
 }
 `;
 
-exports[`hard breaks > Convert a block with a hard break between links 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert link/adjacent to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -332,12 +623,9 @@ exports[`hard breaks > Convert a block with a hard break between links 1`] = `
               "type": "link",
             },
           ],
-          "text": "Link1",
+          "text": "Website",
           "type": "text",
         },
-        {
-          "type": "hardBreak",
-        },
         {
           "marks": [
             {
@@ -349,7 +637,7 @@ exports[`hard breaks > Convert a block with a hard break between links 1`] = `
               "type": "link",
             },
           ],
-          "text": "Link2",
+          "text": "Website2",
           "type": "text",
         },
       ],
@@ -360,7 +648,7 @@ exports[`hard breaks > Convert a block with a hard break between links 1`] = `
 }
 `;
 
-exports[`hard breaks > Convert a block with a hard break in a link 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert link/basic to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -384,11 +672,46 @@ exports[`hard breaks > Convert a block with a hard break in a link 1`] = `
               "type": "link",
             },
           ],
-          "text": "Link1",
+          "text": "Website",
           "type": "text",
         },
+      ],
+      "type": "paragraph",
+    },
+  ],
+  "type": "blockContainer",
+}
+`;
+
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert link/styled to/from prosemirror 1`] = `
+{
+  "attrs": {
+    "backgroundColor": "default",
+    "id": "1",
+    "textColor": "default",
+  },
+  "content": [
+    {
+      "attrs": {
+        "textAlignment": "left",
+      },
+      "content": [
         {
-          "type": "hardBreak",
+          "marks": [
+            {
+              "type": "bold",
+            },
+            {
+              "attrs": {
+                "class": null,
+                "href": "https://www.website.com",
+                "target": "_blank",
+              },
+              "type": "link",
+            },
+          ],
+          "text": "Web",
+          "type": "text",
         },
         {
           "marks": [
@@ -401,7 +724,7 @@ exports[`hard breaks > Convert a block with a hard break in a link 1`] = `
               "type": "link",
             },
           ],
-          "text": "Link1",
+          "text": "site",
           "type": "text",
         },
       ],
@@ -412,7 +735,7 @@ exports[`hard breaks > Convert a block with a hard break in a link 1`] = `
 }
 `;
 
-exports[`hard breaks > Convert a block with multiple hard breaks 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert paragraph/basic to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -426,21 +749,7 @@ exports[`hard breaks > Convert a block with multiple hard breaks 1`] = `
       },
       "content": [
         {
-          "text": "Text1",
-          "type": "text",
-        },
-        {
-          "type": "hardBreak",
-        },
-        {
-          "text": "Text2",
-          "type": "text",
-        },
-        {
-          "type": "hardBreak",
-        },
-        {
-          "text": "Text3",
+          "text": "Paragraph",
           "type": "text",
         },
       ],
@@ -451,7 +760,7 @@ exports[`hard breaks > Convert a block with multiple hard breaks 1`] = `
 }
 `;
 
-exports[`hard breaks > Convert a block with only a hard break 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert paragraph/empty to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -463,11 +772,6 @@ exports[`hard breaks > Convert a block with only a hard break 1`] = `
       "attrs": {
         "textAlignment": "left",
       },
-      "content": [
-        {
-          "type": "hardBreak",
-        },
-      ],
       "type": "paragraph",
     },
   ],
@@ -475,7 +779,7 @@ exports[`hard breaks > Convert a block with only a hard break 1`] = `
 }
 `;
 
-exports[`links > Convert a block with link 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert paragraph/nested to/from prosemirror 1`] = `
 {
   "attrs": {
     "backgroundColor": "default",
@@ -489,66 +793,123 @@ exports[`links > Convert a block with link 1`] = `
       },
       "content": [
         {
-          "marks": [
+          "text": "Paragraph",
+          "type": "text",
+        },
+      ],
+      "type": "paragraph",
+    },
+    {
+      "content": [
+        {
+          "attrs": {
+            "backgroundColor": "default",
+            "id": "2",
+            "textColor": "default",
+          },
+          "content": [
             {
               "attrs": {
-                "class": null,
-                "href": "https://www.website.com",
-                "target": "_blank",
+                "textAlignment": "left",
               },
-              "type": "link",
+              "content": [
+                {
+                  "text": "Nested Paragraph 1",
+                  "type": "text",
+                },
+              ],
+              "type": "paragraph",
             },
           ],
-          "text": "Website",
-          "type": "text",
+          "type": "blockContainer",
+        },
+        {
+          "attrs": {
+            "backgroundColor": "default",
+            "id": "3",
+            "textColor": "default",
+          },
+          "content": [
+            {
+              "attrs": {
+                "textAlignment": "left",
+              },
+              "content": [
+                {
+                  "text": "Nested Paragraph 2",
+                  "type": "text",
+                },
+              ],
+              "type": "paragraph",
+            },
+          ],
+          "type": "blockContainer",
         },
       ],
-      "type": "paragraph",
+      "type": "blockGroup",
     },
   ],
   "type": "blockContainer",
 }
 `;
 
-exports[`links > Convert two adjacent links in a block 1`] = `
+exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert paragraph/styled to/from prosemirror 1`] = `
 {
   "attrs": {
-    "backgroundColor": "default",
+    "backgroundColor": "pink",
     "id": "1",
-    "textColor": "default",
+    "textColor": "orange",
   },
   "content": [
     {
       "attrs": {
-        "textAlignment": "left",
+        "textAlignment": "center",
       },
       "content": [
+        {
+          "text": "Plain ",
+          "type": "text",
+        },
         {
           "marks": [
             {
               "attrs": {
-                "class": null,
-                "href": "https://www.website.com",
-                "target": "_blank",
+                "stringValue": "red",
               },
-              "type": "link",
+              "type": "textColor",
             },
           ],
-          "text": "Website",
+          "text": "Red Text ",
           "type": "text",
         },
         {
           "marks": [
             {
               "attrs": {
-                "class": null,
-                "href": "https://www.website2.com",
-                "target": "_blank",
+                "stringValue": "blue",
               },
-              "type": "link",
+              "type": "backgroundColor",
             },
           ],
-          "text": "Website2",
+          "text": "Blue Background ",
+          "type": "text",
+        },
+        {
+          "marks": [
+            {
+              "attrs": {
+                "stringValue": "red",
+              },
+              "type": "textColor",
+            },
+            {
+              "attrs": {
+                "stringValue": "blue",
+              },
+              "type": "backgroundColor",
+            },
+          ],
+          "text": "Mixed Colors",
           "type": "text",
         },
       ],
diff --git a/packages/core/src/api/nodeConversions/nodeConversions.test.ts b/packages/core/src/api/nodeConversions/nodeConversions.test.ts
index 37bdadfdb9..68d583dd55 100644
--- a/packages/core/src/api/nodeConversions/nodeConversions.test.ts
+++ b/packages/core/src/api/nodeConversions/nodeConversions.test.ts
@@ -1,501 +1,70 @@
-import { Editor } from "@tiptap/core";
 import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { BlockNoteEditor, PartialBlock } from "../..";
-import {
-  DefaultBlockSchema,
-  defaultBlockSchema,
-} from "../../extensions/Blocks/api/defaultBlocks";
-import UniqueID from "../../extensions/UniqueID/UniqueID";
-import { blockToNode, nodeToBlock } from "./nodeConversions";
-import { partialBlockToBlockForTesting } from "./testUtil";
-
-let editor: BlockNoteEditor;
-let tt: Editor;
-
-beforeEach(() => {
-  (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {};
-
-  editor = new BlockNoteEditor();
-  tt = editor._tiptapEditor;
-});
-
-afterEach(() => {
-  tt.destroy();
-  editor = undefined as any;
-  tt = undefined as any;
-
-  delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
-});
-
-describe("Simple ProseMirror Node Conversions", () => {
-  it("Convert simple block to node", async () => {
-    const block: PartialBlock = {
-      type: "paragraph",
-    };
-    const firstNodeConversion = blockToNode(block, tt.schema);
-
-    expect(firstNodeConversion).toMatchSnapshot();
-  });
-
-  it("Convert simple node to block", async () => {
-    const node = tt.schema.nodes["blockContainer"].create(
-      { id: UniqueID.options.generateID() },
-      tt.schema.nodes["paragraph"].create()
-    );
-    const firstBlockConversion = nodeToBlock(
-      node,
-      defaultBlockSchema
-    );
-
-    expect(firstBlockConversion).toMatchSnapshot();
-
-    const firstNodeConversion = blockToNode(
-      firstBlockConversion,
-      tt.schema
-    );
-
-    expect(firstNodeConversion).toStrictEqual(node);
-  });
-});
-
-describe("Complex ProseMirror Node Conversions", () => {
-  it("Convert complex block to node", async () => {
-    const block: PartialBlock = {
-      type: "heading",
-      props: {
-        backgroundColor: "blue",
-        textColor: "yellow",
-        textAlignment: "right",
-        level: 2,
-      },
-      content: [
-        {
-          type: "text",
-          text: "Heading ",
-          styles: {
-            bold: true,
-            underline: true,
-          },
-        },
-        {
-          type: "text",
-          text: "2",
-          styles: {
-            italic: true,
-            strike: true,
-          },
-        },
-      ],
-      children: [
-        {
-          type: "paragraph",
-          props: {
-            backgroundColor: "red",
-          },
-          content: "Paragraph",
-          children: [],
-        },
-        {
-          type: "bulletListItem",
-        },
-      ],
-    };
-    const firstNodeConversion = blockToNode(block, tt.schema);
-
-    expect(firstNodeConversion).toMatchSnapshot();
-  });
-
-  it("Convert complex node to block", async () => {
-    const node = tt.schema.nodes["blockContainer"].create(
-      {
-        id: UniqueID.options.generateID(),
-        backgroundColor: "blue",
-        textColor: "yellow",
-      },
-      [
-        tt.schema.nodes["heading"].create(
-          { textAlignment: "right", level: 2 },
-          [
-            tt.schema.text("Heading ", [
-              tt.schema.mark("bold"),
-              tt.schema.mark("underline"),
-            ]),
-            tt.schema.text("2", [
-              tt.schema.mark("italic"),
-              tt.schema.mark("strike"),
-            ]),
-          ]
-        ),
-        tt.schema.nodes["blockGroup"].create({}, [
-          tt.schema.nodes["blockContainer"].create(
-            { id: UniqueID.options.generateID(), backgroundColor: "red" },
-            [
-              tt.schema.nodes["paragraph"].create(
-                {},
-                tt.schema.text("Paragraph")
-              ),
-            ]
-          ),
-          tt.schema.nodes["blockContainer"].create(
-            { id: UniqueID.options.generateID() },
-            [tt.schema.nodes["bulletListItem"].create()]
-          ),
-        ]),
-      ]
-    );
-    const firstBlockConversion = nodeToBlock(
-      node,
-      defaultBlockSchema
-    );
-
-    expect(firstBlockConversion).toMatchSnapshot();
-
-    const firstNodeConversion = blockToNode(
-      firstBlockConversion,
-      tt.schema
-    );
-
-    expect(firstNodeConversion).toStrictEqual(node);
-  });
-});
-
-describe("links", () => {
-  it("Convert a block with link", async () => {
-    const block: PartialBlock = {
-      id: UniqueID.options.generateID(),
-      type: "paragraph",
-      content: [
-        {
-          type: "link",
-          href: "https://www.website.com",
-          content: "Website",
-        },
-      ],
-    };
-    const node = blockToNode(block, tt.schema);
-    expect(node).toMatchSnapshot();
-    const outputBlock = nodeToBlock(
-      node,
-      defaultBlockSchema
-    );
-
-    // Temporary fix to set props to {}, because at this point
-    // we don't have an easy way to access default props at runtime,
-    // so partialBlockToBlockForTesting will not set them.
-    (outputBlock as any).props = {};
-    const fullOriginalBlock = partialBlockToBlockForTesting(block);
-
-    expect(outputBlock).toStrictEqual(fullOriginalBlock);
-  });
-
-  it("Convert link block with marks", async () => {
-    const block: PartialBlock = {
-      id: UniqueID.options.generateID(),
-      type: "paragraph",
-      content: [
-        {
-          type: "link",
-          href: "https://www.website.com",
-          content: [
-            {
-              type: "text",
-              text: "Web",
-              styles: {
-                bold: true,
-              },
-            },
-            {
-              type: "text",
-              text: "site",
-              styles: {},
-            },
-          ],
-        },
-      ],
-    };
-    const node = blockToNode(block, tt.schema);
-    // expect(node).toMatchSnapshot();
-    const outputBlock = nodeToBlock(
-      node,
-      defaultBlockSchema
-    );
-
-    // Temporary fix to set props to {}, because at this point
-    // we don't have an easy way to access default props at runtime,
-    // so partialBlockToBlockForTesting will not set them.
-    (outputBlock as any).props = {};
-    const fullOriginalBlock = partialBlockToBlockForTesting(block);
-
-    expect(outputBlock).toStrictEqual(fullOriginalBlock);
-  });
-
-  it("Convert two adjacent links in a block", async () => {
-    const block: PartialBlock = {
-      id: UniqueID.options.generateID(),
-      type: "paragraph",
-      content: [
-        {
-          type: "link",
-          href: "https://www.website.com",
-          content: "Website",
-        },
-        {
-          type: "link",
-          href: "https://www.website2.com",
-          content: "Website2",
-        },
-      ],
-    };
 
-    const node = blockToNode(block, tt.schema);
-    expect(node).toMatchSnapshot();
-    const outputBlock = nodeToBlock(
-      node,
-      defaultBlockSchema
-    );
-
-    // Temporary fix to set props to {}, because at this point
-    // we don't have an easy way to access default props at runtime,
-    // so partialBlockToBlockForTesting will not set them.
-    (outputBlock as any).props = {};
-    const fullOriginalBlock = partialBlockToBlockForTesting(block);
-
-    expect(outputBlock).toStrictEqual(fullOriginalBlock);
-  });
-});
-
-describe("hard breaks", () => {
-  it("Convert a block with a hard break", async () => {
-    const block: PartialBlock = {
-      id: UniqueID.options.generateID(),
-      type: "paragraph",
-      content: [
-        {
-          type: "text",
-          text: "Text1\nText2",
-          styles: {},
-        },
-      ],
-    };
-    const node = blockToNode(block, tt.schema);
-    expect(node).toMatchSnapshot();
-    const outputBlock = nodeToBlock(
-      node,
-      defaultBlockSchema
-    );
-
-    // Temporary fix to set props to {}, because at this point
-    // we don't have an easy way to access default props at runtime,
-    // so partialBlockToBlockForTesting will not set them.
-    (outputBlock as any).props = {};
-    const fullOriginalBlock = partialBlockToBlockForTesting(block);
-
-    expect(outputBlock).toStrictEqual(fullOriginalBlock);
-  });
-
-  it("Convert a block with multiple hard breaks", async () => {
-    const block: PartialBlock = {
-      id: UniqueID.options.generateID(),
-      type: "paragraph",
-      content: [
-        {
-          type: "text",
-          text: "Text1\nText2\nText3",
-          styles: {},
-        },
-      ],
-    };
-    const node = blockToNode(block, tt.schema);
-    expect(node).toMatchSnapshot();
-    const outputBlock = nodeToBlock(
-      node,
-      defaultBlockSchema
-    );
-
-    // Temporary fix to set props to {}, because at this point
-    // we don't have an easy way to access default props at runtime,
-    // so partialBlockToBlockForTesting will not set them.
-    (outputBlock as any).props = {};
-    const fullOriginalBlock = partialBlockToBlockForTesting(block);
-
-    expect(outputBlock).toStrictEqual(fullOriginalBlock);
-  });
-
-  it("Convert a block with a hard break at the start", async () => {
-    const block: PartialBlock = {
-      id: UniqueID.options.generateID(),
-      type: "paragraph",
-      content: [
-        {
-          type: "text",
-          text: "\nText1",
-          styles: {},
-        },
-      ],
-    };
-    const node = blockToNode(block, tt.schema);
-    expect(node).toMatchSnapshot();
-    const outputBlock = nodeToBlock(
-      node,
-      defaultBlockSchema
-    );
-
-    // Temporary fix to set props to {}, because at this point
-    // we don't have an easy way to access default props at runtime,
-    // so partialBlockToBlockForTesting will not set them.
-    (outputBlock as any).props = {};
-    const fullOriginalBlock = partialBlockToBlockForTesting(block);
-
-    expect(outputBlock).toStrictEqual(fullOriginalBlock);
-  });
-
-  it("Convert a block with a hard break at the end", async () => {
-    const block: PartialBlock = {
-      id: UniqueID.options.generateID(),
-      type: "paragraph",
-      content: [
-        {
-          type: "text",
-          text: "Text1\n",
-          styles: {},
-        },
-      ],
-    };
-    const node = blockToNode(block, tt.schema);
-    expect(node).toMatchSnapshot();
-    const outputBlock = nodeToBlock(
-      node,
-      defaultBlockSchema
-    );
-
-    // Temporary fix to set props to {}, because at this point
-    // we don't have an easy way to access default props at runtime,
-    // so partialBlockToBlockForTesting will not set them.
-    (outputBlock as any).props = {};
-    const fullOriginalBlock = partialBlockToBlockForTesting(block);
-
-    expect(outputBlock).toStrictEqual(fullOriginalBlock);
-  });
-
-  it("Convert a block with only a hard break", async () => {
-    const block: PartialBlock = {
-      id: UniqueID.options.generateID(),
-      type: "paragraph",
-      content: [
-        {
-          type: "text",
-          text: "\n",
-          styles: {},
-        },
-      ],
-    };
-    const node = blockToNode(block, tt.schema);
-    expect(node).toMatchSnapshot();
-    const outputBlock = nodeToBlock(
-      node,
-      defaultBlockSchema
-    );
-
-    // Temporary fix to set props to {}, because at this point
-    // we don't have an easy way to access default props at runtime,
-    // so partialBlockToBlockForTesting will not set them.
-    (outputBlock as any).props = {};
-    const fullOriginalBlock = partialBlockToBlockForTesting(block);
-
-    expect(outputBlock).toStrictEqual(fullOriginalBlock);
-  });
-
-  it("Convert a block with a hard break and different styles", async () => {
-    const block: PartialBlock = {
-      id: UniqueID.options.generateID(),
-      type: "paragraph",
-      content: [
-        {
-          type: "text",
-          text: "Text1\n",
-          styles: {},
-        },
-        {
-          type: "text",
-          text: "Text2",
-          styles: { bold: true },
-        },
-      ],
-    };
-    const node = blockToNode(block, tt.schema);
-    expect(node).toMatchSnapshot();
-    const outputBlock = nodeToBlock(
-      node,
-      defaultBlockSchema
-    );
-
-    // Temporary fix to set props to {}, because at this point
-    // we don't have an easy way to access default props at runtime,
-    // so partialBlockToBlockForTesting will not set them.
-    (outputBlock as any).props = {};
-    const fullOriginalBlock = partialBlockToBlockForTesting(block);
-
-    expect(outputBlock).toStrictEqual(fullOriginalBlock);
-  });
-
-  it("Convert a block with a hard break in a link", async () => {
-    const block: PartialBlock = {
-      id: UniqueID.options.generateID(),
-      type: "paragraph",
-      content: [
-        {
-          type: "link",
-          href: "https://www.website.com",
-          content: "Link1\nLink1",
-        },
-      ],
-    };
-    const node = blockToNode(block, tt.schema);
-    expect(node).toMatchSnapshot();
-    const outputBlock = nodeToBlock(
-      node,
-      defaultBlockSchema
-    );
-
-    // Temporary fix to set props to {}, because at this point
-    // we don't have an easy way to access default props at runtime,
-    // so partialBlockToBlockForTesting will not set them.
-    (outputBlock as any).props = {};
-    const fullOriginalBlock = partialBlockToBlockForTesting(block);
-
-    expect(outputBlock).toStrictEqual(fullOriginalBlock);
-  });
-
-  it("Convert a block with a hard break between links", async () => {
-    const block: PartialBlock = {
-      id: UniqueID.options.generateID(),
-      type: "paragraph",
-      content: [
-        {
-          type: "link",
-          href: "https://www.website.com",
-          content: "Link1\n",
-        },
-        {
-          type: "link",
-          href: "https://www.website2.com",
-          content: "Link2",
-        },
-      ],
-    };
-    const node = blockToNode(block, tt.schema);
-    expect(node).toMatchSnapshot();
-    const outputBlock = nodeToBlock(
-      node,
-      defaultBlockSchema
-    );
-
-    // Temporary fix to set props to {}, because at this point
-    // we don't have an easy way to access default props at runtime,
-    // so partialBlockToBlockForTesting will not set them.
-    (outputBlock as any).props = {};
-    const fullOriginalBlock = partialBlockToBlockForTesting(block);
-
-    expect(outputBlock).toStrictEqual(fullOriginalBlock);
-  });
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor";
+import { PartialBlock } from "../../schema/blocks/types";
+import { customInlineContentTestCases } from "../testUtil/cases/customInlineContent";
+import { customStylesTestCases } from "../testUtil/cases/customStyles";
+import { defaultSchemaTestCases } from "../testUtil/cases/defaultSchema";
+import { blockToNode, nodeToBlock } from "./nodeConversions";
+import { addIdsToBlock, partialBlockToBlockForTesting } from "../testUtil/partialBlockTestUtil";
+
+function validateConversion(
+  block: PartialBlock,
+  editor: BlockNoteEditor
+) {
+  addIdsToBlock(block);
+  const node = blockToNode(
+    block,
+    editor._tiptapEditor.schema,
+    editor.styleSchema
+  );
+
+  expect(node).toMatchSnapshot();
+
+  const outputBlock = nodeToBlock(
+    node,
+    editor.blockSchema,
+    editor.inlineContentSchema,
+    editor.styleSchema
+  );
+
+  const fullOriginalBlock = partialBlockToBlockForTesting(
+    editor.blockSchema,
+    block
+  );
+
+  expect(outputBlock).toStrictEqual(fullOriginalBlock);
+}
+
+const testCases = [
+  defaultSchemaTestCases,
+  customStylesTestCases,
+  customInlineContentTestCases,
+];
+
+describe("Test BlockNote-Prosemirror conversion", () => {
+  for (const testCase of testCases) {
+    describe("Case: " + testCase.name, () => {
+      let editor: BlockNoteEditor;
+
+      beforeEach(() => {
+        editor = testCase.createEditor();
+      });
+
+      afterEach(() => {
+        editor._tiptapEditor.destroy();
+        editor = undefined as any;
+
+        delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
+      });
+
+      for (const document of testCase.documents) {
+        // eslint-disable-next-line no-loop-func
+        it("Convert " + document.name + " to/from prosemirror", () => {
+          // NOTE: only converts first block
+          validateConversion(document.blocks[0], editor);
+        });
+      }
+    });
+  }
 });
diff --git a/packages/core/src/api/nodeConversions/nodeConversions.ts b/packages/core/src/api/nodeConversions/nodeConversions.ts
index e20ff08315..3f82377c5a 100644
--- a/packages/core/src/api/nodeConversions/nodeConversions.ts
+++ b/packages/core/src/api/nodeConversions/nodeConversions.ts
@@ -1,44 +1,56 @@
-import { Mark } from "@tiptap/pm/model";
-import { Node, Schema } from "prosemirror-model";
-import {
+import { Mark, Node, Schema } from "@tiptap/pm/model";
+
+import UniqueID from "../../extensions/UniqueID/UniqueID";
+import type {
   Block,
   BlockSchema,
-  PartialBlock,
-} from "../../extensions/Blocks/api/blockTypes";
-import {
-  ColorStyle,
+  CustomInlineContentConfig,
+  CustomInlineContentFromConfig,
   InlineContent,
+  InlineContentFromConfig,
+  InlineContentSchema,
+  PartialBlock,
+  PartialCustomInlineContentFromConfig,
   PartialInlineContent,
   PartialLink,
+  PartialTableContent,
+  StyleSchema,
   StyledText,
   Styles,
-  ToggledStyle,
-} from "../../extensions/Blocks/api/inlineContentTypes";
-import { getBlockInfo } from "../../extensions/Blocks/helpers/getBlockInfoFromPos";
-import UniqueID from "../../extensions/UniqueID/UniqueID";
-import { UnreachableCaseError } from "../../shared/utils";
+  TableContent,
+} from "../../schema";
+import { getBlockInfo } from "../getBlockInfoFromPos";
 
-const toggleStyles = new Set([
-  "bold",
-  "italic",
-  "underline",
-  "strike",
-  "code",
-]);
-const colorStyles = new Set(["textColor", "backgroundColor"]);
+import {
+  isLinkInlineContent,
+  isPartialLinkInlineContent,
+  isStyledTextInlineContent,
+} from "../../schema/inlineContent/types";
+import { UnreachableCaseError } from "../../util/typescript";
 
 /**
  * Convert a StyledText inline element to a
  * prosemirror text node with the appropriate marks
  */
-function styledTextToNodes(styledText: StyledText, schema: Schema): Node[] {
+function styledTextToNodes(
+  styledText: StyledText,
+  schema: Schema,
+  styleSchema: T
+): Node[] {
   const marks: Mark[] = [];
 
   for (const [style, value] of Object.entries(styledText.styles)) {
-    if (toggleStyles.has(style as ToggledStyle)) {
+    const config = styleSchema[style];
+    if (!config) {
+      throw new Error(`style ${style} not found in styleSchema`);
+    }
+
+    if (config.propSchema === "boolean") {
       marks.push(schema.mark(style));
-    } else if (colorStyles.has(style as ColorStyle)) {
-      marks.push(schema.mark(style, { color: value }));
+    } else if (config.propSchema === "string") {
+      marks.push(schema.mark(style, { stringValue: value }));
+    } else {
+      throw new UnreachableCaseError(config.propSchema);
     }
   }
 
@@ -64,42 +76,53 @@ function styledTextToNodes(styledText: StyledText, schema: Schema): Node[] {
  * Converts a Link inline content element to
  * prosemirror text nodes with the appropriate marks
  */
-function linkToNodes(link: PartialLink, schema: Schema): Node[] {
+function linkToNodes(
+  link: PartialLink,
+  schema: Schema,
+  styleSchema: StyleSchema
+): Node[] {
   const linkMark = schema.marks.link.create({
     href: link.href,
   });
 
-  return styledTextArrayToNodes(link.content, schema).map((node) => {
-    if (node.type.name === "text") {
-      return node.mark([...node.marks, linkMark]);
-    }
+  return styledTextArrayToNodes(link.content, schema, styleSchema).map(
+    (node) => {
+      if (node.type.name === "text") {
+        return node.mark([...node.marks, linkMark]);
+      }
 
-    if (node.type.name === "hardBreak") {
-      return node;
+      if (node.type.name === "hardBreak") {
+        return node;
+      }
+      throw new Error("unexpected node type");
     }
-    throw new Error("unexpected node type");
-  });
+  );
 }
 
 /**
  * Converts an array of StyledText inline content elements to
  * prosemirror text nodes with the appropriate marks
  */
-function styledTextArrayToNodes(
-  content: string | StyledText[],
-  schema: Schema
+function styledTextArrayToNodes(
+  content: string | StyledText[],
+  schema: Schema,
+  styleSchema: S
 ): Node[] {
   const nodes: Node[] = [];
 
   if (typeof content === "string") {
     nodes.push(
-      ...styledTextToNodes({ type: "text", text: content, styles: {} }, schema)
+      ...styledTextToNodes(
+        { type: "text", text: content, styles: {} },
+        schema,
+        styleSchema
+      )
     );
     return nodes;
   }
 
   for (const styledText of content) {
-    nodes.push(...styledTextToNodes(styledText, schema));
+    nodes.push(...styledTextToNodes(styledText, schema, styleSchema));
   }
   return nodes;
 }
@@ -107,44 +130,85 @@ function styledTextArrayToNodes(
 /**
  * converts an array of inline content elements to prosemirror nodes
  */
-export function inlineContentToNodes(
-  blockContent: PartialInlineContent[],
-  schema: Schema
+export function inlineContentToNodes<
+  I extends InlineContentSchema,
+  S extends StyleSchema
+>(
+  blockContent: PartialInlineContent,
+  schema: Schema,
+  styleSchema: S
 ): Node[] {
   const nodes: Node[] = [];
 
   for (const content of blockContent) {
-    if (content.type === "link") {
-      nodes.push(...linkToNodes(content, schema));
-    } else if (content.type === "text") {
-      nodes.push(...styledTextArrayToNodes([content], schema));
+    if (typeof content === "string") {
+      nodes.push(...styledTextArrayToNodes(content, schema, styleSchema));
+    } else if (isPartialLinkInlineContent(content)) {
+      nodes.push(...linkToNodes(content, schema, styleSchema));
+    } else if (isStyledTextInlineContent(content)) {
+      nodes.push(...styledTextArrayToNodes([content], schema, styleSchema));
     } else {
-      throw new UnreachableCaseError(content);
+      nodes.push(
+        blockOrInlineContentToContentNode(content, schema, styleSchema)
+      );
     }
   }
   return nodes;
 }
 
 /**
- * Converts a BlockNote block to a TipTap node.
+ * converts an array of inline content elements to prosemirror nodes
  */
-export function blockToNode(
-  block: PartialBlock,
-  schema: Schema
-) {
-  let id = block.id;
+export function tableContentToNodes<
+  I extends InlineContentSchema,
+  S extends StyleSchema
+>(
+  tableContent: PartialTableContent,
+  schema: Schema,
+  styleSchema: StyleSchema
+): Node[] {
+  const rowNodes: Node[] = [];
+
+  for (const row of tableContent.rows) {
+    const columnNodes: Node[] = [];
+    for (const cell of row.cells) {
+      let pNode: Node;
+      if (!cell) {
+        pNode = schema.nodes["tableParagraph"].create({});
+      } else if (typeof cell === "string") {
+        pNode = schema.nodes["tableParagraph"].create({}, schema.text(cell));
+      } else {
+        const textNodes = inlineContentToNodes(cell, schema, styleSchema);
+        pNode = schema.nodes["tableParagraph"].create({}, textNodes);
+      }
 
-  if (id === undefined) {
-    id = UniqueID.options.generateID();
+      const cellNode = schema.nodes["tableCell"].create({}, pNode);
+      columnNodes.push(cellNode);
+    }
+    const rowNode = schema.nodes["tableRow"].create({}, columnNodes);
+    rowNodes.push(rowNode);
   }
+  return rowNodes;
+}
 
+function blockOrInlineContentToContentNode(
+  block:
+    | PartialBlock
+    | PartialCustomInlineContentFromConfig,
+  schema: Schema,
+  styleSchema: StyleSchema
+) {
+  let contentNode: Node;
   let type = block.type;
 
+  // TODO: needed? came from previous code
   if (type === undefined) {
     type = "paragraph";
   }
 
-  let contentNode: Node;
+  if (!schema.nodes[type]) {
+    throw new Error(`node type ${type} not found in schema`);
+  }
 
   if (!block.content) {
     contentNode = schema.nodes[type].create(block.props);
@@ -153,16 +217,42 @@ export function blockToNode(
       block.props,
       schema.text(block.content)
     );
-  } else {
-    const nodes = inlineContentToNodes(block.content, schema);
+  } else if (Array.isArray(block.content)) {
+    const nodes = inlineContentToNodes(block.content, schema, styleSchema);
     contentNode = schema.nodes[type].create(block.props, nodes);
+  } else if (block.content.type === "tableContent") {
+    const nodes = tableContentToNodes(block.content, schema, styleSchema);
+    contentNode = schema.nodes[type].create(block.props, nodes);
+  } else {
+    throw new UnreachableCaseError(block.content.type);
+  }
+  return contentNode;
+}
+/**
+ * Converts a BlockNote block to a TipTap node.
+ */
+export function blockToNode(
+  block: PartialBlock,
+  schema: Schema,
+  styleSchema: StyleSchema
+) {
+  let id = block.id;
+
+  if (id === undefined) {
+    id = UniqueID.options.generateID();
   }
 
+  const contentNode = blockOrInlineContentToContentNode(
+    block,
+    schema,
+    styleSchema
+  );
+
   const children: Node[] = [];
 
   if (block.children) {
     for (const child of block.children) {
-      children.push(blockToNode(child, schema));
+      children.push(blockToNode(child, schema, styleSchema));
     }
   }
 
@@ -177,12 +267,48 @@ export function blockToNode(
   );
 }
 
+/**
+ * Converts an internal (prosemirror) table node contentto a BlockNote Tablecontent
+ */
+function contentNodeToTableContent<
+  I extends InlineContentSchema,
+  S extends StyleSchema
+>(contentNode: Node, inlineContentSchema: I, styleSchema: S) {
+  const ret: TableContent = {
+    type: "tableContent",
+    rows: [],
+  };
+
+  contentNode.content.forEach((rowNode) => {
+    const row: TableContent["rows"][0] = {
+      cells: [],
+    };
+
+    rowNode.content.forEach((cellNode) => {
+      row.cells.push(
+        contentNodeToInlineContent(
+          cellNode.firstChild!,
+          inlineContentSchema,
+          styleSchema
+        )
+      );
+    });
+
+    ret.rows.push(row);
+  });
+
+  return ret;
+}
+
 /**
  * Converts an internal (prosemirror) content node to a BlockNote InlineContent array.
  */
-function contentNodeToInlineContent(contentNode: Node) {
-  const content: InlineContent[] = [];
-  let currentContent: InlineContent | undefined = undefined;
+export function contentNodeToInlineContent<
+  I extends InlineContentSchema,
+  S extends StyleSchema
+>(contentNode: Node, inlineContentSchema: I, styleSchema: S) {
+  const content: InlineContent[] = [];
+  let currentContent: InlineContent | undefined = undefined;
 
   // Most of the logic below is for handling links because in ProseMirror links are marks
   // while in BlockNote links are a type of inline content
@@ -192,13 +318,15 @@ function contentNodeToInlineContent(contentNode: Node) {
     if (node.type.name === "hardBreak") {
       if (currentContent) {
         // Current content exists.
-        if (currentContent.type === "text") {
+        if (isStyledTextInlineContent(currentContent)) {
           // Current content is text.
           currentContent.text += "\n";
-        } else if (currentContent.type === "link") {
+        } else if (isLinkInlineContent(currentContent)) {
           // Current content is a link.
           currentContent.content[currentContent.content.length - 1].text +=
             "\n";
+        } else {
+          throw new Error("unexpected");
         }
       } else {
         // Current content does not exist.
@@ -212,18 +340,41 @@ function contentNodeToInlineContent(contentNode: Node) {
       return;
     }
 
-    const styles: Styles = {};
+    if (
+      node.type.name !== "link" &&
+      node.type.name !== "text" &&
+      inlineContentSchema[node.type.name]
+    ) {
+      if (currentContent) {
+        content.push(currentContent);
+        currentContent = undefined;
+      }
+
+      content.push(
+        nodeToCustomInlineContent(node, inlineContentSchema, styleSchema)
+      );
+
+      return;
+    }
+
+    const styles: Styles = {};
     let linkMark: Mark | undefined;
 
     for (const mark of node.marks) {
       if (mark.type.name === "link") {
         linkMark = mark;
-      } else if (toggleStyles.has(mark.type.name as ToggledStyle)) {
-        styles[mark.type.name as ToggledStyle] = true;
-      } else if (colorStyles.has(mark.type.name as ColorStyle)) {
-        styles[mark.type.name as ColorStyle] = mark.attrs.color;
       } else {
-        throw Error("Mark is of an unrecognized type: " + mark.type.name);
+        const config = styleSchema[mark.type.name];
+        if (!config) {
+          throw new Error(`style ${mark.type.name} not found in styleSchema`);
+        }
+        if (config.propSchema === "boolean") {
+          (styles as any)[config.type] = true;
+        } else if (config.propSchema === "string") {
+          (styles as any)[config.type] = mark.attrs.stringValue;
+        } else {
+          throw new UnreachableCaseError(config.propSchema);
+        }
       }
     }
 
@@ -231,7 +382,7 @@ function contentNodeToInlineContent(contentNode: Node) {
     // Current content exists.
     if (currentContent) {
       // Current content is text.
-      if (currentContent.type === "text") {
+      if (isStyledTextInlineContent(currentContent)) {
         if (!linkMark) {
           // Node is text (same type as current content).
           if (
@@ -263,7 +414,7 @@ function contentNodeToInlineContent(contentNode: Node) {
             ],
           };
         }
-      } else if (currentContent.type === "link") {
+      } else if (isLinkInlineContent(currentContent)) {
         // Current content is a link.
         if (linkMark) {
           // Node is a link (same type as current content).
@@ -309,6 +460,8 @@ function contentNodeToInlineContent(contentNode: Node) {
             styles,
           };
         }
+      } else {
+        // TODO
       }
     }
     // Current content does not exist.
@@ -342,17 +495,66 @@ function contentNodeToInlineContent(contentNode: Node) {
     content.push(currentContent);
   }
 
-  return content;
+  return content as InlineContent[];
+}
+
+export function nodeToCustomInlineContent<
+  I extends InlineContentSchema,
+  S extends StyleSchema
+>(node: Node, inlineContentSchema: I, styleSchema: S): InlineContent {
+  if (node.type.name === "text" || node.type.name === "link") {
+    throw new Error("unexpected");
+  }
+  const props: any = {};
+  const icConfig = inlineContentSchema[
+    node.type.name
+  ] as CustomInlineContentConfig;
+  for (const [attr, value] of Object.entries(node.attrs)) {
+    if (!icConfig) {
+      throw Error("ic node is of an unrecognized type: " + node.type.name);
+    }
+
+    const propSchema = icConfig.propSchema;
+
+    if (attr in propSchema) {
+      props[attr] = value;
+    }
+  }
+
+  let content: CustomInlineContentFromConfig["content"];
+
+  if (icConfig.content === "styled") {
+    content = contentNodeToInlineContent(
+      node,
+      inlineContentSchema,
+      styleSchema
+    ) as any; // TODO: is this safe? could we have Links here that are undesired?
+  } else {
+    content = undefined;
+  }
+
+  const ic = {
+    type: node.type.name,
+    props,
+    content,
+  } as InlineContentFromConfig;
+  return ic;
 }
 
 /**
  * Convert a TipTap node to a BlockNote block.
  */
-export function nodeToBlock(
+export function nodeToBlock<
+  BSchema extends BlockSchema,
+  I extends InlineContentSchema,
+  S extends StyleSchema
+>(
   node: Node,
   blockSchema: BSchema,
-  blockCache?: WeakMap>
-): Block {
+  inlineContentSchema: I,
+  styleSchema: S,
+  blockCache?: WeakMap>
+): Block {
   if (node.type.name !== "blockContainer") {
     throw Error(
       "Node must be of type blockContainer, but is of type" +
@@ -382,6 +584,7 @@ export function nodeToBlock(
     ...blockInfo.contentNode.attrs,
   })) {
     const blockSpec = blockSchema[blockInfo.contentType.name];
+
     if (!blockSpec) {
       throw Error(
         "Block is of an unrecognized type: " + blockInfo.contentType.name
@@ -395,25 +598,48 @@ export function nodeToBlock(
     }
   }
 
-  const blockSpec = blockSchema[blockInfo.contentType.name];
+  const blockConfig = blockSchema[blockInfo.contentType.name];
 
-  const children: Block[] = [];
+  const children: Block[] = [];
   for (let i = 0; i < blockInfo.numChildBlocks; i++) {
     children.push(
-      nodeToBlock(node.lastChild!.child(i), blockSchema, blockCache)
+      nodeToBlock(
+        node.lastChild!.child(i),
+        blockSchema,
+        inlineContentSchema,
+        styleSchema,
+        blockCache
+      )
     );
   }
 
-  const block: Block = {
+  let content: Block["content"];
+
+  if (blockConfig.content === "inline") {
+    content = contentNodeToInlineContent(
+      blockInfo.contentNode,
+      inlineContentSchema,
+      styleSchema
+    );
+  } else if (blockConfig.content === "table") {
+    content = contentNodeToTableContent(
+      blockInfo.contentNode,
+      inlineContentSchema,
+      styleSchema
+    );
+  } else if (blockConfig.content === "none") {
+    content = undefined;
+  } else {
+    throw new UnreachableCaseError(blockConfig.content);
+  }
+
+  const block = {
     id,
-    type: blockSpec.node.name,
+    type: blockConfig.type,
     props,
-    content:
-      blockSpec.node.config.content === "inline*"
-        ? contentNodeToInlineContent(blockInfo.contentNode)
-        : undefined,
+    content,
     children,
-  } as Block;
+  } as Block;
 
   blockCache?.set(node, block);
 
diff --git a/packages/core/src/api/nodeConversions/testUtil.ts b/packages/core/src/api/nodeConversions/testUtil.ts
deleted file mode 100644
index c1740b0120..0000000000
--- a/packages/core/src/api/nodeConversions/testUtil.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import {
-  Block,
-  BlockSchema,
-  PartialBlock,
-} from "../../extensions/Blocks/api/blockTypes";
-import {
-  InlineContent,
-  PartialInlineContent,
-  StyledText,
-} from "../../extensions/Blocks/api/inlineContentTypes";
-
-function textShorthandToStyledText(
-  content: string | StyledText[] = ""
-): StyledText[] {
-  if (typeof content === "string") {
-    return [
-      {
-        type: "text",
-        text: content,
-        styles: {},
-      },
-    ];
-  }
-  return content;
-}
-
-function partialContentToInlineContent(
-  content: string | PartialInlineContent[] = ""
-): InlineContent[] {
-  if (typeof content === "string") {
-    return textShorthandToStyledText(content);
-  }
-
-  return content.map((partialContent) => {
-    if (partialContent.type === "link") {
-      return {
-        ...partialContent,
-        content: textShorthandToStyledText(partialContent.content),
-      };
-    } else {
-      return partialContent;
-    }
-  });
-}
-
-export function partialBlockToBlockForTesting(
-  partialBlock: PartialBlock
-): Block {
-  const withDefaults = {
-    id: "",
-    type: "paragraph",
-    // because at this point we don't have an easy way to access default props at runtime,
-    // partialBlockToBlockForTesting will not set them.
-    props: {} as any,
-    content: [] as any,
-    children: [],
-    ...partialBlock,
-  } satisfies PartialBlock;
-
-  return {
-    ...withDefaults,
-    content: partialContentToInlineContent(withDefaults.content),
-    children: withDefaults.children.map(partialBlockToBlockForTesting),
-  };
-}
diff --git a/packages/core/src/api/util/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts
similarity index 100%
rename from packages/core/src/api/util/nodeUtil.ts
rename to packages/core/src/api/nodeUtil.ts
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json b/packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json
new file mode 100644
index 0000000000..7ef10bf491
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/list-test.json
@@ -0,0 +1,105 @@
+[
+  {
+    "id": "1",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "First",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Second",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "3",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Third",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "4",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Five Parent",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "5",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Child 1",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "6",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Child 2",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json
new file mode 100644
index 0000000000..2d11e081f6
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-basic-block-types.json
@@ -0,0 +1,140 @@
+[
+  {
+    "id": "1",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 1
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 1",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 2
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 2",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "3",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 3
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 3",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "4",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Paragraph",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "5",
+    "type": "image",
+    "props": {
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "url": "exampleURL",
+      "caption": "Image Caption",
+      "width": 512
+    },
+    "children": []
+  },
+  {
+    "id": "6",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "None ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Bold ",
+        "styles": {
+          "bold": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "Italic ",
+        "styles": {
+          "italic": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "Underline ",
+        "styles": {
+          "underline": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "Strikethrough ",
+        "styles": {
+          "strike": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "All",
+        "styles": {
+          "bold": true,
+          "italic": true,
+          "underline": true,
+          "strike": true
+        }
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json
new file mode 100644
index 0000000000..ae11e36cb7
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-deep-nested-content.json
@@ -0,0 +1,240 @@
+[
+  {
+    "id": "1",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Outer 1 Div Before",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": " Outer 2 Div Before",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "3",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": " Outer 3 Div Before",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "4",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": " Outer 4 Div Before",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "5",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 1
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 1",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "6",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 2
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 2",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "7",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 3
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 3",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "8",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Paragraph",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "9",
+    "type": "image",
+    "props": {
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "url": "exampleURL",
+      "caption": "Image Caption",
+      "width": 512
+    },
+    "children": []
+  },
+  {
+    "id": "10",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bold",
+        "styles": {
+          "bold": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Italic",
+        "styles": {
+          "italic": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Underline",
+        "styles": {
+          "underline": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Strikethrough",
+        "styles": {
+          "strike": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "All",
+        "styles": {
+          "bold": true,
+          "italic": true,
+          "underline": true,
+          "strike": true
+        }
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "11",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": " Outer 4 Div After Outer 3 Div After Outer 2 Div After Outer 1 Div After",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json
new file mode 100644
index 0000000000..d06969a05f
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-div-with-inline-content.json
@@ -0,0 +1,91 @@
+[
+  {
+    "id": "1",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "None ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Bold ",
+        "styles": {
+          "bold": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "Italic ",
+        "styles": {
+          "italic": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "Underline ",
+        "styles": {
+          "underline": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "Strikethrough ",
+        "styles": {
+          "strike": true
+        }
+      },
+      {
+        "type": "text",
+        "text": "All",
+        "styles": {
+          "bold": true,
+          "italic": true,
+          "underline": true,
+          "strike": true
+        }
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "3",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Paragraph",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json
new file mode 100644
index 0000000000..764afd66ac
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-divs.json
@@ -0,0 +1,121 @@
+[
+  {
+    "id": "1",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Single Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": " Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "3",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "4",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "5",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Single Div 2",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "6",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "7",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json
new file mode 100644
index 0000000000..86a0cb8168
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-fake-image-caption.json
@@ -0,0 +1,31 @@
+[
+  {
+    "id": "1",
+    "type": "image",
+    "props": {
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "url": "exampleURL",
+      "caption": "",
+      "width": 512
+    },
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Image Caption",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-google-docs-html.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-google-docs-html.json
new file mode 100644
index 0000000000..c45e54ef9f
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-google-docs-html.json
@@ -0,0 +1,476 @@
+[
+  {
+    "id": "1",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 1
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 1",
+        "styles": {
+          "bold": true
+        }
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 2
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 2",
+        "styles": {
+          "bold": true
+        }
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "3",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 3
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 3",
+        "styles": {
+          "bold": true
+        }
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "4",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Paragraph 1",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "5",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Paragraph 2",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "6",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Paragraph 3",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "7",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Paragraph With \nHard Break",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "8",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bold",
+        "styles": {
+          "bold": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Italic",
+        "styles": {
+          "italic": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " Underline ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Strikethrough",
+        "styles": {
+          "strike": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "All",
+        "styles": {
+          "bold": true,
+          "italic": true,
+          "strike": true
+        }
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "9",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item 1",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "10",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item 1",
+            "styles": {}
+          }
+        ],
+        "children": [
+          {
+            "id": "11",
+            "type": "numberedListItem",
+            "props": {
+              "textColor": "default",
+              "backgroundColor": "default",
+              "textAlignment": "left"
+            },
+            "content": [
+              {
+                "type": "text",
+                "text": "Nested Numbered List Item 1",
+                "styles": {}
+              }
+            ],
+            "children": []
+          },
+          {
+            "id": "12",
+            "type": "numberedListItem",
+            "props": {
+              "textColor": "default",
+              "backgroundColor": "default",
+              "textAlignment": "left"
+            },
+            "content": [
+              {
+                "type": "text",
+                "text": "Nested Numbered List Item 2",
+                "styles": {}
+              }
+            ],
+            "children": []
+          }
+        ]
+      },
+      {
+        "id": "13",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item 2",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "14",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item 2",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "15",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item 1",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "16",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item 2",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "17",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [],
+    "children": []
+  },
+  {
+    "id": "18",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "\n",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "19",
+    "type": "table",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default"
+    },
+    "content": {
+      "type": "tableContent",
+      "rows": [
+        {
+          "cells": [
+            [
+              {
+                "type": "text",
+                "text": "Cell 1",
+                "styles": {}
+              }
+            ],
+            [
+              {
+                "type": "text",
+                "text": "Cell 2",
+                "styles": {}
+              }
+            ],
+            [
+              {
+                "type": "text",
+                "text": "Cell 3",
+                "styles": {}
+              }
+            ]
+          ]
+        },
+        {
+          "cells": [
+            [
+              {
+                "type": "text",
+                "text": "Cell 4",
+                "styles": {}
+              }
+            ],
+            [
+              {
+                "type": "text",
+                "text": "Cell 5",
+                "styles": {}
+              }
+            ],
+            [
+              {
+                "type": "text",
+                "text": "Cell 6",
+                "styles": {}
+              }
+            ]
+          ]
+        },
+        {
+          "cells": [
+            [
+              {
+                "type": "text",
+                "text": "Cell 7",
+                "styles": {}
+              }
+            ],
+            [
+              {
+                "type": "text",
+                "text": "Cell 8",
+                "styles": {}
+              }
+            ],
+            [
+              {
+                "type": "text",
+                "text": "Cell 9",
+                "styles": {}
+              }
+            ]
+          ]
+        }
+      ]
+    },
+    "children": []
+  },
+  {
+    "id": "20",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Paragraph",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "21",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "\n",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json
new file mode 100644
index 0000000000..7bb12cd2cb
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-mixed-nested-lists.json
@@ -0,0 +1,140 @@
+[
+  {
+    "id": "1",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "2",
+        "type": "numberedListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Numbered List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "3",
+        "type": "numberedListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Numbered List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "4",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "5",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "6",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "7",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "8",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json
new file mode 100644
index 0000000000..cc6065d2d4
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists-with-paragraphs.json
@@ -0,0 +1,140 @@
+[
+  {
+    "id": "1",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "2",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "3",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "4",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "5",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "6",
+        "type": "numberedListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Numbered List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "7",
+        "type": "numberedListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Numbered List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "8",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json
new file mode 100644
index 0000000000..e20435c9c8
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-nested-lists.json
@@ -0,0 +1,157 @@
+[
+  {
+    "id": "1",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "3",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "4",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "5",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "6",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "7",
+        "type": "numberedListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Numbered List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      },
+      {
+        "id": "8",
+        "type": "numberedListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Numbered List Item",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "9",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-notion-html.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-notion-html.json
new file mode 100644
index 0000000000..d79fe09648
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-notion-html.json
@@ -0,0 +1,470 @@
+[
+  {
+    "id": "1",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 1
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 1",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 2
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 2",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "3",
+    "type": "heading",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left",
+      "level": 3
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Heading 3",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "4",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Paragraph 1",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "5",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Paragraph 1",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "6",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Nested Paragraph 2",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "7",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Paragraph With Hard Break",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "8",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bold",
+        "styles": {
+          "bold": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Italic",
+        "styles": {
+          "italic": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " Underline ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "Strikethrough",
+        "styles": {
+          "strike": true
+        }
+      },
+      {
+        "type": "text",
+        "text": " ",
+        "styles": {}
+      },
+      {
+        "type": "text",
+        "text": "All",
+        "styles": {
+          "bold": true,
+          "italic": true,
+          "strike": true
+        }
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "9",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item 1",
+        "styles": {}
+      }
+    ],
+    "children": [
+      {
+        "id": "10",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item 1",
+            "styles": {}
+          }
+        ],
+        "children": [
+          {
+            "id": "11",
+            "type": "numberedListItem",
+            "props": {
+              "textColor": "default",
+              "backgroundColor": "default",
+              "textAlignment": "left"
+            },
+            "content": [
+              {
+                "type": "text",
+                "text": "Nested Numbered List Item 1",
+                "styles": {}
+              }
+            ],
+            "children": []
+          },
+          {
+            "id": "12",
+            "type": "numberedListItem",
+            "props": {
+              "textColor": "default",
+              "backgroundColor": "default",
+              "textAlignment": "left"
+            },
+            "content": [
+              {
+                "type": "text",
+                "text": "Nested Numbered List Item 2",
+                "styles": {}
+              }
+            ],
+            "children": []
+          }
+        ]
+      },
+      {
+        "id": "13",
+        "type": "bulletListItem",
+        "props": {
+          "textColor": "default",
+          "backgroundColor": "default",
+          "textAlignment": "left"
+        },
+        "content": [
+          {
+            "type": "text",
+            "text": "Nested Bullet List Item 2",
+            "styles": {}
+          }
+        ],
+        "children": []
+      }
+    ]
+  },
+  {
+    "id": "14",
+    "type": "bulletListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Bullet List Item 2",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "15",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item 1",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "16",
+    "type": "numberedListItem",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Numbered List Item 2",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "17",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Background Color Paragraph",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "18",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "!",
+        "styles": {}
+      },
+      {
+        "type": "link",
+        "href": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg",
+        "content": [
+          {
+            "type": "text",
+            "text": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg",
+            "styles": {}
+          }
+        ]
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "19",
+    "type": "table",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default"
+    },
+    "content": {
+      "type": "tableContent",
+      "rows": [
+        {
+          "cells": [
+            [
+              {
+                "type": "text",
+                "text": "Cell 1",
+                "styles": {}
+              }
+            ],
+            [
+              {
+                "type": "text",
+                "text": "Cell 2",
+                "styles": {}
+              }
+            ],
+            [
+              {
+                "type": "text",
+                "text": "Cell 3",
+                "styles": {}
+              }
+            ]
+          ]
+        },
+        {
+          "cells": [
+            [
+              {
+                "type": "text",
+                "text": "Cell 4",
+                "styles": {}
+              }
+            ],
+            [
+              {
+                "type": "text",
+                "text": "Cell 5",
+                "styles": {}
+              }
+            ],
+            [
+              {
+                "type": "text",
+                "text": "Cell 6",
+                "styles": {}
+              }
+            ]
+          ]
+        },
+        {
+          "cells": [
+            [
+              {
+                "type": "text",
+                "text": "Cell 7",
+                "styles": {}
+              }
+            ],
+            [
+              {
+                "type": "text",
+                "text": "Cell 8",
+                "styles": {}
+              }
+            ],
+            [
+              {
+                "type": "text",
+                "text": "Cell 9",
+                "styles": {}
+              }
+            ]
+          ]
+        }
+      ]
+    },
+    "children": []
+  },
+  {
+    "id": "20",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Paragraph",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json
new file mode 100644
index 0000000000..aa21de34f0
--- /dev/null
+++ b/packages/core/src/api/parsers/html/__snapshots__/paste/parse-two-divs.json
@@ -0,0 +1,36 @@
+[
+  {
+    "id": "1",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "Single Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  },
+  {
+    "id": "2",
+    "type": "paragraph",
+    "props": {
+      "textColor": "default",
+      "backgroundColor": "default",
+      "textAlignment": "left"
+    },
+    "content": [
+      {
+        "type": "text",
+        "text": "second Div",
+        "styles": {}
+      }
+    ],
+    "children": []
+  }
+]
\ No newline at end of file
diff --git a/packages/core/src/api/parsers/html/parseHTML.test.ts b/packages/core/src/api/parsers/html/parseHTML.test.ts
new file mode 100644
index 0000000000..69be9d69bb
--- /dev/null
+++ b/packages/core/src/api/parsers/html/parseHTML.test.ts
@@ -0,0 +1,440 @@
+import { describe, expect, it } from "vitest";
+import { BlockNoteEditor } from "../../..";
+import { nestedListsToBlockNoteStructure } from "./util/nestedLists";
+
+async function parseHTMLAndCompareSnapshots(
+  html: string,
+  snapshotName: string
+) {
+  // use a dynamic import because we want to access
+  // __parseFromClipboard which is not exposed in types
+  const view: any = await import("prosemirror-view");
+
+  const editor = BlockNoteEditor.create();
+  const blocks = await editor.tryParseHTMLToBlocks(html);
+
+  const snapshotPath = "./__snapshots__/paste/" + snapshotName + ".json";
+  expect(JSON.stringify(blocks, undefined, 2)).toMatchFileSnapshot(
+    snapshotPath
+  );
+
+  // Now, we also want to test actually pasting in the editor, and not just calling
+  // tryParseHTMLToBlocks directly.
+  // The reason is that the prosemirror logic for pasting can be a bit different, because
+  // it's related to the context of where the user is pasting exactly (selection)
+  //
+  // The internal difference come that in tryParseHTMLToBlocks, we use DOMParser.parse,
+  // while when pasting, Prosemirror uses DOMParser.parseSlice, and then tries to fit the
+  // slice in the document. This fitting might change the structure / interpretation of the pasted blocks
+
+  // Simulate a paste event (this uses DOMParser.parseSlice internally)
+
+  (window as any).__TEST_OPTIONS.mockID = 0; // reset id counter
+  const htmlNode = nestedListsToBlockNoteStructure(html);
+  const tt = editor._tiptapEditor;
+
+  const slice = view.__parseFromClipboard(
+    tt.view,
+    "",
+    htmlNode.innerHTML,
+    false,
+    tt.view.state.selection.$from
+  );
+  tt.view.dispatch(tt.view.state.tr.replaceSelection(slice));
+
+  // alternative paste simulation doesn't work in a non-browser vitest env
+  //   editor._tiptapEditor.view.pasteHTML(html, {
+  //     preventDefault: () => {
+  //       // noop
+  //     },
+  //     clipboardData: {
+  //       types: ["text/html"],
+  //       getData: () => html,
+  //     },
+  //   } as any);
+
+  const pastedBlocks = editor.topLevelBlocks;
+  pastedBlocks.pop(); // trailing paragraph
+  expect(pastedBlocks).toStrictEqual(blocks);
+}
+
+describe("Parse HTML", () => {
+  it("Parse basic block types", async () => {
+    const html = `

Heading 1

+

Heading 2

+

Heading 3

+

Paragraph

+
Image Caption
+

None Bold Italic Underline Strikethrough All

`; + + await parseHTMLAndCompareSnapshots(html, "parse-basic-block-types"); + }); + + it("list test", async () => { + const html = `
    +
  • First
  • +
  • Second
  • +
  • Third
  • +
  • Five Parent +
      +
    • Child 1
    • +
    • Child 2
    • +
    +
  • +
`; + await parseHTMLAndCompareSnapshots(html, "list-test"); + }); + + it("Parse nested lists", async () => { + const html = `
    +
  • Bullet List Item
  • +
  • Bullet List Item
  • +
      +
    • + Nested Bullet List Item +
    • +
    • + Nested Bullet List Item +
    • +
    +
  • + Bullet List Item +
  • +
+
    +
  1. + Numbered List Item +
      +
    1. + Nested Numbered List Item +
    2. +
    3. + Nested Numbered List Item +
    4. +
    +
  2. +
  3. + Numbered List Item +
  4. +
`; + + await parseHTMLAndCompareSnapshots(html, "parse-nested-lists"); + }); + + it("Parse nested lists with paragraphs", async () => { + const html = `
    +
  • +

    Bullet List Item

    +
      +
    • +

      Nested Bullet List Item

      +
    • +
    • +

      Nested Bullet List Item

      +
    • +
    +
  • +
  • +

    Bullet List Item

    +
  • +
+
    +
  1. +

    Numbered List Item

    +
      +
    1. +

      Nested Numbered List Item

      +
    2. +
    3. +

      Nested Numbered List Item

      +
    4. +
    +
  2. +
  3. +

    Numbered List Item

    +
  4. +
`; + + await parseHTMLAndCompareSnapshots( + html, + "parse-nested-lists-with-paragraphs" + ); + }); + + it("Parse mixed nested lists", async () => { + const html = `
    +
  • + Bullet List Item +
      +
    1. + Nested Numbered List Item +
    2. +
    3. + Nested Numbered List Item +
    4. +
    +
  • +
  • + Bullet List Item +
  • +
+
    +
  1. + Numbered List Item +
      +
    • +

      Nested Bullet List Item

      +
    • +
    • +

      Nested Bullet List Item

      +
    • +
    +
  2. +
  3. + Numbered List Item +
  4. +
`; + + await parseHTMLAndCompareSnapshots(html, "parse-mixed-nested-lists"); + }); + + it("Parse divs", async () => { + const html = `
Single Div
+
+ Div +
Nested Div
+
Nested Div
+
+
Single Div 2
+
+
Nested Div
+
Nested Div
+
`; + + await parseHTMLAndCompareSnapshots(html, "parse-divs"); + }); + + it("Parse two divs", async () => { + const html = `
Single Div
second Div
`; + + await parseHTMLAndCompareSnapshots(html, "parse-two-divs"); + }); + + it("Parse fake image caption", async () => { + const html = `
+ +

Image Caption

+
`; + + await parseHTMLAndCompareSnapshots(html, "parse-fake-image-caption"); + }); + + // TODO: this one fails + it.skip("Parse deep nested content", async () => { + const html = `
+ Outer 1 Div Before +
+ Outer 2 Div Before +
+ Outer 3 Div Before +
+ Outer 4 Div Before +

Heading 1

+

Heading 2

+

Heading 3

+

Paragraph

+
Image Caption
+

Bold Italic Underline Strikethrough All

+ Outer 4 Div After +
+ Outer 3 Div After +
+ Outer 2 Div After +
+ Outer 1 Div After +
`; + + await parseHTMLAndCompareSnapshots(html, "parse-deep-nested-content"); + }); + + it("Parse div with inline content and nested blocks", async () => { + const html = `
+ None Bold Italic Underline Strikethrough All +
Nested Div
+

Nested Paragraph

+
`; + + await parseHTMLAndCompareSnapshots(html, "parse-div-with-inline-content"); + }); + + it("Parse Notion HTML", async () => { + // A few notes on Notion output HTML: + // - Does not preserve text/background colors + // - Does not preserve non-list-item block nesting + // - Hard breaks are represented using white space, not `
` elements + // - Images are converted to links with a "!" at the start + // - Cells in first row of a table are converted to `th` elements, regardless + // of if the row is set as a header row + + const html = `

Heading 1

+

Heading 2

+

Heading 3

+

Paragraph 1

+

Nested Paragraph 1

+

Nested Paragraph 2

+

Paragraph +With Hard Break

+

Bold Italic Underline Strikethrough All

+
    +
  • Bullet List Item 1 +
      +
    • Nested Bullet List Item 1 +
        +
      1. Nested Numbered List Item 1
      2. +
      3. Nested Numbered List Item 2
      4. +
      +
    • +
    • Nested Bullet List Item 2
    • +
    +
  • +
  • Bullet List Item 2
  • +
+
    +
  1. Numbered List Item 1
  2. +
  3. Numbered List Item 2
  4. +
+

Background Color Paragraph

+

!https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg

+ + + + + + + + + + + + + + + + + + + + +
Cell 1Cell 2Cell 3
Cell 4Cell 5Cell 6
Cell 7Cell 8Cell 9
+

Paragraph

+`; + + await parseHTMLAndCompareSnapshots(html, "parse-notion-html"); + }); + + // Currently breaking, seems related to parsing `
` elements + it.skip("Parse Google Docs HTML", async () => { + // A few notes on Google Docs output HTML: + // - All inline markup is represented as `` elements with inline + // styles (bold, italic, etc.) + // - The nested list structure is not valid, i.e. `
    ` elements are not + // placed within `
  • ` elements + // - Images are wrapped in two spans and a paragraph + // - Everything is nested within a `` element + + const html = ` + + +

    Heading 1

    +

    Heading 2

    +

    Heading 3

    +

    Paragraph 1

    +

    Paragraph 2

    +

    Paragraph 3

    +

    Paragraph With
    Hard Break

    +

    Bold Italic Underline Strikethrough All

    +
      +
    • +

      Bullet List Item 1

      +
    • +
        +
      • +

        Nested Bullet List Item 1

        +
      • +
          +
        1. +

          Nested Numbered List Item 1

          +
        2. +
        3. +

          Nested Numbered List Item 2

          +
        4. +
        +
      • +

        Nested Bullet List Item 2

        +
      • +
      +
    • +

      Bullet List Item 2

      +
    • +
    +
      +
    1. +

      Numbered List Item 1

      +
    2. +
    3. +

      Numbered List Item 2

      +
    4. +
    +

    +
    +
    + +++++ + + + + + + + + + + + + + + + + + +
    +

    Cell 1

    +
    +

    Cell 2

    +
    +

    Cell 3

    +
    +

    Cell 4

    +
    +

    Cell 5

    +
    +

    Cell 6

    +
    +

    Cell 7

    +
    +

    Cell 8

    +
    +

    Cell 9

    +
    +
    +

    Paragraph

    +
    +
    `; + + await parseHTMLAndCompareSnapshots(html, "parse-google-docs-html"); + }); +}); diff --git a/packages/core/src/api/parsers/html/parseHTML.ts b/packages/core/src/api/parsers/html/parseHTML.ts new file mode 100644 index 0000000000..dad025f9dc --- /dev/null +++ b/packages/core/src/api/parsers/html/parseHTML.ts @@ -0,0 +1,42 @@ +import { DOMParser, Schema } from "prosemirror-model"; +import { + Block, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../../schema"; + +import { nodeToBlock } from "../../nodeConversions/nodeConversions"; +import { nestedListsToBlockNoteStructure } from "./util/nestedLists"; +export async function HTMLToBlocks< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + html: string, + blockSchema: BSchema, + icSchema: I, + styleSchema: S, + pmSchema: Schema +): Promise[]> { + const htmlNode = nestedListsToBlockNoteStructure(html); + const parser = DOMParser.fromSchema(pmSchema); + + // Other approach might be to use + // const doc = pmSchema.nodes["doc"].createAndFill()!; + // and context: doc.resolve(3), + + const parentNode = parser.parse(htmlNode, { + topNode: pmSchema.nodes["blockGroup"].create(), + }); + + const blocks: Block[] = []; + + for (let i = 0; i < parentNode.childCount; i++) { + blocks.push( + nodeToBlock(parentNode.child(i), blockSchema, icSchema, styleSchema) + ); + } + + return blocks; +} diff --git a/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap b/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap new file mode 100644 index 0000000000..d697b8db72 --- /dev/null +++ b/packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap @@ -0,0 +1,129 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Lift nested lists > Lifts multiple bullet lists 1`] = ` +" +
      +
      +
    • Bullet List Item 1
    • +
      +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
        +
      • Nested Bullet List Item 3
      • +
      • Nested Bullet List Item 4
      • +
      +
      +
      +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts multiple bullet lists with content in between 1`] = ` +" +
      +
      +
    • Bullet List Item 1
    • +
      +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
      +
      +
      +
    • In between content
    • +
      +
        +
      • Nested Bullet List Item 3
      • +
      • Nested Bullet List Item 4
      • +
      +
      +
      +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts nested bullet lists 1`] = ` +" +
      +
      +
    • Bullet List Item 1
    • +
      +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
      +
      +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts nested bullet lists with content after nested list 1`] = ` +" +
      +
      +
    • Bullet List Item 1
    • +
      +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
      +
      +
    • More content in list item 1
    • +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts nested bullet lists without li 1`] = ` +" +
      Bullet List Item 1 +
        +
      • Nested Bullet List Item 1
      • +
      • Nested Bullet List Item 2
      • +
      +
    • Bullet List Item 2
    • +
    +" +`; + +exports[`Lift nested lists > Lifts nested mixed lists 1`] = ` +" +
      +
      +
    1. Numbered List Item 1
    2. +
      +
        +
      • Bullet List Item 1
      • +
      • Bullet List Item 2
      • +
      +
      +
      +
    3. Numbered List Item 2
    4. +
    +" +`; + +exports[`Lift nested lists > Lifts nested numbered lists 1`] = ` +" +
      +
      +
    1. Numbered List Item 1
    2. +
      +
        +
      1. Nested Numbered List Item 1
      2. +
      3. Nested Numbered List Item 2
      4. +
      +
      +
      +
    3. Numbered List Item 2
    4. +
    +" +`; diff --git a/packages/core/src/api/parsers/html/util/nestedLists.test.ts b/packages/core/src/api/parsers/html/util/nestedLists.test.ts new file mode 100644 index 0000000000..96b0e1e9d2 --- /dev/null +++ b/packages/core/src/api/parsers/html/util/nestedLists.test.ts @@ -0,0 +1,176 @@ +import rehypeFormat from "rehype-format"; +import rehypeParse from "rehype-parse"; +import rehypeStringify from "rehype-stringify"; +import { unified } from "unified"; +import { describe, expect, it } from "vitest"; +import { nestedListsToBlockNoteStructure } from "./nestedLists"; + +async function testHTML(html: string) { + const htmlNode = nestedListsToBlockNoteStructure(html); + + const pretty = await unified() + .use(rehypeParse, { fragment: true }) + .use(rehypeFormat) + .use(rehypeStringify) + .process(htmlNode.innerHTML); + + expect(pretty.value).toMatchSnapshot(); +} + +describe("Lift nested lists", () => { + it("Lifts nested bullet lists", async () => { + const html = `
      +
    • + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      +
    • +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts nested bullet lists without li", async () => { + const html = `
      + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts nested bullet lists with content after nested list", async () => { + const html = `
      +
    • + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      + More content in list item 1 +
    • +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts multiple bullet lists", async () => { + const html = `
      +
    • + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      +
        +
      • + Nested Bullet List Item 3 +
      • +
      • + Nested Bullet List Item 4 +
      • +
      +
    • +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts multiple bullet lists with content in between", async () => { + const html = `
      +
    • + Bullet List Item 1 +
        +
      • + Nested Bullet List Item 1 +
      • +
      • + Nested Bullet List Item 2 +
      • +
      + In between content +
        +
      • + Nested Bullet List Item 3 +
      • +
      • + Nested Bullet List Item 4 +
      • +
      +
    • +
    • + Bullet List Item 2 +
    • +
    `; + await testHTML(html); + }); + + it("Lifts nested numbered lists", async () => { + const html = `
      +
    1. + Numbered List Item 1 +
        +
      1. + Nested Numbered List Item 1 +
      2. +
      3. + Nested Numbered List Item 2 +
      4. +
      +
    2. +
    3. + Numbered List Item 2 +
    4. +
    `; + await testHTML(html); + }); + + it("Lifts nested mixed lists", async () => { + const html = `
      +
    1. + Numbered List Item 1 +
        +
      • + Bullet List Item 1 +
      • +
      • + Bullet List Item 2 +
      • +
      +
    2. +
    3. + Numbered List Item 2 +
    4. +
    `; + await testHTML(html); + }); +}); diff --git a/packages/core/src/api/parsers/html/util/nestedLists.ts b/packages/core/src/api/parsers/html/util/nestedLists.ts new file mode 100644 index 0000000000..78c60b2a1a --- /dev/null +++ b/packages/core/src/api/parsers/html/util/nestedLists.ts @@ -0,0 +1,113 @@ +function getChildIndex(node: Element) { + return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); +} + +function isWhitespaceNode(node: Node) { + return node.nodeType === 3 && !/\S/.test(node.nodeValue || ""); +} + +/** + * Step 1, Turns: + * + *
      + *
    • item
    • + *
    • + *
        + *
      • ...
      • + *
      • ...
      • + *
      + *
    • + * + * Into: + *
        + *
      • item
      • + *
          + *
        • ...
        • + *
        • ...
        • + *
        + *
      + * + */ +function liftNestedListsToParent(element: HTMLElement) { + element.querySelectorAll("li > ul, li > ol").forEach((list) => { + const index = getChildIndex(list); + const parentListItem = list.parentElement!; + const siblingsAfter = Array.from(parentListItem.childNodes).slice( + index + 1 + ); + list.remove(); + siblingsAfter.forEach((sibling) => { + sibling.remove(); + }); + + parentListItem.insertAdjacentElement("afterend", list); + + siblingsAfter.reverse().forEach((sibling) => { + if (isWhitespaceNode(sibling)) { + return; + } + const siblingContainer = document.createElement("li"); + siblingContainer.append(sibling); + list.insertAdjacentElement("afterend", siblingContainer); + }); + if (parentListItem.childNodes.length === 0) { + parentListItem.remove(); + } + }); +} + +/** + * Step 2, Turns (output of liftNestedListsToParent): + * + *
    • item
    • + *
        + *
      • ...
      • + *
      • ...
      • + *
      + * + * Into: + *
      + *
    • item
    • + *
      + *
        + *
      • ...
      • + *
      • ...
      • + *
      + *
      + *
      + * + * This resulting format is parsed + */ +function createGroups(element: HTMLElement) { + element.querySelectorAll("li + ul, li + ol").forEach((list) => { + const listItem = list.previousElementSibling as HTMLElement; + const blockContainer = document.createElement("div"); + + listItem.insertAdjacentElement("afterend", blockContainer); + blockContainer.append(listItem); + + const blockGroup = document.createElement("div"); + blockGroup.setAttribute("data-node-type", "blockGroup"); + blockContainer.append(blockGroup); + + while ( + blockContainer.nextElementSibling?.nodeName === "UL" || + blockContainer.nextElementSibling?.nodeName === "OL" + ) { + blockGroup.append(blockContainer.nextElementSibling); + } + }); +} + +export function nestedListsToBlockNoteStructure( + elementOrHTML: HTMLElement | string +) { + if (typeof elementOrHTML === "string") { + const element = document.createElement("div"); + element.innerHTML = elementOrHTML; + elementOrHTML = element; + } + liftNestedListsToParent(elementOrHTML); + createGroups(elementOrHTML); + return elementOrHTML; +} diff --git a/packages/core/src/api/parsers/markdown/__snapshots__/complex.json b/packages/core/src/api/parsers/markdown/__snapshots__/complex.json new file mode 100644 index 0000000000..37036e205d --- /dev/null +++ b/packages/core/src/api/parsers/markdown/__snapshots__/complex.json @@ -0,0 +1,353 @@ +[ + { + "id": "1", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Heading 1", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Heading 2", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 3 + }, + "content": [ + { + "type": "text", + "text": "Heading 3", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph", + "styles": {} + } + ], + "children": [] + }, + { + "id": "5", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "P", + "styles": {} + }, + { + "type": "text", + "text": "ara", + "styles": { + "bold": true + } + }, + { + "type": "text", + "text": "grap", + "styles": { + "italic": true + } + }, + { + "type": "text", + "text": "h", + "styles": {} + } + ], + "children": [] + }, + { + "id": "6", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "P", + "styles": {} + }, + { + "type": "text", + "text": "ara", + "styles": { + "italic": true + } + }, + { + "type": "text", + "text": "grap", + "styles": { + "strike": true + } + }, + { + "type": "text", + "text": "h", + "styles": {} + } + ], + "children": [] + }, + { + "id": "7", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "8", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [ + { + "id": "9", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [ + { + "id": "10", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + } + ] + }, + { + "id": "11", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph", + "styles": {} + } + ], + "children": [ + { + "id": "12", + "type": "numberedListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Numbered List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "13", + "type": "numberedListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Numbered List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "14", + "type": "numberedListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Numbered List Item", + "styles": {} + } + ], + "children": [ + { + "id": "15", + "type": "numberedListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Numbered List Item", + "styles": {} + } + ], + "children": [] + } + ] + }, + { + "id": "16", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + } + ] + }, + { + "id": "17", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + } + ] + }, + { + "id": "18", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/parsers/markdown/__snapshots__/issue-226-1.json b/packages/core/src/api/parsers/markdown/__snapshots__/issue-226-1.json new file mode 100644 index 0000000000..9c186d37b0 --- /dev/null +++ b/packages/core/src/api/parsers/markdown/__snapshots__/issue-226-1.json @@ -0,0 +1,71 @@ +[ + { + "id": "1", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "📝 item1", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "⚙️ item2", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "🔗 item3", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "h1", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/parsers/markdown/__snapshots__/issue-226-2.json b/packages/core/src/api/parsers/markdown/__snapshots__/issue-226-2.json new file mode 100644 index 0000000000..fc1b9f3de9 --- /dev/null +++ b/packages/core/src/api/parsers/markdown/__snapshots__/issue-226-2.json @@ -0,0 +1,144 @@ +[ + { + "id": "1", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "a", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "b", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "c", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "d", + "styles": {} + } + ], + "children": [] + }, + { + "id": "5", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "anything", + "styles": {} + } + ], + "children": [] + }, + { + "id": "6", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "link", + "href": "http://example.com", + "content": [ + { + "type": "text", + "text": "a link", + "styles": {} + } + ] + } + ], + "children": [] + }, + { + "id": "7", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "another", + "styles": {} + } + ], + "children": [] + }, + { + "id": "8", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "list", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/parsers/markdown/__snapshots__/nested.json b/packages/core/src/api/parsers/markdown/__snapshots__/nested.json new file mode 100644 index 0000000000..627349f4f8 --- /dev/null +++ b/packages/core/src/api/parsers/markdown/__snapshots__/nested.json @@ -0,0 +1,72 @@ +[ + { + "id": "1", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Heading", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [ + { + "id": "4", + "type": "numberedListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Numbered List Item", + "styles": {} + } + ], + "children": [] + } + ] + } +] \ No newline at end of file diff --git a/packages/core/src/api/parsers/markdown/__snapshots__/non-nested.json b/packages/core/src/api/parsers/markdown/__snapshots__/non-nested.json new file mode 100644 index 0000000000..05911c4983 --- /dev/null +++ b/packages/core/src/api/parsers/markdown/__snapshots__/non-nested.json @@ -0,0 +1,71 @@ +[ + { + "id": "1", + "type": "heading", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Heading", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Paragraph", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "bulletListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bullet List Item", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "numberedListItem", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Numbered List Item", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/parsers/markdown/__snapshots__/styled.json b/packages/core/src/api/parsers/markdown/__snapshots__/styled.json new file mode 100644 index 0000000000..43a1efe52d --- /dev/null +++ b/packages/core/src/api/parsers/markdown/__snapshots__/styled.json @@ -0,0 +1,58 @@ +[ + { + "id": "1", + "type": "paragraph", + "props": { + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "Bold", + "styles": { + "bold": true + } + }, + { + "type": "text", + "text": " ", + "styles": {} + }, + { + "type": "text", + "text": "Italic", + "styles": { + "italic": true + } + }, + { + "type": "text", + "text": " ", + "styles": {} + }, + { + "type": "text", + "text": "Strikethrough", + "styles": { + "strike": true + } + }, + { + "type": "text", + "text": " ", + "styles": {} + }, + { + "type": "text", + "text": "Multiple", + "styles": { + "bold": true, + "italic": true + } + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/parsers/markdown/parseMarkdown.test.ts b/packages/core/src/api/parsers/markdown/parseMarkdown.test.ts new file mode 100644 index 0000000000..2c78b7f9e2 --- /dev/null +++ b/packages/core/src/api/parsers/markdown/parseMarkdown.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { BlockNoteEditor } from "../../.."; + +async function parseMarkdownAndCompareSnapshots( + md: string, + snapshotName: string +) { + const editor = BlockNoteEditor.create(); + const blocks = await editor.tryParseMarkdownToBlocks(md); + + const snapshotPath = "./__snapshots__/" + snapshotName + ".json"; + expect(JSON.stringify(blocks, undefined, 2)).toMatchFileSnapshot( + snapshotPath + ); +} + +describe("Parse Markdown", () => { + it("Convert non-nested Markdown to blocks", async () => { + const markdown = `# Heading + +Paragraph + +* Bullet List Item + +1. Numbered List Item + `; + await parseMarkdownAndCompareSnapshots(markdown, "non-nested"); + }); + + // Failing due to nested block parsing bug. + it("Convert nested Markdown to blocks", async () => { + const markdown = `# Heading + +Paragraph + +* Bullet List Item + + 1. Numbered List Item +`; + await parseMarkdownAndCompareSnapshots(markdown, "nested"); + }); + + it("Convert styled Markdown to blocks", async () => { + const markdown = `**Bold** *Italic* ~~Strikethrough~~ ***Multiple***`; + await parseMarkdownAndCompareSnapshots(markdown, "styled"); + }); + + it("Convert complex Markdown to blocks", async () => { + const markdown = `# Heading 1 + +## Heading 2 + +### Heading 3 + +Paragraph + +P**ara***grap*h + +P*ara*~~grap~~h + +* Bullet List Item + +* Bullet List Item + + * Bullet List Item + + * Bullet List Item + + Paragraph + + 1. Numbered List Item + + 2. Numbered List Item + + 3. Numbered List Item + + 1. Numbered List Item + + * Bullet List Item + + * Bullet List Item + +* Bullet List Item`; + await parseMarkdownAndCompareSnapshots(markdown, "complex"); + }); +}); + +describe("Issue 226", () => { + it("Case 1", async () => { + const markdown = ` +- 📝 item1 +- ⚙️ item2 +- 🔗 item3 + +# h1 +`; + await parseMarkdownAndCompareSnapshots(markdown, "issue-226-1"); + }); + + it("Case 2", async () => { + const markdown = `* a +* b +* c +* d + +anything + +[a link](http://example.com) + +* another +* list`; + await parseMarkdownAndCompareSnapshots(markdown, "issue-226-2"); + }); +}); diff --git a/packages/core/src/api/parsers/markdown/parseMarkdown.ts b/packages/core/src/api/parsers/markdown/parseMarkdown.ts new file mode 100644 index 0000000000..6e4fab74e2 --- /dev/null +++ b/packages/core/src/api/parsers/markdown/parseMarkdown.ts @@ -0,0 +1,84 @@ +import { Schema } from "prosemirror-model"; +import rehypeStringify from "rehype-stringify"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import remarkRehype, { defaultHandlers } from "remark-rehype"; +import { unified } from "unified"; +import { + Block, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../../schema"; +import { HTMLToBlocks } from "../html/parseHTML"; + +// modified version of https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/code.js +// that outputs a data-language attribute instead of a CSS class (e.g.: language-typescript) +function code(state: any, node: any) { + const value = node.value ? node.value + "\n" : ""; + /** @type {Properties} */ + const properties: any = {}; + + if (node.lang) { + // changed line + properties["data-language"] = node.lang; + } + + // Create ``. + /** @type {Element} */ + let result: any = { + type: "element", + tagName: "code", + properties, + children: [{ type: "text", value }], + }; + + if (node.meta) { + result.data = { meta: node.meta }; + } + + state.patch(node, result); + result = state.applyData(node, result); + + // Create `
      `.
      +  result = {
      +    type: "element",
      +    tagName: "pre",
      +    properties: {},
      +    children: [result],
      +  };
      +  state.patch(node, result);
      +  return result;
      +}
      +
      +export function markdownToBlocks<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  markdown: string,
      +  blockSchema: BSchema,
      +  icSchema: I,
      +  styleSchema: S,
      +  pmSchema: Schema
      +): Promise[]> {
      +  const htmlString = unified()
      +    .use(remarkParse)
      +    .use(remarkGfm)
      +    .use(remarkRehype, {
      +      handlers: {
      +        ...(defaultHandlers as any),
      +        code,
      +      },
      +    })
      +    .use(rehypeStringify)
      +    .processSync(markdown);
      +
      +  return HTMLToBlocks(
      +    htmlString.value as string,
      +    blockSchema,
      +    icSchema,
      +    styleSchema,
      +    pmSchema
      +  );
      +}
      diff --git a/packages/core/src/api/parsers/pasteExtension.ts b/packages/core/src/api/parsers/pasteExtension.ts
      new file mode 100644
      index 0000000000..0b0a027405
      --- /dev/null
      +++ b/packages/core/src/api/parsers/pasteExtension.ts
      @@ -0,0 +1,59 @@
      +import { Extension } from "@tiptap/core";
      +import { Plugin } from "prosemirror-state";
      +
      +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor";
      +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema";
      +import { nestedListsToBlockNoteStructure } from "./html/util/nestedLists";
      +
      +const acceptedMIMETypes = [
      +  "blocknote/html",
      +  "text/html",
      +  "text/plain",
      +] as const;
      +
      +export const createPasteFromClipboardExtension = <
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  editor: BlockNoteEditor
      +) =>
      +  Extension.create<{ editor: BlockNoteEditor }, undefined>({
      +    name: "pasteFromClipboard",
      +    addProseMirrorPlugins() {
      +      return [
      +        new Plugin({
      +          props: {
      +            handleDOMEvents: {
      +              paste(_view, event) {
      +                event.preventDefault();
      +                let format: (typeof acceptedMIMETypes)[number] | null = null;
      +
      +                for (const mimeType of acceptedMIMETypes) {
      +                  if (event.clipboardData!.types.includes(mimeType)) {
      +                    format = mimeType;
      +                    break;
      +                  }
      +                }
      +
      +                if (format !== null) {
      +                  let data = event.clipboardData!.getData(format);
      +                  if (format === "text/html") {
      +                    const htmlNode = nestedListsToBlockNoteStructure(
      +                      data.trim()
      +                    );
      +
      +                    data = htmlNode.innerHTML;
      +                    console.log(data);
      +                  }
      +                  editor._tiptapEditor.view.pasteHTML(data);
      +                }
      +
      +                return true;
      +              },
      +            },
      +          },
      +        }),
      +      ];
      +    },
      +  });
      diff --git a/packages/core/src/api/testUtil/cases/customBlocks.ts b/packages/core/src/api/testUtil/cases/customBlocks.ts
      new file mode 100644
      index 0000000000..4c278ea408
      --- /dev/null
      +++ b/packages/core/src/api/testUtil/cases/customBlocks.ts
      @@ -0,0 +1,282 @@
      +import { EditorTestCases } from "../index";
      +
      +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
      +import {
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema,
      +  defaultBlockSpecs,
      +} from "../../../blocks/defaultBlocks";
      +import { defaultProps } from "../../../blocks/defaultProps";
      +import {
      +  imagePropSchema,
      +  renderImage,
      +} from "../../../blocks/ImageBlockContent/ImageBlockContent";
      +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../blocks/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      +import { createBlockSpec } from "../../../schema/blocks/createSpec";
      +import { BlockSchemaFromSpecs, BlockSpecs } from "../../../schema/blocks/types";
      +
      +// This is a modified version of the default image block that does not implement
      +// a `serialize` function. It's used to test if the custom serializer by default
      +// serializes custom blocks using their `render` function.
      +const SimpleImage = createBlockSpec(
      +  {
      +    type: "simpleImage" as const,
      +    propSchema: imagePropSchema,
      +    content: "none",
      +  },
      +  { render: renderImage as any }
      +);
      +
      +const CustomParagraph = createBlockSpec(
      +  {
      +    type: "customParagraph" as const,
      +    propSchema: defaultProps,
      +    content: "inline",
      +  },
      +  {
      +    render: () => {
      +      const paragraph = document.createElement("p");
      +      paragraph.className = "custom-paragraph";
      +
      +      return {
      +        dom: paragraph,
      +        contentDOM: paragraph,
      +      };
      +    },
      +    toExternalHTML: () => {
      +      const paragraph = document.createElement("p");
      +      paragraph.className = "custom-paragraph";
      +      paragraph.innerHTML = "Hello World";
      +
      +      return {
      +        dom: paragraph,
      +      };
      +    },
      +  }
      +);
      +
      +const SimpleCustomParagraph = createBlockSpec(
      +  {
      +    type: "simpleCustomParagraph" as const,
      +    propSchema: defaultProps,
      +    content: "inline",
      +  },
      +  {
      +    render: () => {
      +      const paragraph = document.createElement("p");
      +      paragraph.className = "simple-custom-paragraph";
      +
      +      return {
      +        dom: paragraph,
      +        contentDOM: paragraph,
      +      };
      +    },
      +  }
      +);
      +
      +const customSpecs = {
      +  ...defaultBlockSpecs,
      +  simpleImage: SimpleImage,
      +  customParagraph: CustomParagraph,
      +  simpleCustomParagraph: SimpleCustomParagraph,
      +} satisfies BlockSpecs;
      +
      +export const customBlocksTestCases: EditorTestCases<
      +  BlockSchemaFromSpecs,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema
      +> = {
      +  name: "custom blocks schema",
      +  createEditor: () => {
      +    return BlockNoteEditor.create({
      +      blockSpecs: customSpecs,
      +      uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      +    });
      +  },
      +  documents: [
      +    {
      +      name: "simpleImage/button",
      +      blocks: [
      +        {
      +          type: "simpleImage" as const,
      +        },
      +      ],
      +    },
      +    {
      +      name: "simpleImage/basic",
      +      blocks: [
      +        {
      +          type: "simpleImage" as const,
      +          props: {
      +            url: "exampleURL",
      +            caption: "Caption",
      +            width: 256,
      +          } as const,
      +        },
      +      ],
      +    },
      +    {
      +      name: "simpleImage/nested",
      +      blocks: [
      +        {
      +          type: "simpleImage" as const,
      +          props: {
      +            url: "exampleURL",
      +            caption: "Caption",
      +            width: 256,
      +          } as const,
      +          children: [
      +            {
      +              type: "simpleImage" as const,
      +              props: {
      +                url: "exampleURL",
      +                caption: "Caption",
      +                width: 256,
      +              } as const,
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "customParagraph/basic",
      +      blocks: [
      +        {
      +          type: "customParagraph" as const,
      +          content: "Custom Paragraph",
      +        },
      +      ],
      +    },
      +    {
      +      name: "customParagraph/styled",
      +      blocks: [
      +        {
      +          type: "customParagraph" as const,
      +          props: {
      +            textAlignment: "center",
      +            textColor: "orange",
      +            backgroundColor: "pink",
      +          } as const,
      +          content: [
      +            {
      +              type: "text",
      +              styles: {},
      +              text: "Plain ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                textColor: "red",
      +              },
      +              text: "Red Text ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                backgroundColor: "blue",
      +              },
      +              text: "Blue Background ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                textColor: "red",
      +                backgroundColor: "blue",
      +              },
      +              text: "Mixed Colors",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "customParagraph/nested",
      +      blocks: [
      +        {
      +          type: "customParagraph" as const,
      +          content: "Custom Paragraph",
      +          children: [
      +            {
      +              type: "customParagraph" as const,
      +              content: "Nested Custom Paragraph 1",
      +            },
      +            {
      +              type: "customParagraph" as const,
      +              content: "Nested Custom Paragraph 2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "simpleCustomParagraph/basic",
      +      blocks: [
      +        {
      +          type: "simpleCustomParagraph" as const,
      +          content: "Custom Paragraph",
      +        },
      +      ],
      +    },
      +    {
      +      name: "simpleCustomParagraph/styled",
      +      blocks: [
      +        {
      +          type: "simpleCustomParagraph" as const,
      +          props: {
      +            textAlignment: "center",
      +            textColor: "orange",
      +            backgroundColor: "pink",
      +          } as const,
      +          content: [
      +            {
      +              type: "text",
      +              styles: {},
      +              text: "Plain ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                textColor: "red",
      +              },
      +              text: "Red Text ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                backgroundColor: "blue",
      +              },
      +              text: "Blue Background ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                textColor: "red",
      +                backgroundColor: "blue",
      +              },
      +              text: "Mixed Colors",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "simpleCustomParagraph/nested",
      +      blocks: [
      +        {
      +          type: "simpleCustomParagraph" as const,
      +          content: "Custom Paragraph",
      +          children: [
      +            {
      +              type: "simpleCustomParagraph" as const,
      +              content: "Nested Custom Paragraph 1",
      +            },
      +            {
      +              type: "simpleCustomParagraph" as const,
      +              content: "Nested Custom Paragraph 2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/packages/core/src/api/testUtil/cases/customInlineContent.ts b/packages/core/src/api/testUtil/cases/customInlineContent.ts
      new file mode 100644
      index 0000000000..737cab3db9
      --- /dev/null
      +++ b/packages/core/src/api/testUtil/cases/customInlineContent.ts
      @@ -0,0 +1,114 @@
      +import { EditorTestCases } from "../index";
      +
      +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
      +import {
      +  DefaultBlockSchema,
      +  DefaultStyleSchema,
      +  defaultInlineContentSpecs,
      +} from "../../../blocks/defaultBlocks";
      +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../blocks/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      +import { createInlineContentSpec } from "../../../schema/inlineContent/createSpec";
      +import {
      +  InlineContentSchemaFromSpecs,
      +  InlineContentSpecs,
      +} from "../../../schema/inlineContent/types";
      +
      +const mention = createInlineContentSpec(
      +  {
      +    type: "mention" as const,
      +    propSchema: {
      +      user: {
      +        default: "",
      +      },
      +    },
      +    content: "none",
      +  },
      +  {
      +    render: (ic) => {
      +      const dom = document.createElement("span");
      +      dom.appendChild(document.createTextNode("@" + ic.props.user));
      +
      +      return {
      +        dom,
      +      };
      +    },
      +  }
      +);
      +
      +const tag = createInlineContentSpec(
      +  {
      +    type: "tag" as const,
      +    propSchema: {},
      +    content: "styled",
      +  },
      +  {
      +    render: () => {
      +      const dom = document.createElement("span");
      +      dom.textContent = "#";
      +
      +      const contentDOM = document.createElement("span");
      +      dom.appendChild(contentDOM);
      +
      +      return {
      +        dom,
      +        contentDOM,
      +      };
      +    },
      +  }
      +);
      +
      +const customInlineContent = {
      +  ...defaultInlineContentSpecs,
      +  mention,
      +  tag,
      +} satisfies InlineContentSpecs;
      +
      +export const customInlineContentTestCases: EditorTestCases<
      +  DefaultBlockSchema,
      +  InlineContentSchemaFromSpecs,
      +  DefaultStyleSchema
      +> = {
      +  name: "custom inline content schema",
      +  createEditor: () => {
      +    return BlockNoteEditor.create({
      +      uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      +      inlineContentSpecs: customInlineContent,
      +    });
      +  },
      +  documents: [
      +    {
      +      name: "mention/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            "I enjoy working with ",
      +            {
      +              type: "mention",
      +              props: {
      +                user: "Matthew",
      +              },
      +              content: undefined,
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "tag/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            "I love ",
      +            {
      +              type: "tag",
      +              // props: {},
      +              content: "BlockNote",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/packages/core/src/api/testUtil/cases/customStyles.ts b/packages/core/src/api/testUtil/cases/customStyles.ts
      new file mode 100644
      index 0000000000..cd19064a99
      --- /dev/null
      +++ b/packages/core/src/api/testUtil/cases/customStyles.ts
      @@ -0,0 +1,100 @@
      +import { EditorTestCases } from "../index";
      +
      +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
      +import {
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  defaultStyleSpecs,
      +} from "../../../blocks/defaultBlocks";
      +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../blocks/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      +import { createStyleSpec } from "../../../schema/styles/createSpec";
      +import { StyleSchemaFromSpecs, StyleSpecs } from "../../../schema/styles/types";
      +
      +const small = createStyleSpec(
      +  {
      +    type: "small",
      +    propSchema: "boolean",
      +  },
      +  {
      +    render: () => {
      +      const dom = document.createElement("small");
      +      return {
      +        dom,
      +        contentDOM: dom,
      +      };
      +    },
      +  }
      +);
      +
      +const fontSize = createStyleSpec(
      +  {
      +    type: "fontSize",
      +    propSchema: "string",
      +  },
      +  {
      +    render: (value) => {
      +      const dom = document.createElement("span");
      +      dom.setAttribute("style", "font-size: " + value);
      +      return {
      +        dom,
      +        contentDOM: dom,
      +      };
      +    },
      +  }
      +);
      +
      +const customStyles = {
      +  ...defaultStyleSpecs,
      +  small,
      +  fontSize,
      +} satisfies StyleSpecs;
      +
      +export const customStylesTestCases: EditorTestCases<
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  StyleSchemaFromSpecs
      +> = {
      +  name: "custom style schema",
      +  createEditor: () => {
      +    return BlockNoteEditor.create({
      +      uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      +      styleSpecs: customStyles,
      +    });
      +  },
      +  documents: [
      +    {
      +      name: "small/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "This is a small text",
      +              styles: {
      +                small: true,
      +              },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "fontSize/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "This is text with a custom fontSize",
      +              styles: {
      +                fontSize: "18px",
      +              },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/packages/core/src/api/testUtil/cases/defaultSchema.ts b/packages/core/src/api/testUtil/cases/defaultSchema.ts
      new file mode 100644
      index 0000000000..0b9f1f2f5c
      --- /dev/null
      +++ b/packages/core/src/api/testUtil/cases/defaultSchema.ts
      @@ -0,0 +1,399 @@
      +import { EditorTestCases } from "../index";
      +
      +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
      +import {
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema,
      +} from "../../../blocks/defaultBlocks";
      +import { uploadToTmpFilesDotOrg_DEV_ONLY } from "../../../blocks/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY";
      +
      +export const defaultSchemaTestCases: EditorTestCases<
      +  DefaultBlockSchema,
      +  DefaultInlineContentSchema,
      +  DefaultStyleSchema
      +> = {
      +  name: "default schema",
      +  createEditor: () => {
      +    return BlockNoteEditor.create({
      +      uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY,
      +    });
      +  },
      +  documents: [
      +    {
      +      name: "paragraph/empty",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +        },
      +      ],
      +    },
      +    {
      +      name: "paragraph/basic",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: "Paragraph",
      +        },
      +      ],
      +    },
      +    {
      +      name: "paragraph/styled",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          props: {
      +            textAlignment: "center",
      +            textColor: "orange",
      +            backgroundColor: "pink",
      +          },
      +          content: [
      +            {
      +              type: "text",
      +              styles: {},
      +              text: "Plain ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                textColor: "red",
      +              },
      +              text: "Red Text ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                backgroundColor: "blue",
      +              },
      +              text: "Blue Background ",
      +            },
      +            {
      +              type: "text",
      +              styles: {
      +                textColor: "red",
      +                backgroundColor: "blue",
      +              },
      +              text: "Mixed Colors",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "paragraph/nested",
      +      blocks: [
      +        {
      +          type: "paragraph",
      +          content: "Paragraph",
      +          children: [
      +            {
      +              type: "paragraph",
      +              content: "Nested Paragraph 1",
      +            },
      +            {
      +              type: "paragraph",
      +              content: "Nested Paragraph 2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "image/button",
      +      blocks: [
      +        {
      +          type: "image",
      +        },
      +      ],
      +    },
      +    {
      +      name: "image/basic",
      +      blocks: [
      +        {
      +          type: "image",
      +          props: {
      +            url: "exampleURL",
      +            caption: "Caption",
      +            width: 256,
      +          },
      +        },
      +      ],
      +    },
      +    {
      +      name: "image/nested",
      +      blocks: [
      +        {
      +          type: "image",
      +          props: {
      +            url: "exampleURL",
      +            caption: "Caption",
      +            width: 256,
      +          },
      +          children: [
      +            {
      +              type: "image",
      +              props: {
      +                url: "exampleURL",
      +                caption: "Caption",
      +                width: 256,
      +              },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "link/basic",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Website",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "link/styled",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: [
      +                {
      +                  type: "text",
      +                  text: "Web",
      +                  styles: {
      +                    bold: true,
      +                  },
      +                },
      +                {
      +                  type: "text",
      +                  text: "site",
      +                  styles: {},
      +                },
      +              ],
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "link/adjacent",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Website",
      +            },
      +            {
      +              type: "link",
      +              href: "https://www.website2.com",
      +              content: "Website2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/basic",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\nText2",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/multiple",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\nText2\nText3",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/start",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "\nText1",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/end",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\n",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/only",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "\n",
      +              styles: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/styles",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "text",
      +              text: "Text1\n",
      +              styles: {},
      +            },
      +            {
      +              type: "text",
      +              text: "Text2",
      +              styles: { bold: true },
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/link",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Link1\nLink1",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "hardbreak/between-links",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "paragraph",
      +          content: [
      +            {
      +              type: "link",
      +              href: "https://www.website.com",
      +              content: "Link1\n",
      +            },
      +            {
      +              type: "link",
      +              href: "https://www.website2.com",
      +              content: "Link2",
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +    {
      +      name: "complex/misc",
      +      blocks: [
      +        {
      +          // id: UniqueID.options.generateID(),
      +          type: "heading",
      +          props: {
      +            backgroundColor: "blue",
      +            textColor: "yellow",
      +            textAlignment: "right",
      +            level: 2,
      +          },
      +          content: [
      +            {
      +              type: "text",
      +              text: "Heading ",
      +              styles: {
      +                bold: true,
      +                underline: true,
      +              },
      +            },
      +            {
      +              type: "text",
      +              text: "2",
      +              styles: {
      +                italic: true,
      +                strike: true,
      +              },
      +            },
      +          ],
      +          children: [
      +            {
      +              // id: UniqueID.options.generateID(),
      +              type: "paragraph",
      +              props: {
      +                backgroundColor: "red",
      +              },
      +              content: "Paragraph",
      +              children: [],
      +            },
      +            {
      +              // id: UniqueID.options.generateID(),
      +              type: "bulletListItem",
      +              props: {},
      +            },
      +          ],
      +        },
      +      ],
      +    },
      +  ],
      +};
      diff --git a/packages/core/src/api/testUtil/index.ts b/packages/core/src/api/testUtil/index.ts
      new file mode 100644
      index 0000000000..d3269f3e86
      --- /dev/null
      +++ b/packages/core/src/api/testUtil/index.ts
      @@ -0,0 +1,17 @@
      +import { BlockNoteEditor } from "../../editor/BlockNoteEditor";
      +import { BlockSchema, PartialBlock } from "../../schema/blocks/types";
      +import { InlineContentSchema } from "../../schema/inlineContent/types";
      +import { StyleSchema } from "../../schema/styles/types";
      +
      +export type EditorTestCases<
      +  B extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +> = {
      +  name: string;
      +  createEditor: () => BlockNoteEditor;
      +  documents: Array<{
      +    name: string;
      +    blocks: PartialBlock[];
      +  }>;
      +};
      diff --git a/packages/core/src/api/testUtil/partialBlockTestUtil.ts b/packages/core/src/api/testUtil/partialBlockTestUtil.ts
      new file mode 100644
      index 0000000000..6d91a204f3
      --- /dev/null
      +++ b/packages/core/src/api/testUtil/partialBlockTestUtil.ts
      @@ -0,0 +1,127 @@
      +import UniqueID from "../../extensions/UniqueID/UniqueID";
      +import {
      +  Block,
      +  BlockSchema,
      +  PartialBlock,
      +  TableContent,
      +} from "../../schema/blocks/types";
      +import {
      +  InlineContent,
      +  InlineContentSchema,
      +  PartialInlineContent,
      +  StyledText,
      +  isPartialLinkInlineContent,
      +  isStyledTextInlineContent,
      +} from "../../schema/inlineContent/types";
      +import { StyleSchema } from "../../schema/styles/types";
      +
      +function textShorthandToStyledText(
      +  content: string | StyledText[] = ""
      +): StyledText[] {
      +  if (typeof content === "string") {
      +    return [
      +      {
      +        type: "text",
      +        text: content,
      +        styles: {},
      +      },
      +    ];
      +  }
      +  return content;
      +}
      +
      +function partialContentToInlineContent(
      +  content: PartialInlineContent | TableContent | undefined
      +): InlineContent[] | TableContent | undefined {
      +  if (typeof content === "string") {
      +    return textShorthandToStyledText(content);
      +  }
      +
      +  if (Array.isArray(content)) {
      +    return content.flatMap((partialContent) => {
      +      if (typeof partialContent === "string") {
      +        return textShorthandToStyledText(partialContent);
      +      } else if (isPartialLinkInlineContent(partialContent)) {
      +        return {
      +          ...partialContent,
      +          content: textShorthandToStyledText(partialContent.content),
      +        };
      +      } else if (isStyledTextInlineContent(partialContent)) {
      +        return partialContent;
      +      } else {
      +        // custom inline content
      +
      +        return {
      +          props: {},
      +          ...partialContent,
      +          content: partialContentToInlineContent(partialContent.content),
      +        } as any;
      +      }
      +    });
      +  }
      +
      +  return content;
      +}
      +
      +export function partialBlocksToBlocksForTesting<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  schema: BSchema,
      +  partialBlocks: Array>
      +): Array> {
      +  return partialBlocks.map((partialBlock) =>
      +    partialBlockToBlockForTesting(schema, partialBlock)
      +  );
      +}
      +
      +export function partialBlockToBlockForTesting<
      +  BSchema extends BlockSchema,
      +  I extends InlineContentSchema,
      +  S extends StyleSchema
      +>(
      +  schema: BSchema,
      +  partialBlock: PartialBlock
      +): Block {
      +  const withDefaults: Block = {
      +    id: "",
      +    type: partialBlock.type!,
      +    props: {} as any,
      +    content:
      +      schema[partialBlock.type!].content === "inline" ? [] : (undefined as any),
      +    children: [] as any,
      +    ...partialBlock,
      +  };
      +
      +  Object.entries(schema[partialBlock.type!].propSchema).forEach(
      +    ([propKey, propValue]) => {
      +      if (withDefaults.props[propKey] === undefined) {
      +        (withDefaults.props as any)[propKey] = propValue.default;
      +      }
      +    }
      +  );
      +
      +  return {
      +    ...withDefaults,
      +    content: partialContentToInlineContent(withDefaults.content),
      +    children: withDefaults.children.map((c) => {
      +      return partialBlockToBlockForTesting(schema, c);
      +    }),
      +  } as any;
      +}
      +
      +export function addIdsToBlock(block: PartialBlock) {
      +  if (!block.id) {
      +    block.id = UniqueID.options.generateID();
      +  }
      +  if (block.children) {
      +    addIdsToBlocks(block.children);
      +  }
      +}
      +
      +export function addIdsToBlocks(blocks: PartialBlock[]) {
      +  for (const block of blocks) {
      +    addIdsToBlock(block);
      +  }
      +}
      diff --git a/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts b/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts
      new file mode 100644
      index 0000000000..9b38c720f7
      --- /dev/null
      +++ b/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts
      @@ -0,0 +1,136 @@
      +import { InputRule } from "@tiptap/core";
      +import {
      +  PropSchema,
      +  createBlockSpecFromStronglyTypedTiptapNode,
      +  createStronglyTypedTiptapNode,
      +} from "../../schema";
      +import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers";
      +import { defaultProps } from "../defaultProps";
      +
      +export const headingPropSchema = {
      +  ...defaultProps,
      +  level: { default: 1, values: [1, 2, 3] as const },
      +} satisfies PropSchema;
      +
      +const HeadingBlockContent = createStronglyTypedTiptapNode({
      +  name: "heading",
      +  content: "inline*",
      +  group: "blockContent",
      +  addAttributes() {
      +    return {
      +      level: {
      +        default: 1,
      +        // instead of "level" attributes, use "data-level"
      +        parseHTML: (element) => {
      +          const attr = element.getAttribute("data-level")!;
      +          const parsed = parseInt(attr);
      +          if (isFinite(parsed)) {
      +            return parsed;
      +          }
      +          return undefined;
      +        },
      +        renderHTML: (attributes) => {
      +          return {
      +            "data-level": (attributes.level as number).toString(),
      +          };
      +        },
      +      },
      +    };
      +  },
      +
      +  addInputRules() {
      +    return [
      +      ...[1, 2, 3].map((level) => {
      +        // Creates a heading of appropriate level when starting with "#", "##", or "###".
      +        return new InputRule({
      +          find: new RegExp(`^(#{${level}})\\s$`),
      +          handler: ({ state, chain, range }) => {
      +            chain()
      +              .BNUpdateBlock(state.selection.from, {
      +                type: "heading",
      +                props: {
      +                  level: level as any,
      +                },
      +              })
      +              // Removes the "#" character(s) used to set the heading.
      +              .deleteRange({ from: range.from, to: range.to });
      +          },
      +        });
      +      }),
      +    ];
      +  },
      +
      +  addKeyboardShortcuts() {
      +    return {
      +      "Mod-Alt-1": () =>
      +        this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, {
      +          type: "heading",
      +          props: {
      +            level: 1 as any,
      +          },
      +        }),
      +      "Mod-Alt-2": () =>
      +        this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, {
      +          type: "heading",
      +          props: {
      +            level: 2 as any,
      +          },
      +        }),
      +      "Mod-Alt-3": () =>
      +        this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, {
      +          type: "heading",
      +          props: {
      +            level: 3 as any,
      +          },
      +        }),
      +    };
      +  },
      +  parseHTML() {
      +    return [
      +      {
      +        tag: "div[data-content-type=" + this.name + "]",
      +        getAttrs: (element) => {
      +          if (typeof element === "string") {
      +            return false;
      +          }
      +
      +          return {
      +            level: element.getAttribute("data-level"),
      +          };
      +        },
      +      },
      +      {
      +        tag: "h1",
      +        attrs: { level: 1 },
      +        node: "heading",
      +      },
      +      {
      +        tag: "h2",
      +        attrs: { level: 2 },
      +        node: "heading",
      +      },
      +      {
      +        tag: "h3",
      +        attrs: { level: 3 },
      +        node: "heading",
      +      },
      +    ];
      +  },
      +
      +  renderHTML({ node, HTMLAttributes }) {
      +    return createDefaultBlockDOMOutputSpec(
      +      this.name,
      +      `h${node.attrs.level}`,
      +      {
      +        ...(this.options.domAttributes?.blockContent || {}),
      +        ...HTMLAttributes,
      +      },
      +      this.options.domAttributes?.inlineContent || {}
      +    );
      +  },
      +});
      +
      +export const Heading = createBlockSpecFromStronglyTypedTiptapNode(
      +  HeadingBlockContent,
      +  headingPropSchema
      +);
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts
      similarity index 78%
      rename from packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts
      rename to packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts
      index 95b5fd3173..deecfe6a76 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/ImageBlockContent.ts
      +++ b/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts
      @@ -1,9 +1,16 @@
      -import { createBlockSpec } from "../../../api/block";
      -import { defaultProps } from "../../../api/defaultProps";
      -import { BlockSpec, PropSchema, SpecificBlock } from "../../../api/blockTypes";
      -import { BlockNoteEditor } from "../../../../../BlockNoteEditor";
      -import { imageToolbarPluginKey } from "../../../../ImageToolbar/ImageToolbarPlugin";
      -import styles from "../../Block.module.css";
      +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor";
      +import { imageToolbarPluginKey } from "../../extensions/ImageToolbar/ImageToolbarPlugin";
      +
      +import {
      +  BlockFromConfig,
      +  BlockSchemaWithBlock,
      +  CustomBlockConfig,
      +  InlineContentSchema,
      +  PropSchema,
      +  StyleSchema,
      +  createBlockSpec,
      +} from "../../schema";
      +import { defaultProps } from "../defaultProps";
       
       export const imagePropSchema = {
         textAlignment: defaultProps.textAlignment,
      @@ -41,50 +48,51 @@ const textAlignmentToAlignItems = (
       // Min image width in px.
       const minWidth = 64;
       
      -const renderImage = (
      -  block: SpecificBlock<
      -    { image: BlockSpec<"image", typeof imagePropSchema, false> },
      -    "image"
      -  >,
      -  editor: BlockNoteEditor<{
      -    image: BlockSpec<"image", typeof imagePropSchema, false>;
      -  }>
      +const blockConfig = {
      +  type: "image" as const,
      +  propSchema: imagePropSchema,
      +  content: "none",
      +} satisfies CustomBlockConfig;
      +
      +export const renderImage = (
      +  block: BlockFromConfig,
      +  editor: BlockNoteEditor>
       ) => {
         // Wrapper element to set the image alignment, contains both image/image
         // upload dashboard and caption.
         const wrapper = document.createElement("div");
      -  wrapper.className = styles.wrapper;
      +  wrapper.className = "bn-image-block-content-wrapper";
         wrapper.style.alignItems = textAlignmentToAlignItems(
           block.props.textAlignment
         );
       
         // Button element that acts as a placeholder for images with no src.
         const addImageButton = document.createElement("div");
      -  addImageButton.className = styles.addImageButton;
      +  addImageButton.className = "bn-add-image-button";
         addImageButton.style.display = block.props.url === "" ? "" : "none";
       
         // Icon for the add image button.
         const addImageButtonIcon = document.createElement("div");
      -  addImageButtonIcon.className = styles.addImageButtonIcon;
      +  addImageButtonIcon.className = "bn-add-image-button-icon";
       
         // Text for the add image button.
         const addImageButtonText = document.createElement("p");
      -  addImageButtonText.className = styles.addImageButtonText;
      +  addImageButtonText.className = "bn-add-image-button-text";
         addImageButtonText.innerText = "Add Image";
       
         // Wrapper element for the image, resize handles and caption.
         const imageAndCaptionWrapper = document.createElement("div");
      -  imageAndCaptionWrapper.className = styles.imageAndCaptionWrapper;
      +  imageAndCaptionWrapper.className = "bn-image-and-caption-wrapper";
         imageAndCaptionWrapper.style.display = block.props.url !== "" ? "" : "none";
       
         // Wrapper element for the image and resize handles.
         const imageWrapper = document.createElement("div");
      -  imageWrapper.className = styles.imageWrapper;
      +  imageWrapper.className = "bn-image-wrapper";
         imageWrapper.style.display = block.props.url !== "" ? "" : "none";
       
         // Image element.
         const image = document.createElement("img");
      -  image.className = styles.image;
      +  image.className = "bn-image";
         image.src = block.props.url;
         image.alt = "placeholder";
         image.contentEditable = "false";
      @@ -96,15 +104,15 @@ const renderImage = (
       
         // Resize handle elements.
         const leftResizeHandle = document.createElement("div");
      -  leftResizeHandle.className = styles.resizeHandle;
      +  leftResizeHandle.className = "bn-image-resize-handle";
         leftResizeHandle.style.left = "4px";
         const rightResizeHandle = document.createElement("div");
      -  rightResizeHandle.className = styles.resizeHandle;
      +  rightResizeHandle.className = "bn-image-resize-handle";
         rightResizeHandle.style.right = "4px";
       
         // Caption element.
         const caption = document.createElement("p");
      -  caption.className = styles.caption;
      +  caption.className = "bn-image-caption";
         caption.innerText = block.props.caption;
         caption.style.padding = block.props.caption ? "4px" : "";
       
      @@ -207,7 +215,7 @@ const renderImage = (
             type: "image",
             props: {
               // Removes "px" from the end of the width string and converts to float.
      -        width: parseFloat(image.style.width.slice(0, -2)),
      +        width: parseFloat(image.style.width.slice(0, -2)) as any,
             },
           });
         };
      @@ -327,9 +335,57 @@ const renderImage = (
         };
       };
       
      -export const Image = createBlockSpec({
      -  type: "image",
      -  propSchema: imagePropSchema,
      -  containsInlineContent: false,
      -  render: renderImage,
      -});
      +export const Image = createBlockSpec(
      +  {
      +    type: "image" as const,
      +    propSchema: imagePropSchema,
      +    content: "none",
      +  },
      +  {
      +    render: renderImage,
      +    toExternalHTML: (block) => {
      +      if (block.props.url === "") {
      +        const div = document.createElement("p");
      +        div.innerHTML = "Add Image";
      +
      +        return {
      +          dom: div,
      +        };
      +      }
      +
      +      const figure = document.createElement("figure");
      +
      +      const img = document.createElement("img");
      +      img.src = block.props.url;
      +      figure.appendChild(img);
      +
      +      if (block.props.caption !== "") {
      +        const figcaption = document.createElement("figcaption");
      +        figcaption.innerHTML = block.props.caption;
      +        figure.appendChild(figcaption);
      +      }
      +
      +      return {
      +        dom: figure,
      +      };
      +    },
      +    parse: (element: HTMLElement) => {
      +      if (element.tagName === "FIGURE") {
      +        const img = element.querySelector("img");
      +        const caption = element.querySelector("figcaption");
      +        return {
      +          url: img?.getAttribute("src") || "",
      +          caption:
      +            caption?.textContent || img?.getAttribute("alt") || undefined,
      +        };
      +      } else if (element.tagName === "IMG") {
      +        return {
      +          url: element.getAttribute("src") || "",
      +          caption: element.getAttribute("alt") || undefined,
      +        };
      +      }
      +
      +      return undefined;
      +    },
      +  }
      +);
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.ts b/packages/core/src/blocks/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.ts
      similarity index 100%
      rename from packages/core/src/extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.ts
      rename to packages/core/src/blocks/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.ts
      diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts
      similarity index 54%
      rename from packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts
      rename to packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts
      index 04c97338fb..c6daafbcce 100644
      --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts
      +++ b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts
      @@ -1,19 +1,21 @@
      -import { InputRule, mergeAttributes } from "@tiptap/core";
      -import { defaultProps } from "../../../../api/defaultProps";
      -import { createTipTapBlock } from "../../../../api/block";
      -import { BlockSpec, PropSchema } from "../../../../api/blockTypes";
      -import { mergeCSSClasses } from "../../../../../../shared/utils";
      +import { InputRule } from "@tiptap/core";
      +import {
      +  PropSchema,
      +  createBlockSpecFromStronglyTypedTiptapNode,
      +  createStronglyTypedTiptapNode,
      +} from "../../../schema";
      +import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers";
      +import { defaultProps } from "../../defaultProps";
       import { handleEnter } from "../ListItemKeyboardShortcuts";
      -import styles from "../../../Block.module.css";
       
       export const bulletListItemPropSchema = {
         ...defaultProps,
       } satisfies PropSchema;
       
      -const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({
      +const BulletListItemBlockContent = createStronglyTypedTiptapNode({
         name: "bulletListItem",
         content: "inline*",
      -
      +  group: "blockContent",
         addInputRules() {
           return [
             // Creates an unordered list when starting with "-", "+", or "*".
      @@ -36,13 +38,7 @@ const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({
           return {
             Enter: () => handleEnter(this.editor),
             "Mod-Shift-7": () =>
      -        this.editor.commands.BNUpdateBlock<{
      -          bulletListItem: BlockSpec<
      -            "bulletListItem",
      -            typeof bulletListItemPropSchema,
      -            true
      -          >;
      -        }>(this.editor.state.selection.anchor, {
      +        this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, {
                 type: "bulletListItem",
                 props: {},
               }),
      @@ -52,6 +48,9 @@ const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({
         parseHTML() {
           return [
             // Case for regular HTML list structure.
      +      {
      +        tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this
      +      },
             {
               tag: "li",
               getAttrs: (element) => {
      @@ -65,7 +64,10 @@ const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({
                   return false;
                 }
       
      -          if (parent.tagName === "UL") {
      +          if (
      +            parent.tagName === "UL" ||
      +            (parent.tagName === "DIV" && parent.parentElement!.tagName === "UL")
      +          ) {
                   return {};
                 }
       
      @@ -100,37 +102,22 @@ const BulletListItemBlockContent = createTipTapBlock<"bulletListItem", true>({
         },
       
         renderHTML({ HTMLAttributes }) {
      -    const blockContentDOMAttributes =
      -      this.options.domAttributes?.blockContent || {};
      -    const inlineContentDOMAttributes =
      -      this.options.domAttributes?.inlineContent || {};
      -
      -    return [
      -      "div",
      -      mergeAttributes(HTMLAttributes, {
      -        ...blockContentDOMAttributes,
      -        class: mergeCSSClasses(
      -          styles.blockContent,
      -          blockContentDOMAttributes.class
      -        ),
      -        "data-content-type": this.name,
      -      }),
      -      [
      -        "p",
      -        {
      -          ...inlineContentDOMAttributes,
      -          class: mergeCSSClasses(
      -            styles.inlineContent,
      -            inlineContentDOMAttributes.class
      -          ),
      -        },
      -        0,
      -      ],
      -    ];
      +    return createDefaultBlockDOMOutputSpec(
      +      this.name,
      +      // We use a 

      tag, because for

    • tags we'd need a
        element to put + // them in to be semantically correct, which we can't have due to the + // schema. + "p", + { + ...(this.options.domAttributes?.blockContent || {}), + ...HTMLAttributes, + }, + this.options.domAttributes?.inlineContent || {} + ); }, }); -export const BulletListItem = { - node: BulletListItemBlockContent, - propSchema: bulletListItemPropSchema, -} satisfies BlockSpec<"bulletListItem", typeof bulletListItemPropSchema, true>; +export const BulletListItem = createBlockSpecFromStronglyTypedTiptapNode( + BulletListItemBlockContent, + bulletListItemPropSchema +); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts similarity index 94% rename from packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/ListItemKeyboardShortcuts.ts rename to packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts index 01f7474eab..b51c6b5294 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/ListItemKeyboardShortcuts.ts +++ b/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts @@ -1,5 +1,5 @@ import { Editor } from "@tiptap/core"; -import { getBlockInfoFromPos } from "../../../helpers/getBlockInfoFromPos"; +import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos"; export const handleEnter = (editor: Editor) => { const { node, contentType } = getBlockInfoFromPos( diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts similarity index 97% rename from packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts rename to packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts index 95c7dd4e9e..5ee327fbbc 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts +++ b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts @@ -1,5 +1,5 @@ import { Plugin, PluginKey } from "prosemirror-state"; -import { getBlockInfoFromPos } from "../../../../helpers/getBlockInfoFromPos"; +import { getBlockInfoFromPos } from "../../../api/getBlockInfoFromPos"; // ProseMirror Plugin which automatically assigns indices to ordered list items per nesting level. const PLUGIN_KEY = new PluginKey(`numbered-list-indexing`); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts similarity index 57% rename from packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts rename to packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts index 5972e215c0..7e8752a27e 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +++ b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts @@ -1,23 +1,22 @@ -import { InputRule, mergeAttributes } from "@tiptap/core"; -import { defaultProps } from "../../../../api/defaultProps"; -import { createTipTapBlock } from "../../../../api/block"; -import { BlockSpec, PropSchema } from "../../../../api/blockTypes"; -import { mergeCSSClasses } from "../../../../../../shared/utils"; +import { InputRule } from "@tiptap/core"; +import { + PropSchema, + createBlockSpecFromStronglyTypedTiptapNode, + createStronglyTypedTiptapNode, +} from "../../../schema"; +import { createDefaultBlockDOMOutputSpec } from "../../defaultBlockHelpers"; +import { defaultProps } from "../../defaultProps"; import { handleEnter } from "../ListItemKeyboardShortcuts"; import { NumberedListIndexingPlugin } from "./NumberedListIndexingPlugin"; -import styles from "../../../Block.module.css"; export const numberedListItemPropSchema = { ...defaultProps, } satisfies PropSchema; -const NumberedListItemBlockContent = createTipTapBlock< - "numberedListItem", - true ->({ +const NumberedListItemBlockContent = createStronglyTypedTiptapNode({ name: "numberedListItem", content: "inline*", - + group: "blockContent", addAttributes() { return { index: { @@ -54,13 +53,7 @@ const NumberedListItemBlockContent = createTipTapBlock< return { Enter: () => handleEnter(this.editor), "Mod-Shift-8": () => - this.editor.commands.BNUpdateBlock<{ - numberedListItem: BlockSpec< - "numberedListItem", - typeof numberedListItemPropSchema, - true - >; - }>(this.editor.state.selection.anchor, { + this.editor.commands.BNUpdateBlock(this.editor.state.selection.anchor, { type: "numberedListItem", props: {}, }), @@ -73,6 +66,9 @@ const NumberedListItemBlockContent = createTipTapBlock< parseHTML() { return [ + { + tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this + }, // Case for regular HTML list structure. // (e.g.: when pasting from other apps) { @@ -88,7 +84,10 @@ const NumberedListItemBlockContent = createTipTapBlock< return false; } - if (parent.tagName === "OL") { + if ( + parent.tagName === "OL" || + (parent.tagName === "DIV" && parent.parentElement!.tagName === "OL") + ) { return {}; } @@ -124,43 +123,22 @@ const NumberedListItemBlockContent = createTipTapBlock< }, renderHTML({ HTMLAttributes }) { - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; - const inlineContentDOMAttributes = - this.options.domAttributes?.inlineContent || {}; - - return [ - "div", - mergeAttributes(HTMLAttributes, { - ...blockContentDOMAttributes, - class: mergeCSSClasses( - styles.blockContent, - blockContentDOMAttributes.class - ), - "data-content-type": this.name, - }), - // we use a

        tag, because for

      • tags we'd need to add a
          parent for around siblings to be semantically correct, - // which would be quite cumbersome - [ - "p", - { - ...inlineContentDOMAttributes, - class: mergeCSSClasses( - styles.inlineContent, - inlineContentDOMAttributes.class - ), - }, - 0, - ], - ]; + return createDefaultBlockDOMOutputSpec( + this.name, + // We use a

          tag, because for

        • tags we'd need an
            element to + // put them in to be semantically correct, which we can't have due to the + // schema. + "p", + { + ...(this.options.domAttributes?.blockContent || {}), + ...HTMLAttributes, + }, + this.options.domAttributes?.inlineContent || {} + ); }, }); -export const NumberedListItem = { - node: NumberedListItemBlockContent, - propSchema: numberedListItemPropSchema, -} satisfies BlockSpec< - "numberedListItem", - typeof numberedListItemPropSchema, - true ->; +export const NumberedListItem = createBlockSpecFromStronglyTypedTiptapNode( + NumberedListItemBlockContent, + numberedListItemPropSchema +); diff --git a/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts b/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts new file mode 100644 index 0000000000..fcb532a8af --- /dev/null +++ b/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts @@ -0,0 +1,43 @@ +import { + createBlockSpecFromStronglyTypedTiptapNode, + createStronglyTypedTiptapNode, +} from "../../schema"; +import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers"; +import { defaultProps } from "../defaultProps"; + +export const paragraphPropSchema = { + ...defaultProps, +}; + +export const ParagraphBlockContent = createStronglyTypedTiptapNode({ + name: "paragraph", + content: "inline*", + group: "blockContent", + parseHTML() { + return [ + { tag: "div[data-content-type=" + this.name + "]" }, + { + tag: "p", + priority: 200, + node: "paragraph", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return createDefaultBlockDOMOutputSpec( + this.name, + "p", + { + ...(this.options.domAttributes?.blockContent || {}), + ...HTMLAttributes, + }, + this.options.domAttributes?.inlineContent || {} + ); + }, +}); + +export const Paragraph = createBlockSpecFromStronglyTypedTiptapNode( + ParagraphBlockContent, + paragraphPropSchema +); diff --git a/packages/core/src/blocks/README.md b/packages/core/src/blocks/README.md new file mode 100644 index 0000000000..b5a8581591 --- /dev/null +++ b/packages/core/src/blocks/README.md @@ -0,0 +1,3 @@ +### @blocknote/core/src/blocks + +The default built-in blocks that ship with BlockNote \ No newline at end of file diff --git a/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts b/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts new file mode 100644 index 0000000000..d2bb8c0217 --- /dev/null +++ b/packages/core/src/blocks/TableBlockContent/TableBlockContent.ts @@ -0,0 +1,74 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { TableCell } from "@tiptap/extension-table-cell"; +import { TableHeader } from "@tiptap/extension-table-header"; +import { TableRow } from "@tiptap/extension-table-row"; +import { + createBlockSpecFromStronglyTypedTiptapNode, + createStronglyTypedTiptapNode, +} from "../../schema"; +import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers"; +import { defaultProps } from "../defaultProps"; +import { TableExtension } from "./TableExtension"; + +export const tablePropSchema = { + ...defaultProps, +}; + +export const TableBlockContent = createStronglyTypedTiptapNode({ + name: "table", + content: "tableRow+", + group: "blockContent", + tableRole: "table", + + isolating: true, + + parseHTML() { + return [{ tag: "table" }]; + }, + + renderHTML({ HTMLAttributes }) { + return createDefaultBlockDOMOutputSpec( + this.name, + "table", + { + ...(this.options.domAttributes?.blockContent || {}), + ...HTMLAttributes, + }, + this.options.domAttributes?.inlineContent || {} + ); + }, +}); + +const TableParagraph = Node.create({ + name: "tableParagraph", + group: "tableContent", + content: "inline*", + + parseHTML() { + return [{ tag: "p" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "p", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, +}); + +export const Table = createBlockSpecFromStronglyTypedTiptapNode( + TableBlockContent, + tablePropSchema, + [ + TableExtension, + TableParagraph, + TableHeader.extend({ + content: "tableContent", + }), + TableCell.extend({ + content: "tableContent", + }), + TableRow, + ] +); diff --git a/packages/core/src/blocks/TableBlockContent/TableExtension.ts b/packages/core/src/blocks/TableBlockContent/TableExtension.ts new file mode 100644 index 0000000000..fb0147d7ae --- /dev/null +++ b/packages/core/src/blocks/TableBlockContent/TableExtension.ts @@ -0,0 +1,63 @@ +import { callOrReturn, Extension, getExtensionField } from "@tiptap/core"; +import { columnResizing, tableEditing } from "prosemirror-tables"; + +export const TableExtension = Extension.create({ + name: "BlockNoteTableExtension", + + addProseMirrorPlugins: () => { + return [ + columnResizing({ + cellMinWidth: 100, + }), + tableEditing(), + ]; + }, + + addKeyboardShortcuts() { + return { + // Makes enter create a new line within the cell. + Enter: () => { + if ( + this.editor.state.selection.empty && + this.editor.state.selection.$head.parent.type.name === + "tableParagraph" + ) { + this.editor.commands.setHardBreak(); + + return true; + } + + return false; + }, + // Ensures that backspace won't delete the table if the text cursor is at + // the start of a cell and the selection is empty. + Backspace: () => { + const selection = this.editor.state.selection; + const selectionIsEmpty = selection.empty; + const selectionIsAtStartOfNode = selection.$head.parentOffset === 0; + const selectionIsInTableParagraphNode = + selection.$head.node().type.name === "tableParagraph"; + + return ( + selectionIsEmpty && + selectionIsAtStartOfNode && + selectionIsInTableParagraphNode + ); + }, + }; + }, + + extendNodeSchema(extension) { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + }; + + return { + tableRole: callOrReturn( + getExtensionField(extension, "tableRole", context) + ), + }; + }, +}); diff --git a/packages/core/src/blocks/defaultBlockHelpers.ts b/packages/core/src/blocks/defaultBlockHelpers.ts new file mode 100644 index 0000000000..710ca57aa0 --- /dev/null +++ b/packages/core/src/blocks/defaultBlockHelpers.ts @@ -0,0 +1,95 @@ +import { blockToNode } from "../api/nodeConversions/nodeConversions"; +import type { BlockNoteEditor } from "../editor/BlockNoteEditor"; +import type { + Block, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../schema"; +import { mergeCSSClasses } from "../util/browser"; + +// Function that creates a ProseMirror `DOMOutputSpec` for a default block. +// Since all default blocks have the same structure (`blockContent` div with a +// `inlineContent` element inside), this function only needs the block's name +// for the `data-content-type` attribute of the `blockContent` element and the +// HTML tag of the `inlineContent` element, as well as any HTML attributes to +// add to those. +export function createDefaultBlockDOMOutputSpec( + blockName: string, + htmlTag: string, + blockContentHTMLAttributes: Record, + inlineContentHTMLAttributes: Record +) { + const blockContent = document.createElement("div"); + blockContent.className = mergeCSSClasses( + "bn-block-content", + blockContentHTMLAttributes.class + ); + blockContent.setAttribute("data-content-type", blockName); + for (const [attribute, value] of Object.entries(blockContentHTMLAttributes)) { + if (attribute !== "class") { + blockContent.setAttribute(attribute, value); + } + } + + const inlineContent = document.createElement(htmlTag); + inlineContent.className = mergeCSSClasses( + "bn-inline-content", + inlineContentHTMLAttributes.class + ); + for (const [attribute, value] of Object.entries( + inlineContentHTMLAttributes + )) { + if (attribute !== "class") { + inlineContent.setAttribute(attribute, value); + } + } + + blockContent.appendChild(inlineContent); + + return { + dom: blockContent, + contentDOM: inlineContent, + }; +} + +// Function used to convert default blocks to HTML. It uses the corresponding +// node's `renderHTML` method to do the conversion by using a default +// `DOMSerializer`. +export const defaultBlockToHTML = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + block: Block, + editor: BlockNoteEditor +): { + dom: HTMLElement; + contentDOM?: HTMLElement; +} => { + const node = blockToNode( + block, + editor._tiptapEditor.schema, + editor.styleSchema + ).firstChild!; + const toDOM = editor._tiptapEditor.schema.nodes[node.type.name].spec.toDOM; + + if (toDOM === undefined) { + throw new Error( + "This block has no default HTML serialization as its corresponding TipTap node doesn't implement `renderHTML`." + ); + } + + const renderSpec = toDOM(node); + + if (typeof renderSpec !== "object" || !("dom" in renderSpec)) { + throw new Error( + "Cannot use this block's default HTML serialization as its corresponding TipTap node's `renderHTML` function does not return an object with the `dom` property." + ); + } + + return renderSpec as { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; +}; diff --git a/packages/core/src/blocks/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts new file mode 100644 index 0000000000..36ebc50f7d --- /dev/null +++ b/packages/core/src/blocks/defaultBlocks.ts @@ -0,0 +1,60 @@ +import Bold from "@tiptap/extension-bold"; +import Code from "@tiptap/extension-code"; +import Italic from "@tiptap/extension-italic"; +import Strike from "@tiptap/extension-strike"; +import Underline from "@tiptap/extension-underline"; +import { BackgroundColor } from "../extensions/BackgroundColor/BackgroundColorMark"; +import { TextColor } from "../extensions/TextColor/TextColorMark"; +import { + BlockSpecs, + InlineContentSpecs, + StyleSpecs, + createStyleSpecFromTipTapMark, + getBlockSchemaFromSpecs, + getInlineContentSchemaFromSpecs, + getStyleSchemaFromSpecs, +} from "../schema"; +import { Heading } from "./HeadingBlockContent/HeadingBlockContent"; +import { Image } from "./ImageBlockContent/ImageBlockContent"; +import { BulletListItem } from "./ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent"; +import { NumberedListItem } from "./ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent"; +import { Paragraph } from "./ParagraphBlockContent/ParagraphBlockContent"; +import { Table } from "./TableBlockContent/TableBlockContent"; + +export const defaultBlockSpecs = { + paragraph: Paragraph, + heading: Heading, + bulletListItem: BulletListItem, + numberedListItem: NumberedListItem, + image: Image, + table: Table, +} satisfies BlockSpecs; + +export const defaultBlockSchema = getBlockSchemaFromSpecs(defaultBlockSpecs); + +export type DefaultBlockSchema = typeof defaultBlockSchema; + +export const defaultStyleSpecs = { + bold: createStyleSpecFromTipTapMark(Bold, "boolean"), + italic: createStyleSpecFromTipTapMark(Italic, "boolean"), + underline: createStyleSpecFromTipTapMark(Underline, "boolean"), + strike: createStyleSpecFromTipTapMark(Strike, "boolean"), + code: createStyleSpecFromTipTapMark(Code, "boolean"), + textColor: TextColor, + backgroundColor: BackgroundColor, +} satisfies StyleSpecs; + +export const defaultStyleSchema = getStyleSchemaFromSpecs(defaultStyleSpecs); + +export type DefaultStyleSchema = typeof defaultStyleSchema; + +export const defaultInlineContentSpecs = { + text: { config: "text", implementation: {} as any }, + link: { config: "link", implementation: {} as any }, +} satisfies InlineContentSpecs; + +export const defaultInlineContentSchema = getInlineContentSchemaFromSpecs( + defaultInlineContentSpecs +); + +export type DefaultInlineContentSchema = typeof defaultInlineContentSchema; diff --git a/packages/core/src/blocks/defaultProps.ts b/packages/core/src/blocks/defaultProps.ts new file mode 100644 index 0000000000..da9add88dd --- /dev/null +++ b/packages/core/src/blocks/defaultProps.ts @@ -0,0 +1,24 @@ +import type { Props, PropSchema } from "../schema"; + +// TODO: this system should probably be moved / refactored. +// The dependency from schema on this file doesn't make sense + +export const defaultProps = { + backgroundColor: { + default: "default" as const, + }, + textColor: { + default: "default" as const, + }, + textAlignment: { + default: "left" as const, + values: ["left", "center", "right", "justify"] as const, + }, +} satisfies PropSchema; + +export type DefaultProps = Props; + +// Default props which are set on `blockContainer` nodes rather than +// `blockContent` nodes. Ensures that they are not redundantly added to +// a custom block's TipTap node attributes. +export const inheritedProps = ["backgroundColor", "textColor"]; diff --git a/packages/core/src/extensions/Blocks/nodes/Block.module.css b/packages/core/src/editor/Block.css similarity index 66% rename from packages/core/src/extensions/Blocks/nodes/Block.module.css rename to packages/core/src/editor/Block.css index f8626d053a..bc5ea21eb2 100644 --- a/packages/core/src/extensions/Blocks/nodes/Block.module.css +++ b/packages/core/src/editor/Block.css @@ -2,24 +2,24 @@ BASIC STYLES */ -.blockOuter { +.bn-block-outer { line-height: 1.5; transition: margin 0.2s; } /*Ensures blocks & block content spans editor width*/ -.block { +.bn-block { display: flex; flex-direction: column; } /*Ensures block content inside React node views spans editor width*/ -.reactNodeViewRenderer { +.bn-react-node-view-renderer { display: flex; flex-grow: 1; } -.blockContent { +.bn-block-content { padding: 3px 0; flex-grow: 1; transition: font-size 0.2s; @@ -30,7 +30,7 @@ BASIC STYLES */ } -.blockContent::before { +.bn-block-content::before { /* content: ""; */ transition: all 0.2s; /*margin: 0px;*/ @@ -40,15 +40,16 @@ BASIC STYLES NESTED BLOCKS */ -.blockGroup .blockGroup { +.bn-block-group .bn-block-group { margin-left: 1.5em; } -.blockGroup .blockGroup > .blockOuter { +.bn-block-group .bn-block-group > .bn-block-outer { position: relative; } -.blockGroup .blockGroup > .blockOuter:not([data-prev-depth-changed])::before { +.bn-block-group .bn-block-group + > .bn-block-outer:not([data-prev-depth-changed])::before { content: " "; display: inline; position: absolute; @@ -57,7 +58,8 @@ NESTED BLOCKS transition: all 0.2s 0.1s; } -.blockGroup .blockGroup > .blockOuter[data-prev-depth-change="-2"]::before { +.bn-block-group .bn-block-group + > .bn-block-outer[data-prev-depth-change="-2"]::before { height: 0; } @@ -95,11 +97,12 @@ NESTED BLOCKS --x: -5; } -.blockOuter[data-prev-depth-change] { +.bn-block-outer[data-prev-depth-change] { margin-left: calc(10px * var(--x)); } -.blockOuter[data-prev-depth-change] .blockOuter[data-prev-depth-change] { +.bn-block-outer[data-prev-depth-change] + .bn-block-outer[data-prev-depth-change] { margin-left: 0; } @@ -124,21 +127,21 @@ NESTED BLOCKS --prev-level: 1.3em; } -.blockOuter[data-prev-type="heading"] > .block > .blockContent { +.bn-block-outer[data-prev-type="heading"] > .bn-block > .bn-block-content { font-size: var(--prev-level); font-weight: bold; } -.blockOuter:not([data-prev-type]) - > .block - > .blockContent[data-content-type="heading"] { +.bn-block-outer:not([data-prev-type]) + > .bn-block + > .bn-block-content[data-content-type="heading"] { font-size: var(--level); font-weight: bold; } /* LISTS */ -.blockContent::before { +.bn-block-content::before { margin-right: 0; content: ""; } @@ -152,79 +155,81 @@ NESTED BLOCKS --prev-index: attr(data-prev-index); } -.blockOuter[data-prev-type="numberedListItem"]:not([data-prev-index="none"]) - > .block - > .blockContent::before { +.bn-block-outer[data-prev-type="numberedListItem"]:not([data-prev-index="none"]) + > .bn-block + > .bn-block-content::before { margin-right: 1.2em; content: var(--prev-index) "."; } -.blockOuter:not([data-prev-type]) - > .block - > .blockContent[data-content-type="numberedListItem"]::before { +.bn-block-outer:not([data-prev-type]) + > .bn-block + > .bn-block-content[data-content-type="numberedListItem"]::before { margin-right: 1.2em; content: var(--index) "."; } /* Unordered */ /* No list nesting */ -.blockOuter[data-prev-type="bulletListItem"] > .block > .blockContent::before { +.bn-block-outer[data-prev-type="bulletListItem"] + > .bn-block + > .bn-block-content::before { margin-right: 1.2em; content: "•"; } -.blockOuter:not([data-prev-type]) - > .block - > .blockContent[data-content-type="bulletListItem"]::before { +.bn-block-outer:not([data-prev-type]) + > .bn-block + > .bn-block-content[data-content-type="bulletListItem"]::before { margin-right: 1.2em; content: "•"; } /* 1 level of list nesting */ [data-content-type="bulletListItem"] - ~ .blockGroup - > .blockOuter[data-prev-type="bulletListItem"] - > .block - > .blockContent::before { + ~ .bn-block-group + > .bn-block-outer[data-prev-type="bulletListItem"] + > .bn-block + > .bn-block-content::before { margin-right: 1.2em; content: "◦"; } [data-content-type="bulletListItem"] - ~ .blockGroup - > .blockOuter:not([data-prev-type]) - > .block - > .blockContent[data-content-type="bulletListItem"]::before { + ~ .bn-block-group + > .bn-block-outer:not([data-prev-type]) + > .bn-block + > .bn-block-content[data-content-type="bulletListItem"]::before { margin-right: 1.2em; content: "◦"; } /* 2 levels of list nesting */ [data-content-type="bulletListItem"] - ~ .blockGroup + ~ .bn-block-group [data-content-type="bulletListItem"] - ~ .blockGroup - > .blockOuter[data-prev-type="bulletListItem"] - > .block - > .blockContent::before { + ~ .bn-block-group + > .bn-block-outer[data-prev-type="bulletListItem"] + > .bn-block + > .bn-block-content::before { margin-right: 1.2em; content: "▪"; } [data-content-type="bulletListItem"] - ~ .blockGroup + ~ .bn-block-group [data-content-type="bulletListItem"] - ~ .blockGroup - > .blockOuter:not([data-prev-type]) - > .block - > .blockContent[data-content-type="bulletListItem"]::before { + ~ .bn-block-group + > .bn-block-outer:not([data-prev-type]) + > .bn-block + > .bn-block-content[data-content-type="bulletListItem"]::before { margin-right: 1.2em; content: "▪"; } /* IMAGES */ -[data-content-type="image"] .wrapper { +[data-content-type="image"] .bn-image-block-content-wrapper { display: flex; flex-direction: column; justify-content: center; @@ -232,7 +237,7 @@ NESTED BLOCKS width: 100%; } -[data-content-type="image"] .addImageButton { +[data-content-type="image"] .bn-add-image-button { display: flex; flex-direction: row; align-items: center; @@ -244,27 +249,27 @@ NESTED BLOCKS width: 100%; } -[data-content-type="image"] .addImageButton:hover { +[data-content-type="image"] .bn-add-image-button:hover { background-color: gainsboro; } -[data-content-type="image"] .addImageButtonIcon { +[data-content-type="image"] .bn-add-image-button-icon { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 5H4V19L13.2923 9.70649C13.6828 9.31595 14.3159 9.31591 14.7065 9.70641L20 15.0104V5ZM2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z'%3E%3C/path%3E%3C/svg%3E"); width: 24px; height: 24px; } -[data-content-type="image"] .addImageButtonText { +[data-content-type="image"] .bn-add-image-button-text { color: black; } -[data-content-type="image"] .imageAndCaptionWrapper { +[data-content-type="image"] .bn-image-and-caption-wrapper { display: flex; flex-direction: column; border-radius: 4px; } -[data-content-type="image"] .imageWrapper { +[data-content-type="image"] .bn-image-wrapper { display: flex; flex-direction: row; align-items: center; @@ -272,12 +277,12 @@ NESTED BLOCKS width: fit-content; } -[data-content-type="image"] .image { +[data-content-type="image"] .bn-image { border-radius: 4px; max-width: 100%; } -[data-content-type="image"] .resizeHandle { +[data-content-type="image"] .bn-image-resize-handle { display: none; position: absolute; width: 8px; @@ -294,8 +299,8 @@ NESTED BLOCKS /* PLACEHOLDERS*/ -.isEmpty .inlineContent:before, -.isFilter .inlineContent:before { +.bn-is-empty .bn-inline-content:before, +.bn-is-filter .bn-inline-content:before { /*float: left; */ content: ""; pointer-events: none; @@ -307,25 +312,27 @@ NESTED BLOCKS /* TODO: would be nicer if defined from code */ -.blockContent.isEmpty.hasAnchor .inlineContent:before { +.bn-block-content.bn-is-empty.bn-has-anchor .bn-inline-content:before { content: "Enter text or type '/' for commands"; } -.blockContent.isFilter.hasAnchor .inlineContent:before { +.bn-block-content.bn-is-filter.bn-has-anchor .bn-inline-content:before { content: "Type to filter"; } -.blockContent[data-content-type="heading"].isEmpty .inlineContent:before { +.bn-block-content[data-content-type="heading"].bn-is-empty + .bn-inline-content:before { content: "Heading"; } -.blockContent[data-content-type="bulletListItem"].isEmpty .inlineContent:before, -.blockContent[data-content-type="numberedListItem"].isEmpty -.inlineContent:before { +.bn-block-content[data-content-type="bulletListItem"].bn-is-empty + .bn-inline-content:before, + .bn-block-content[data-content-type="numberedListItem"].bn-is-empty + .bn-inline-content:before { content: "List"; } -.isEmpty .blockContent[data-content-type="captionedImage"] .inlineContent:before { +.bn-is-empty .bn-block-content[data-content-type="captionedImage"] .bn-inline-content:before { content: "Caption"; } diff --git a/packages/core/src/BlockNoteEditor.test.ts b/packages/core/src/editor/BlockNoteEditor.test.ts similarity index 70% rename from packages/core/src/BlockNoteEditor.test.ts rename to packages/core/src/editor/BlockNoteEditor.test.ts index 9c1b60fb12..b4c1e26558 100644 --- a/packages/core/src/BlockNoteEditor.test.ts +++ b/packages/core/src/editor/BlockNoteEditor.test.ts @@ -1,12 +1,12 @@ import { expect, it } from "vitest"; import { BlockNoteEditor } from "./BlockNoteEditor"; -import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFromPos"; +import { getBlockInfoFromPos } from "../api/getBlockInfoFromPos"; /** * @vitest-environment jsdom */ it("creates an editor", () => { - const editor = new BlockNoteEditor({}); + const editor = BlockNoteEditor.create(); const blockInfo = getBlockInfoFromPos(editor._tiptapEditor.state.doc, 2); expect(blockInfo?.contentNode.type.name).toEqual("paragraph"); }); diff --git a/packages/core/src/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts similarity index 65% rename from packages/core/src/BlockNoteEditor.ts rename to packages/core/src/editor/BlockNoteEditor.ts index 58491b687c..c888a0f670 100644 --- a/packages/core/src/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -3,56 +3,79 @@ import { Node } from "prosemirror-model"; // import "./blocknote.css"; import { Editor as TiptapEditor } from "@tiptap/core/dist/packages/core/src/Editor"; import * as Y from "yjs"; -import { getBlockNoteExtensions } from "./BlockNoteExtensions"; import { insertBlocks, removeBlocks, replaceBlocks, updateBlock, -} from "./api/blockManipulation/blockManipulation"; -import { - HTMLToBlocks, - blocksToHTML, - blocksToMarkdown, - markdownToBlocks, -} from "./api/formatConversions/formatConversions"; +} from "../api/blockManipulation/blockManipulation"; +import { createExternalHTMLExporter } from "../api/exporters/html/externalHTMLExporter"; +import { blocksToMarkdown } from "../api/exporters/markdown/markdownExporter"; +import { getBlockInfoFromPos } from "../api/getBlockInfoFromPos"; import { blockToNode, nodeToBlock, -} from "./api/nodeConversions/nodeConversions"; -import { getNodeById } from "./api/util/nodeUtil"; -import styles from "./editor.module.css"; +} from "../api/nodeConversions/nodeConversions"; +import { getNodeById } from "../api/nodeUtil"; +import { HTMLToBlocks } from "../api/parsers/html/parseHTML"; +import { markdownToBlocks } from "../api/parsers/markdown/parseMarkdown"; +import { + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + defaultBlockSchema, + defaultBlockSpecs, + defaultInlineContentSpecs, + defaultStyleSpecs, +} from "../blocks/defaultBlocks"; +import { FormattingToolbarProsemirrorPlugin } from "../extensions/FormattingToolbar/FormattingToolbarPlugin"; +import { HyperlinkToolbarProsemirrorPlugin } from "../extensions/HyperlinkToolbar/HyperlinkToolbarPlugin"; +import { ImageToolbarProsemirrorPlugin } from "../extensions/ImageToolbar/ImageToolbarPlugin"; +import { SideMenuProsemirrorPlugin } from "../extensions/SideMenu/SideMenuPlugin"; +import { BaseSlashMenuItem } from "../extensions/SlashMenu/BaseSlashMenuItem"; +import { SlashMenuProsemirrorPlugin } from "../extensions/SlashMenu/SlashMenuPlugin"; +import { getDefaultSlashMenuItems } from "../extensions/SlashMenu/defaultSlashMenuItems"; +import { TableHandlesProsemirrorPlugin } from "../extensions/TableHandles/TableHandlesPlugin"; +import { UniqueID } from "../extensions/UniqueID/UniqueID"; import { Block, BlockIdentifier, BlockNoteDOMAttributes, BlockSchema, + BlockSchemaFromSpecs, + BlockSchemaWithBlock, + BlockSpecs, + InlineContentSchema, + InlineContentSchemaFromSpecs, + InlineContentSpecs, PartialBlock, -} from "./extensions/Blocks/api/blockTypes"; -import { - DefaultBlockSchema, - defaultBlockSchema, -} from "./extensions/Blocks/api/defaultBlocks"; -import { - ColorStyle, + StyleSchema, + StyleSchemaFromSpecs, + StyleSpecs, Styles, - ToggledStyle, -} from "./extensions/Blocks/api/inlineContentTypes"; -import { Selection } from "./extensions/Blocks/api/selectionTypes"; -import { getBlockInfoFromPos } from "./extensions/Blocks/helpers/getBlockInfoFromPos"; - -import { TextCursorPosition } from "./extensions/Blocks/api/cursorPositionTypes"; -import { FormattingToolbarProsemirrorPlugin } from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; -import { HyperlinkToolbarProsemirrorPlugin } from "./extensions/HyperlinkToolbar/HyperlinkToolbarPlugin"; -import { ImageToolbarProsemirrorPlugin } from "./extensions/ImageToolbar/ImageToolbarPlugin"; -import { SideMenuProsemirrorPlugin } from "./extensions/SideMenu/SideMenuPlugin"; -import { BaseSlashMenuItem } from "./extensions/SlashMenu/BaseSlashMenuItem"; -import { SlashMenuProsemirrorPlugin } from "./extensions/SlashMenu/SlashMenuPlugin"; -import { getDefaultSlashMenuItems } from "./extensions/SlashMenu/defaultSlashMenuItems"; -import { UniqueID } from "./extensions/UniqueID/UniqueID"; -import { mergeCSSClasses } from "./shared/utils"; - -export type BlockNoteEditorOptions = { + getBlockSchemaFromSpecs, + getInlineContentSchemaFromSpecs, + getStyleSchemaFromSpecs, +} from "../schema"; +import { mergeCSSClasses } from "../util/browser"; +import { UnreachableCaseError } from "../util/typescript"; + +import { getBlockNoteExtensions } from "./BlockNoteExtensions"; +import { TextCursorPosition } from "./cursorPositionTypes"; + +import { Selection } from "./selectionTypes"; +import { transformPasted } from "./transformPasted"; + +// CSS +import "prosemirror-tables/style/tables.css"; +import "./Block.css"; +import "./editor.css"; + +export type BlockNoteEditorOptions< + BSpecs extends BlockSpecs, + ISpecs extends InlineContentSpecs, + SSpecs extends StyleSpecs +> = { // TODO: Figure out if enableBlockNoteExtensions/disableHistoryExtension are needed and document them. enableBlockNoteExtensions: boolean; /** @@ -61,7 +84,7 @@ export type BlockNoteEditorOptions = { * * @default defaultSlashMenuItems from `./extensions/SlashMenu` */ - slashMenuItems: BaseSlashMenuItem[]; + slashMenuItems: BaseSlashMenuItem[]; /** * The HTML element that should be used as the parent element for the editor. @@ -78,15 +101,33 @@ export type BlockNoteEditorOptions = { /** * A callback function that runs when the editor is ready to be used. */ - onEditorReady: (editor: BlockNoteEditor) => void; + onEditorReady: ( + editor: BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + > + ) => void; /** * A callback function that runs whenever the editor's contents change. */ - onEditorContentChange: (editor: BlockNoteEditor) => void; + onEditorContentChange: ( + editor: BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + > + ) => void; /** * A callback function that runs whenever the text cursor position changes. */ - onTextCursorPositionChange: (editor: BlockNoteEditor) => void; + onTextCursorPositionChange: ( + editor: BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + > + ) => void; /** * Locks the editor from being editable by the user if set to `false`. */ @@ -94,7 +135,11 @@ export type BlockNoteEditorOptions = { /** * The content that should be in the editor when it's created, represented as an array of partial block objects. */ - initialContent: PartialBlock[]; + initialContent: PartialBlock< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + >[]; /** * Use default BlockNote font and reset the styles of

          1. elements etc., that are used in BlockNote. * @@ -105,7 +150,11 @@ export type BlockNoteEditorOptions = { /** * A list of block types that should be available in the editor. */ - blockSchema: BSchema; + blockSpecs: BSpecs; + + styleSpecs: SSpecs; + + inlineContentSpecs: ISpecs; /** * A custom function to handle file uploads. @@ -149,52 +198,115 @@ const blockNoteTipTapOptions = { enableCoreExtensions: false, }; -export class BlockNoteEditor { +export class BlockNoteEditor< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema +> { public readonly _tiptapEditor: TiptapEditor & { contentComponent: any }; - public blockCache = new WeakMap>(); - public readonly schema: BSchema; + public blockCache = new WeakMap>(); + public readonly blockSchema: BSchema; + public readonly inlineContentSchema: ISchema; + public readonly styleSchema: SSchema; + + public readonly blockImplementations: BlockSpecs; + public readonly inlineContentImplementations: InlineContentSpecs; + public readonly styleImplementations: StyleSpecs; + public ready = false; - public readonly sideMenu: SideMenuProsemirrorPlugin; - public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin; - public readonly slashMenu: SlashMenuProsemirrorPlugin; - public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin; - public readonly imageToolbar: ImageToolbarProsemirrorPlugin; + public readonly sideMenu: SideMenuProsemirrorPlugin< + BSchema, + ISchema, + SSchema + >; + public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin; + public readonly slashMenu: SlashMenuProsemirrorPlugin< + BSchema, + ISchema, + SSchema, + any + >; + public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin< + BSchema, + ISchema, + SSchema + >; + public readonly imageToolbar: ImageToolbarProsemirrorPlugin< + BSchema, + ISchema, + SSchema + >; + public readonly tableHandles: + | TableHandlesProsemirrorPlugin< + BSchema extends BlockSchemaWithBlock< + "table", + DefaultBlockSchema["table"] + > + ? BSchema + : any, + ISchema, + SSchema + > + | undefined; public readonly uploadFile: ((file: File) => Promise) | undefined; - constructor( - private readonly options: Partial> = {} + public static create< + BSpecs extends BlockSpecs = typeof defaultBlockSpecs, + ISpecs extends InlineContentSpecs = typeof defaultInlineContentSpecs, + SSpecs extends StyleSpecs = typeof defaultStyleSpecs + >(options: Partial> = {}) { + return new BlockNoteEditor(options) as BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + >; + } + + private constructor( + private readonly options: Partial> ) { // apply defaults - const newOptions: Omit & { - defaultStyles: boolean; - blockSchema: BSchema; - } = { + const newOptions = { defaultStyles: true, - // TODO: There's a lot of annoying typing stuff to deal with here. If - // BSchema is specified, then options.blockSchema should also be required. - // If BSchema is not specified, then options.blockSchema should also not - // be defined. Unfortunately, trying to implement these constraints seems - // to be a huge pain, hence the `as any` casts. - blockSchema: options.blockSchema || (defaultBlockSchema as any), + blockSpecs: options.blockSpecs || defaultBlockSpecs, + styleSpecs: options.styleSpecs || defaultStyleSpecs, + inlineContentSpecs: + options.inlineContentSpecs || defaultInlineContentSpecs, ...options, }; + this.blockSchema = getBlockSchemaFromSpecs(newOptions.blockSpecs); + this.inlineContentSchema = getInlineContentSchemaFromSpecs( + newOptions.inlineContentSpecs + ); + this.styleSchema = getStyleSchemaFromSpecs(newOptions.styleSpecs); + this.blockImplementations = newOptions.blockSpecs; + this.inlineContentImplementations = newOptions.inlineContentSpecs; + this.styleImplementations = newOptions.styleSpecs; + this.sideMenu = new SideMenuProsemirrorPlugin(this); this.formattingToolbar = new FormattingToolbarProsemirrorPlugin(this); this.slashMenu = new SlashMenuProsemirrorPlugin( this, newOptions.slashMenuItems || - getDefaultSlashMenuItems(newOptions.blockSchema) + (getDefaultSlashMenuItems(this.blockSchema) as any) ); this.hyperlinkToolbar = new HyperlinkToolbarProsemirrorPlugin(this); this.imageToolbar = new ImageToolbarProsemirrorPlugin(this); - const extensions = getBlockNoteExtensions({ + if (this.blockSchema.table === defaultBlockSchema.table) { + this.tableHandles = new TableHandlesProsemirrorPlugin(this as any); + } + + const extensions = getBlockNoteExtensions({ editor: this, domAttributes: newOptions.domAttributes || {}, - blockSchema: newOptions.blockSchema, + blockSchema: this.blockSchema, + blockSpecs: newOptions.blockSpecs, + styleSpecs: newOptions.styleSpecs, + inlineContentSpecs: newOptions.inlineContentSpecs, collaboration: newOptions.collaboration, }); @@ -208,13 +320,12 @@ export class BlockNoteEditor { this.slashMenu.plugin, this.hyperlinkToolbar.plugin, this.imageToolbar.plugin, + ...(this.tableHandles ? [this.tableHandles.plugin] : []), ]; }, }); extensions.push(blockNoteUIExtension); - this.schema = newOptions.blockSchema; - this.uploadFile = newOptions.uploadFile; if (newOptions.collaboration && newOptions.initialContent) { @@ -233,6 +344,7 @@ export class BlockNoteEditor { id: UniqueID.options.generateID(), }, ]); + const styleSchema = this.styleSchema; const tiptapOptions: Partial = { ...blockNoteTipTapOptions, @@ -267,7 +379,11 @@ export class BlockNoteEditor { "doc", undefined, schema.node("blockGroup", undefined, [ - blockToNode({ id: "initialBlockId", type: "paragraph" }, schema), + blockToNode( + { id: "initialBlockId", type: "paragraph" }, + schema, + styleSchema + ), ]) ); editor.editor.options.content = root.toJSON(); @@ -278,7 +394,7 @@ export class BlockNoteEditor { // initial content, as the schema may contain custom blocks which need // it to render. if (initialContent !== undefined) { - this.replaceBlocks(this.topLevelBlocks, initialContent); + this.replaceBlocks(this.topLevelBlocks, initialContent as any); } newOptions.onEditorReady?.(this); @@ -320,13 +436,13 @@ export class BlockNoteEditor { ...newOptions._tiptapOptions?.editorProps?.attributes, ...newOptions.domAttributes?.editor, class: mergeCSSClasses( - styles.bnEditor, - styles.bnRoot, - newOptions.domAttributes?.editor?.class || "", - newOptions.defaultStyles ? styles.defaultStyles : "", + "bn-root", + "bn-editor", + newOptions.defaultStyles ? "bn-default-styles" : "", newOptions.domAttributes?.editor?.class || "" ), }, + transformPasted, }, }; @@ -359,11 +475,19 @@ export class BlockNoteEditor { * Gets a snapshot of all top-level (non-nested) blocks in the editor. * @returns A snapshot of all top-level (non-nested) blocks in the editor. */ - public get topLevelBlocks(): Block[] { - const blocks: Block[] = []; + public get topLevelBlocks(): Block[] { + const blocks: Block[] = []; this._tiptapEditor.state.doc.firstChild!.descendants((node) => { - blocks.push(nodeToBlock(node, this.schema, this.blockCache)); + blocks.push( + nodeToBlock( + node, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this.blockCache + ) + ); return false; }); @@ -378,12 +502,12 @@ export class BlockNoteEditor { */ public getBlock( blockIdentifier: BlockIdentifier - ): Block | undefined { + ): Block | undefined { const id = typeof blockIdentifier === "string" ? blockIdentifier : blockIdentifier.id; - let newBlock: Block | undefined = undefined; + let newBlock: Block | undefined = undefined; this._tiptapEditor.state.doc.firstChild!.descendants((node) => { if (typeof newBlock !== "undefined") { @@ -394,7 +518,13 @@ export class BlockNoteEditor { return true; } - newBlock = nodeToBlock(node, this.schema, this.blockCache); + newBlock = nodeToBlock( + node, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this.blockCache + ); return false; }); @@ -408,7 +538,7 @@ export class BlockNoteEditor { * @param reverse Whether the blocks should be traversed in reverse order. */ public forEachBlock( - callback: (block: Block) => boolean, + callback: (block: Block) => boolean, reverse = false ): void { const blocks = this.topLevelBlocks.slice(); @@ -417,7 +547,9 @@ export class BlockNoteEditor { blocks.reverse(); } - function traverseBlockArray(blockArray: Block[]): boolean { + function traverseBlockArray( + blockArray: Block[] + ): boolean { for (const block of blockArray) { if (!callback(block)) { return false; @@ -458,7 +590,11 @@ export class BlockNoteEditor { * Gets a snapshot of the current text cursor position. * @returns A snapshot of the current text cursor position. */ - public getTextCursorPosition(): TextCursorPosition { + public getTextCursorPosition(): TextCursorPosition< + BSchema, + ISchema, + SSchema + > { const { node, depth, startPos, endPos } = getBlockInfoFromPos( this._tiptapEditor.state.doc, this._tiptapEditor.state.selection.from @@ -486,15 +622,33 @@ export class BlockNoteEditor { } return { - block: nodeToBlock(node, this.schema, this.blockCache), + block: nodeToBlock( + node, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this.blockCache + ), prevBlock: prevNode === undefined ? undefined - : nodeToBlock(prevNode, this.schema, this.blockCache), + : nodeToBlock( + prevNode, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this.blockCache + ), nextBlock: nextNode === undefined ? undefined - : nodeToBlock(nextNode, this.schema, this.blockCache), + : nodeToBlock( + nextNode, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this.blockCache + ), }; } @@ -516,25 +670,42 @@ export class BlockNoteEditor { posBeforeNode + 2 )!; - // For blocks without inline content - if (contentNode.type.spec.content === "") { + const contentType: "none" | "inline" | "table" = + this.blockSchema[contentNode.type.name]!.content; + + if (contentType === "none") { this._tiptapEditor.commands.setNodeSelection(startPos); return; } - if (placement === "start") { - this._tiptapEditor.commands.setTextSelection(startPos + 1); + if (contentType === "inline") { + if (placement === "start") { + this._tiptapEditor.commands.setTextSelection(startPos + 1); + } else { + this._tiptapEditor.commands.setTextSelection( + startPos + contentNode.nodeSize - 1 + ); + } + } else if (contentType === "table") { + if (placement === "start") { + // Need to offset the position as we have to get through the `tableRow` + // and `tableCell` nodes to get to the `tableParagraph` node we want to + // set the selection in. + this._tiptapEditor.commands.setTextSelection(startPos + 4); + } else { + this._tiptapEditor.commands.setTextSelection( + startPos + contentNode.nodeSize - 4 + ); + } } else { - this._tiptapEditor.commands.setTextSelection( - startPos + contentNode.nodeSize - 1 - ); + throw new UnreachableCaseError(contentType); } } /** * Gets a snapshot of the current selection. */ - public getSelection(): Selection | undefined { + public getSelection(): Selection | undefined { // Either the TipTap selection is empty, or it's a node selection. In either // case, it only spans one block, so we return undefined. if ( @@ -545,8 +716,10 @@ export class BlockNoteEditor { return undefined; } - const blocks: Block[] = []; + const blocks: Block[] = []; + // TODO: This adds all child blocks to the same array. Needs to find min + // depth and only add blocks at that depth. this._tiptapEditor.state.doc.descendants((node, pos) => { if (node.type.spec.group !== "blockContent") { return true; @@ -562,7 +735,9 @@ export class BlockNoteEditor { blocks.push( nodeToBlock( this._tiptapEditor.state.doc.resolve(pos).node(), - this.schema, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, this.blockCache ) ); @@ -598,11 +773,11 @@ export class BlockNoteEditor { * `referenceBlock`. Inserts the blocks at the start of the existing block's children if "nested" is used. */ public insertBlocks( - blocksToInsert: PartialBlock[], + blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, placement: "before" | "after" | "nested" = "before" ): void { - insertBlocks(blocksToInsert, referenceBlock, placement, this._tiptapEditor); + insertBlocks(blocksToInsert, referenceBlock, placement, this); } /** @@ -614,7 +789,7 @@ export class BlockNoteEditor { */ public updateBlock( blockToUpdate: BlockIdentifier, - update: PartialBlock + update: PartialBlock ) { updateBlock(blockToUpdate, update, this._tiptapEditor); } @@ -636,32 +811,28 @@ export class BlockNoteEditor { */ public replaceBlocks( blocksToRemove: BlockIdentifier[], - blocksToInsert: PartialBlock[] + blocksToInsert: PartialBlock[] ) { - replaceBlocks(blocksToRemove, blocksToInsert, this._tiptapEditor); + replaceBlocks(blocksToRemove, blocksToInsert, this); } /** * Gets the active text styles at the text cursor position or at the end of the current selection if it's active. */ public getActiveStyles() { - const styles: Styles = {}; + const styles: Styles = {}; const marks = this._tiptapEditor.state.selection.$to.marks(); - const toggleStyles = new Set([ - "bold", - "italic", - "underline", - "strike", - "code", - ]); - const colorStyles = new Set(["textColor", "backgroundColor"]); - for (const mark of marks) { - if (toggleStyles.has(mark.type.name as ToggledStyle)) { - styles[mark.type.name as ToggledStyle] = true; - } else if (colorStyles.has(mark.type.name as ColorStyle)) { - styles[mark.type.name as ColorStyle] = mark.attrs.color; + const config = this.styleSchema[mark.type.name]; + if (!config) { + console.warn("mark not found in styleschema", mark.type.name); + continue; + } + if (config.propSchema === "boolean") { + (styles as any)[config.type] = true; + } else { + (styles as any)[config.type] = mark.attrs.stringValue; } } @@ -672,23 +843,20 @@ export class BlockNoteEditor { * Adds styles to the currently selected content. * @param styles The styles to add. */ - public addStyles(styles: Styles) { - const toggleStyles = new Set([ - "bold", - "italic", - "underline", - "strike", - "code", - ]); - const colorStyles = new Set(["textColor", "backgroundColor"]); - + public addStyles(styles: Styles) { this._tiptapEditor.view.focus(); for (const [style, value] of Object.entries(styles)) { - if (toggleStyles.has(style as ToggledStyle)) { + const config = this.styleSchema[style]; + if (!config) { + throw new Error(`style ${style} not found in styleSchema`); + } + if (config.propSchema === "boolean") { this._tiptapEditor.commands.setMark(style); - } else if (colorStyles.has(style as ColorStyle)) { - this._tiptapEditor.commands.setMark(style, { color: value }); + } else if (config.propSchema === "string") { + this._tiptapEditor.commands.setMark(style, { stringValue: value }); + } else { + throw new UnreachableCaseError(config.propSchema); } } } @@ -697,7 +865,7 @@ export class BlockNoteEditor { * Removes styles from the currently selected content. * @param styles The styles to remove. */ - public removeStyles(styles: Styles) { + public removeStyles(styles: Styles) { this._tiptapEditor.view.focus(); for (const style of Object.keys(styles)) { @@ -709,23 +877,20 @@ export class BlockNoteEditor { * Toggles styles on the currently selected content. * @param styles The styles to toggle. */ - public toggleStyles(styles: Styles) { - const toggleStyles = new Set([ - "bold", - "italic", - "underline", - "strike", - "code", - ]); - const colorStyles = new Set(["textColor", "backgroundColor"]); - + public toggleStyles(styles: Styles) { this._tiptapEditor.view.focus(); for (const [style, value] of Object.entries(styles)) { - if (toggleStyles.has(style as ToggledStyle)) { + const config = this.styleSchema[style]; + if (!config) { + throw new Error(`style ${style} not found in styleSchema`); + } + if (config.propSchema === "boolean") { this._tiptapEditor.commands.toggleMark(style); - } else if (colorStyles.has(style as ColorStyle)) { - this._tiptapEditor.commands.toggleMark(style, { color: value }); + } else if (config.propSchema === "string") { + this._tiptapEditor.commands.toggleMark(style, { stringValue: value }); + } else { + throw new UnreachableCaseError(config.propSchema); } } } @@ -810,14 +975,21 @@ export class BlockNoteEditor { this._tiptapEditor.commands.liftListItem("blockContainer"); } + // TODO: Fix when implementing HTML/Markdown import & export /** * Serializes blocks into an HTML string. To better conform to HTML standards, children of blocks which aren't list * items are un-nested in the output HTML. * @param blocks An array of blocks that should be serialized into HTML. * @returns The blocks, serialized as an HTML string. */ - public async blocksToHTML(blocks: Block[]): Promise { - return blocksToHTML(blocks, this._tiptapEditor.schema); + public async blocksToHTMLLossy( + blocks = this.topLevelBlocks + ): Promise { + const exporter = createExternalHTMLExporter( + this._tiptapEditor.schema, + this + ); + return exporter.exportBlocks(blocks); } /** @@ -827,8 +999,16 @@ export class BlockNoteEditor { * @param html The HTML string to parse blocks from. * @returns The blocks parsed from the HTML string. */ - public async HTMLToBlocks(html: string): Promise[]> { - return HTMLToBlocks(html, this.schema, this._tiptapEditor.schema); + public async tryParseHTMLToBlocks( + html: string + ): Promise[]> { + return HTMLToBlocks( + html, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this._tiptapEditor.schema + ); } /** @@ -837,8 +1017,10 @@ export class BlockNoteEditor { * @param blocks An array of blocks that should be serialized into Markdown. * @returns The blocks, serialized as a Markdown string. */ - public async blocksToMarkdown(blocks: Block[]): Promise { - return blocksToMarkdown(blocks, this._tiptapEditor.schema); + public async blocksToMarkdownLossy( + blocks: Block[] = this.topLevelBlocks + ): Promise { + return blocksToMarkdown(blocks, this._tiptapEditor.schema, this); } /** @@ -848,8 +1030,16 @@ export class BlockNoteEditor { * @param markdown The Markdown string to parse blocks from. * @returns The blocks parsed from the Markdown string. */ - public async markdownToBlocks(markdown: string): Promise[]> { - return markdownToBlocks(markdown, this.schema, this._tiptapEditor.schema); + public async tryParseMarkdownToBlocks( + markdown: string + ): Promise[]> { + return markdownToBlocks( + markdown, + this.blockSchema, + this.inlineContentSchema, + this.styleSchema, + this._tiptapEditor.schema + ); } /** diff --git a/packages/core/src/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts similarity index 58% rename from packages/core/src/BlockNoteExtensions.ts rename to packages/core/src/editor/BlockNoteExtensions.ts index d328c329b7..ec2277e5b9 100644 --- a/packages/core/src/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -1,45 +1,49 @@ import { Extensions, extensions } from "@tiptap/core"; -import { BlockNoteEditor } from "./BlockNoteEditor"; +import type { BlockNoteEditor } from "./BlockNoteEditor"; -import { Bold } from "@tiptap/extension-bold"; -import { Code } from "@tiptap/extension-code"; import Collaboration from "@tiptap/extension-collaboration"; import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; import { Dropcursor } from "@tiptap/extension-dropcursor"; import { Gapcursor } from "@tiptap/extension-gapcursor"; import { HardBreak } from "@tiptap/extension-hard-break"; import { History } from "@tiptap/extension-history"; -import { Italic } from "@tiptap/extension-italic"; import { Link } from "@tiptap/extension-link"; -import { Strike } from "@tiptap/extension-strike"; import { Text } from "@tiptap/extension-text"; -import { Underline } from "@tiptap/extension-underline"; import * as Y from "yjs"; -import styles from "./editor.module.css"; -import { BackgroundColorExtension } from "./extensions/BackgroundColor/BackgroundColorExtension"; -import { BackgroundColorMark } from "./extensions/BackgroundColor/BackgroundColorMark"; -import { BlockContainer, BlockGroup, Doc } from "./extensions/Blocks"; +import { createCopyToClipboardExtension } from "../api/exporters/copyExtension"; +import { createPasteFromClipboardExtension } from "../api/parsers/pasteExtension"; +import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension"; +import { Placeholder } from "../extensions/Placeholder/PlaceholderExtension"; +import { TextAlignmentExtension } from "../extensions/TextAlignment/TextAlignmentExtension"; +import { TextColorExtension } from "../extensions/TextColor/TextColorExtension"; +import { TrailingNode } from "../extensions/TrailingNode/TrailingNodeExtension"; +import UniqueID from "../extensions/UniqueID/UniqueID"; +import { BlockContainer, BlockGroup, Doc } from "../pm-nodes"; import { BlockNoteDOMAttributes, BlockSchema, -} from "./extensions/Blocks/api/blockTypes"; -import { CustomBlockSerializerExtension } from "./extensions/Blocks/api/serialization"; -import blockStyles from "./extensions/Blocks/nodes/Block.module.css"; -import { Placeholder } from "./extensions/Placeholder/PlaceholderExtension"; -import { TextAlignmentExtension } from "./extensions/TextAlignment/TextAlignmentExtension"; -import { TextColorExtension } from "./extensions/TextColor/TextColorExtension"; -import { TextColorMark } from "./extensions/TextColor/TextColorMark"; -import { TrailingNode } from "./extensions/TrailingNode/TrailingNodeExtension"; -import UniqueID from "./extensions/UniqueID/UniqueID"; + BlockSpecs, + InlineContentSchema, + InlineContentSpecs, + StyleSchema, + StyleSpecs, +} from "../schema"; /** * Get all the Tiptap extensions BlockNote is configured with by default */ -export const getBlockNoteExtensions = (opts: { - editor: BlockNoteEditor; +export const getBlockNoteExtensions = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>(opts: { + editor: BlockNoteEditor; domAttributes: Partial; blockSchema: BSchema; + blockSpecs: BlockSpecs; + inlineContentSpecs: InlineContentSpecs; + styleSpecs: StyleSpecs; collaboration?: { fragment: Y.XmlFragment; user: { @@ -62,9 +66,6 @@ export const getBlockNoteExtensions = (opts: { // DropCursor, Placeholder.configure({ - emptyNodeClass: blockStyles.isEmpty, - hasAnchorClass: blockStyles.hasAnchor, - isFilterClass: blockStyles.isFilter, includeChildren: true, showOnlyCurrent: false, }), @@ -78,33 +79,51 @@ export const getBlockNoteExtensions = (opts: { Text, // marks: - Bold, - Code, - Italic, - Strike, - Underline, Link, - TextColorMark, + ...Object.values(opts.styleSpecs).map((styleSpec) => { + return styleSpec.implementation.mark; + }), + TextColorExtension, - BackgroundColorMark, + BackgroundColorExtension, TextAlignmentExtension, // nodes Doc, BlockContainer.configure({ + editor: opts.editor as any, domAttributes: opts.domAttributes, }), BlockGroup.configure({ domAttributes: opts.domAttributes, }), - ...Object.values(opts.blockSchema).map((blockSpec) => - blockSpec.node.configure({ - editor: opts.editor, - domAttributes: opts.domAttributes, - }) - ), - CustomBlockSerializerExtension, + ...Object.values(opts.inlineContentSpecs) + .filter((a) => a.config !== "link" && a.config !== "text") + .map((inlineContentSpec) => { + return inlineContentSpec.implementation!.node.configure({ + editor: opts.editor as any, + }); + }), + + ...Object.values(opts.blockSpecs).flatMap((blockSpec) => { + return [ + // dependent nodes (e.g.: tablecell / row) + ...(blockSpec.implementation.requiredExtensions || []).map((ext) => + ext.configure({ + editor: opts.editor, + domAttributes: opts.domAttributes, + }) + ), + // the actual node itself + blockSpec.implementation.node.configure({ + editor: opts.editor, + domAttributes: opts.domAttributes, + }), + ]; + }), + createCopyToClipboardExtension(opts.editor), + createPasteFromClipboardExtension(opts.editor), Dropcursor.configure({ width: 5, color: "#ddeeff" }), // This needs to be at the bottom of this list, because Key events (such as enter, when selecting a /command), @@ -122,12 +141,12 @@ export const getBlockNoteExtensions = (opts: { const defaultRender = (user: { color: string; name: string }) => { const cursor = document.createElement("span"); - cursor.classList.add(styles["collaboration-cursor__caret"]); + cursor.classList.add("collaboration-cursor__caret"); cursor.setAttribute("style", `border-color: ${user.color}`); const label = document.createElement("span"); - label.classList.add(styles["collaboration-cursor__label"]); + label.classList.add("collaboration-cursor__label"); label.setAttribute("style", `background-color: ${user.color}`); label.insertBefore(document.createTextNode(user.name), null); diff --git a/packages/core/src/editor/README.md b/packages/core/src/editor/README.md new file mode 100644 index 0000000000..f87722a4e9 --- /dev/null +++ b/packages/core/src/editor/README.md @@ -0,0 +1,3 @@ +### @blocknote/core/src/editor + +Contains main functions to set up the editor \ No newline at end of file diff --git a/packages/core/src/editor/cursorPositionTypes.ts b/packages/core/src/editor/cursorPositionTypes.ts new file mode 100644 index 0000000000..b7fa932475 --- /dev/null +++ b/packages/core/src/editor/cursorPositionTypes.ts @@ -0,0 +1,16 @@ +import { + Block, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../schema"; + +export type TextCursorPosition< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { + block: Block; + prevBlock: Block | undefined; + nextBlock: Block | undefined; +}; diff --git a/packages/core/src/editor.module.css b/packages/core/src/editor/editor.css similarity index 71% rename from packages/core/src/editor.module.css rename to packages/core/src/editor/editor.css index 76c65d9d13..9e5761a45b 100644 --- a/packages/core/src/editor.module.css +++ b/packages/core/src/editor/editor.css @@ -1,6 +1,6 @@ -@import url("./assets/fonts-inter.css"); +@import url("../assets/fonts-inter.css"); -.bnEditor { +.bn-editor { outline: none; padding-inline: 54px; @@ -11,31 +11,31 @@ } /* -bnRoot should be applied to all top-level elements +bn-root 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 { +.bn-root { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; } -.bnRoot *, -.bnRoot *::before, -.bnRoot *::after { +.bn-root *, +.bn-root *::before, +.bn-root *::after { -webkit-box-sizing: inherit; -moz-box-sizing: inherit; box-sizing: inherit; } /* reset styles, they will be set on blockContent */ -.defaultStyles p, -.defaultStyles h1, -.defaultStyles h2, -.defaultStyles h3, -.defaultStyles li { +.bn-default-styles p, +.bn-default-styles h1, +.bn-default-styles h2, +.bn-default-styles h3, +.bn-default-styles li { all: unset; margin: 0; padding: 0; @@ -44,7 +44,7 @@ Tippy popups that are appended to document.body directly min-width: 2px !important; } -.defaultStyles { +.bn-default-styles { font-size: 16px; font-weight: normal; font-family: "Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, @@ -54,9 +54,16 @@ Tippy popups that are appended to document.body directly -moz-osx-font-smoothing: grayscale; } -.dragPreview { +.bn-table-drop-cursor { position: absolute; - top: -1000px; + z-index: 20; + background-color: #adf; + pointer-events: none; +} + +.bn-drag-preview { + position: absolute; + left: -100000px; } /* Give a remote user a caret */ @@ -85,3 +92,23 @@ Tippy popups that are appended to document.body directly user-select: none; white-space: nowrap; } + +/* table related: */ +.bn-editor table { + width: auto !important; +} +.bn-editor th, +.bn-editor td { + min-width: 1em; + border: 1px solid #ddd; + padding: 3px 5px; +} + +.bn-editor .tableWrapper { + margin: 1em 0; +} + +.bn-editor th { + font-weight: bold; + text-align: left; +} diff --git a/packages/core/src/editor/selectionTypes.ts b/packages/core/src/editor/selectionTypes.ts new file mode 100644 index 0000000000..aef65b5f08 --- /dev/null +++ b/packages/core/src/editor/selectionTypes.ts @@ -0,0 +1,14 @@ +import { + Block, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../schema"; + +export type Selection< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { + blocks: Block[]; +}; diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts new file mode 100644 index 0000000000..ac3d2eb578 --- /dev/null +++ b/packages/core/src/editor/transformPasted.ts @@ -0,0 +1,58 @@ +import { Fragment, Slice } from "@tiptap/pm/model"; +import { EditorView } from "@tiptap/pm/view"; + +// helper function to remove a child from a fragment +function removeChild(node: Fragment, n: number) { + const children: any[] = []; + node.forEach((child, _, i) => { + if (i !== n) { + children.push(child); + } + }); + return Fragment.from(children); +} + +/** + * fix for https://github.com/ProseMirror/prosemirror/issues/1430#issuecomment-1822570821 + * + * Without this fix, pasting two paragraphs would cause the second one to be indented in the other + * this fix wraps every element in the slice in it's own blockContainer, to prevent Prosemirror from nesting the + * elements on paste. + * + * The exception is when we encounter blockGroups with listitems, because those actually should be nested + */ +export function transformPasted(slice: Slice, view: EditorView) { + let f = Fragment.from(slice.content); + for (let i = 0; i < f.childCount; i++) { + if (f.child(i).type.spec.group === "blockContent") { + const content = [f.child(i)]; + + // when there is a blockGroup with lists, it should be nested in the new blockcontainer + // (if we remove this if-block, the nesting bug will be fixed, but lists won't be nested correctly) + if ( + i + 1 < f.childCount && + f.child(i + 1).type.spec.group === "blockGroup" + ) { + const nestedChild = f + .child(i + 1) + .child(0) + .child(0); + + if ( + nestedChild.type.name === "bulletListItem" || + nestedChild.type.name === "numberedListItem" + ) { + content.push(f.child(i + 1)); + f = removeChild(f, i + 1); + } + } + const container = view.state.schema.nodes.blockContainer.create( + undefined, + content + ); + f = f.replaceChild(i, container); + } + } + + return new Slice(f, slice.openStart, slice.openEnd); +} diff --git a/packages/core/src/shared/BaseUiElementTypes.ts b/packages/core/src/extensions-shared/BaseUiElementTypes.ts similarity index 100% rename from packages/core/src/shared/BaseUiElementTypes.ts rename to packages/core/src/extensions-shared/BaseUiElementTypes.ts diff --git a/packages/core/src/extensions-shared/README.md b/packages/core/src/extensions-shared/README.md new file mode 100644 index 0000000000..89c300fd7d --- /dev/null +++ b/packages/core/src/extensions-shared/README.md @@ -0,0 +1,3 @@ +### @blocknote/core/src/extensions-shared + +Helper functions / base plugins for @blocknote/core/src/extensions \ No newline at end of file diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionItem.ts b/packages/core/src/extensions-shared/suggestion/SuggestionItem.ts similarity index 100% rename from packages/core/src/shared/plugins/suggestion/SuggestionItem.ts rename to packages/core/src/extensions-shared/suggestion/SuggestionItem.ts diff --git a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts similarity index 94% rename from packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts rename to packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts index a3c4e4e010..23ff2ad1b6 100644 --- a/packages/core/src/shared/plugins/suggestion/SuggestionPlugin.ts +++ b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts @@ -1,11 +1,13 @@ +import { findParentNode } from "@tiptap/core"; import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; -import { BlockNoteEditor } from "../../../BlockNoteEditor"; -import { BlockSchema } from "../../../extensions/Blocks/api/blockTypes"; -import { findBlock } from "../../../extensions/Blocks/helpers/findBlock"; -import { BaseUiElementState } from "../../BaseUiElementTypes"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; +import { BaseUiElementState } from "../BaseUiElementTypes"; import { SuggestionItem } from "./SuggestionItem"; +const findBlock = findParentNode((node) => node.type.name === "blockContainer"); + export type SuggestionsMenuState = BaseUiElementState & { // The suggested items to display. @@ -16,7 +18,9 @@ export type SuggestionsMenuState = class SuggestionsMenuView< T extends SuggestionItem, - BSchema extends BlockSchema + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema > { private suggestionsMenuState?: SuggestionsMenuState; public updateSuggestionsMenu: () => void; @@ -24,7 +28,7 @@ class SuggestionsMenuView< pluginState: SuggestionPluginState; constructor( - private readonly editor: BlockNoteEditor, + private readonly editor: BlockNoteEditor, private readonly pluginKey: PluginKey, updateSuggestionsMenu: ( suggestionsMenuState: SuggestionsMenuState @@ -147,9 +151,11 @@ function getDefaultPluginState< */ export const setupSuggestionsMenu = < T extends SuggestionItem, - BSchema extends BlockSchema + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema >( - editor: BlockNoteEditor, + editor: BlockNoteEditor, updateSuggestionsMenu: ( suggestionsMenuState: SuggestionsMenuState ) => void, @@ -159,7 +165,7 @@ export const setupSuggestionsMenu = < items: (query: string) => T[] = () => [], onSelectItem: (props: { item: T; - editor: BlockNoteEditor; + editor: BlockNoteEditor; }) => void = () => { // noop } @@ -169,7 +175,7 @@ export const setupSuggestionsMenu = < throw new Error("'char' should be a single character"); } - let suggestionsPluginView: SuggestionsMenuView; + let suggestionsPluginView: SuggestionsMenuView; const deactivate = (view: EditorView) => { view.dispatch(view.state.tr.setMeta(pluginKey, { deactivate: true })); @@ -180,7 +186,7 @@ export const setupSuggestionsMenu = < key: pluginKey, view: () => { - suggestionsPluginView = new SuggestionsMenuView( + suggestionsPluginView = new SuggestionsMenuView( editor, pluginKey, @@ -398,7 +404,7 @@ export const setupSuggestionsMenu = < blockNode.pos + blockNode.node.nodeSize, { nodeName: "span", - class: "suggestion-decorator", + class: "bn-suggestion-decorator", "data-decoration-id": decorationId, } ), @@ -412,7 +418,7 @@ export const setupSuggestionsMenu = < queryStartPos, { nodeName: "span", - class: "suggestion-decorator", + class: "bn-suggestion-decorator", "data-decoration-id": decorationId, } ), diff --git a/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts b/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts index caa76f6416..3f74150ec0 100644 --- a/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts +++ b/packages/core/src/extensions/BackgroundColor/BackgroundColorExtension.ts @@ -1,17 +1,5 @@ import { Extension } from "@tiptap/core"; -import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos"; -import { defaultProps } from "../Blocks/api/defaultProps"; - -declare module "@tiptap/core" { - interface Commands { - blockBackgroundColor: { - setBlockBackgroundColor: ( - posInBlock: number, - color: string - ) => ReturnType; - }; - } -} +import { defaultProps } from "../../blocks/defaultProps"; export const BackgroundColorExtension = Extension.create({ name: "blockBackgroundColor", @@ -37,27 +25,4 @@ export const BackgroundColorExtension = Extension.create({ }, ]; }, - - addCommands() { - return { - setBlockBackgroundColor: - (posInBlock, color) => - ({ state, view }) => { - const blockInfo = getBlockInfoFromPos(state.doc, posInBlock); - if (blockInfo === undefined) { - return false; - } - - state.tr.setNodeAttribute( - blockInfo.startPos - 1, - "backgroundColor", - color - ); - - view.focus(); - - return true; - }, - }; - }, }); diff --git a/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts b/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts index adcdca387f..647705acdc 100644 --- a/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts +++ b/packages/core/src/extensions/BackgroundColor/BackgroundColorMark.ts @@ -1,24 +1,16 @@ import { Mark } from "@tiptap/core"; -import { defaultProps } from "../Blocks/api/defaultProps"; +import { createStyleSpecFromTipTapMark } from "../../schema"; -declare module "@tiptap/core" { - interface Commands { - backgroundColor: { - setBackgroundColor: (color: string) => ReturnType; - }; - } -} - -export const BackgroundColorMark = Mark.create({ +const BackgroundColorMark = Mark.create({ name: "backgroundColor", addAttributes() { return { - color: { + stringValue: { default: undefined, parseHTML: (element) => element.getAttribute("data-background-color"), renderHTML: (attributes) => ({ - "data-background-color": attributes.color, + "data-background-color": attributes.stringValue, }), }, }; @@ -34,7 +26,9 @@ export const BackgroundColorMark = Mark.create({ } if (element.hasAttribute("data-background-color")) { - return { color: element.getAttribute("data-background-color") }; + return { + stringValue: element.getAttribute("data-background-color"), + }; } return false; @@ -46,18 +40,9 @@ export const BackgroundColorMark = Mark.create({ renderHTML({ HTMLAttributes }) { return ["span", HTMLAttributes, 0]; }, - - addCommands() { - return { - setBackgroundColor: - (color) => - ({ commands }) => { - if (color !== defaultProps.backgroundColor.default) { - return commands.setMark(this.name, { color: color }); - } - - return commands.unsetMark(this.name); - }, - }; - }, }); + +export const BackgroundColor = createStyleSpecFromTipTapMark( + BackgroundColorMark, + "string" +); diff --git a/packages/core/src/extensions/Blocks/api/block.ts b/packages/core/src/extensions/Blocks/api/block.ts deleted file mode 100644 index ec8a07e8b9..0000000000 --- a/packages/core/src/extensions/Blocks/api/block.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { Attribute, Attributes, Node } from "@tiptap/core"; -import { BlockNoteDOMAttributes, BlockNoteEditor } from "../../.."; -import styles from "../nodes/Block.module.css"; -import { - BlockConfig, - BlockSchema, - BlockSpec, - PropSchema, - TipTapNode, - TipTapNodeConfig, -} from "./blockTypes"; -import { mergeCSSClasses } from "../../../shared/utils"; -import { ParseRule } from "prosemirror-model"; - -export function camelToDataKebab(str: string): string { - return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); -} - -// Function that uses the 'propSchema' of a blockConfig to create a TipTap -// node's `addAttributes` property. -export function propsToAttributes< - BType extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean, - BSchema extends BlockSchema ->( - blockConfig: Omit< - BlockConfig, - "render" - > -): Attributes { - const tiptapAttributes: Record = {}; - - Object.entries(blockConfig.propSchema).forEach(([name, spec]) => { - tiptapAttributes[name] = { - default: spec.default, - keepOnSplit: true, - // Props are displayed in kebab-case as HTML attributes. If a prop's - // value is the same as its default, we don't display an HTML - // attribute for it. - parseHTML: (element) => { - const value = element.getAttribute(camelToDataKebab(name)); - - if (value === null) { - return null; - } - - if (typeof spec.default === "boolean") { - if (value === "true") { - return true; - } - - if (value === "false") { - return false; - } - - return null; - } - - if (typeof spec.default === "number") { - const asNumber = parseFloat(value); - const isNumeric = - !Number.isNaN(asNumber) && Number.isFinite(asNumber); - - if (isNumeric) { - return asNumber; - } - - return null; - } - - return value; - }, - renderHTML: (attributes) => - attributes[name] !== spec.default - ? { - [camelToDataKebab(name)]: attributes[name], - } - : {}, - }; - }); - - return tiptapAttributes; -} - -// Function that uses the 'parse' function of a blockConfig to create a -// TipTap node's `parseHTML` property. This is only used for parsing content -// from the clipboard. -export function parse< - BType extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean, - BSchema extends BlockSchema ->( - blockConfig: Omit< - BlockConfig, - "render" - > -): ParseRule[] { - return [ - { - tag: "div[data-content-type=" + blockConfig.type + "]", - }, - ]; -} - -// Function that uses the 'render' function of a blockConfig to create a -// TipTap node's `renderHTML` property. Since custom blocks use node views, -// this is only used for serializing content to the clipboard. -export function render< - BType extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean, - BSchema extends BlockSchema ->( - blockConfig: Omit< - BlockConfig, - "render" - >, - HTMLAttributes: Record -) { - // Create blockContent element - const blockContent = document.createElement("div"); - // Add blockContent HTML attribute - blockContent.setAttribute("data-content-type", blockConfig.type); - // Add props as HTML attributes in kebab-case with "data-" prefix - for (const [attribute, value] of Object.entries(HTMLAttributes)) { - blockContent.setAttribute(attribute, value); - } - - // TODO: This only works for content copied within BlockNote. - // Creates contentDOM element to serialize inline content into. - let contentDOM: HTMLDivElement | undefined; - if (blockConfig.containsInlineContent) { - contentDOM = document.createElement("div"); - blockContent.appendChild(contentDOM); - } else { - contentDOM = undefined; - } - - return contentDOM !== undefined - ? { - dom: blockContent, - contentDOM: contentDOM, - } - : { - dom: blockContent, - }; -} - -// A function to create custom block for API consumers -// we want to hide the tiptap node from API consumers and provide a simpler API surface instead -export function createBlockSpec< - BType extends string, - PSchema extends PropSchema, - ContainsInlineContent extends false, - BSchema extends BlockSchema ->( - blockConfig: BlockConfig -): BlockSpec { - const node = createTipTapBlock< - BType, - ContainsInlineContent, - { - editor: BlockNoteEditor; - domAttributes?: BlockNoteDOMAttributes; - } - >({ - name: blockConfig.type, - content: (blockConfig.containsInlineContent - ? "inline*" - : "") as ContainsInlineContent extends true ? "inline*" : "", - selectable: true, - - addAttributes() { - return propsToAttributes(blockConfig); - }, - - parseHTML() { - return parse(blockConfig); - }, - - renderHTML({ HTMLAttributes }) { - return render(blockConfig, HTMLAttributes); - }, - - addNodeView() { - return ({ HTMLAttributes, getPos }) => { - // Create blockContent element - const blockContent = document.createElement("div"); - // Add custom HTML attributes - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; - for (const [attribute, value] of Object.entries( - blockContentDOMAttributes - )) { - if (attribute !== "class") { - blockContent.setAttribute(attribute, value); - } - } - // Set blockContent & custom classes - blockContent.className = mergeCSSClasses( - styles.blockContent, - blockContentDOMAttributes.class - ); - // Add blockContent HTML attribute - blockContent.setAttribute("data-content-type", blockConfig.type); - // Add props as HTML attributes in kebab-case with "data-" prefix - for (const [attribute, value] of Object.entries(HTMLAttributes)) { - blockContent.setAttribute(attribute, value); - } - - // Gets BlockNote editor instance - const editor = this.options.editor! as BlockNoteEditor< - BSchema & { - [k in BType]: BlockSpec; - } - >; - // Gets position of the node - if (typeof getPos === "boolean") { - throw new Error( - "Cannot find node position as getPos is a boolean, not a function." - ); - } - const pos = getPos(); - // Gets TipTap editor instance - const tipTapEditor = editor._tiptapEditor; - // Gets parent blockContainer node - const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); - // Gets block identifier - const blockIdentifier = blockContainer.attrs.id; - - // Get the block - const block = editor.getBlock(blockIdentifier)!; - if (block.type !== blockConfig.type) { - throw new Error("Block type does not match"); - } - - // Render elements - const rendered = blockConfig.render(block as any, editor); - // Add HTML attributes to contentDOM - if (blockConfig.containsInlineContent) { - const contentDOM = (rendered as { contentDOM: HTMLElement }) - .contentDOM; - - const inlineContentDOMAttributes = - this.options.domAttributes?.inlineContent || {}; - // Add custom HTML attributes - for (const [attribute, value] of Object.entries( - inlineContentDOMAttributes - )) { - if (attribute !== "class") { - contentDOM.setAttribute(attribute, value); - } - } - // Merge existing classes with inlineContent & custom classes - contentDOM.className = mergeCSSClasses( - contentDOM.className, - styles.inlineContent, - inlineContentDOMAttributes.class - ); - } - // Add elements to blockContent - blockContent.appendChild(rendered.dom); - - return "contentDOM" in rendered - ? { - dom: blockContent, - contentDOM: rendered.contentDOM, - destroy: rendered.destroy, - } - : { - dom: blockContent, - destroy: rendered.destroy, - }; - }; - }, - }); - - return { - node: node as TipTapNode, - propSchema: blockConfig.propSchema, - }; -} - -export function createTipTapBlock< - Type extends string, - ContainsInlineContent extends boolean, - Options extends { - domAttributes?: BlockNoteDOMAttributes; - } = { - domAttributes?: BlockNoteDOMAttributes; - }, - Storage = any ->( - config: TipTapNodeConfig -): TipTapNode { - // Type cast is needed as Node.name is mutable, though there is basically no - // reason to change it after creation. Alternative is to wrap Node in a new - // class, which I don't think is worth it since we'd only be changing 1 - // attribute to be read only. - return Node.create({ - ...config, - group: "blockContent", - content: config.content, - }) as TipTapNode; -} diff --git a/packages/core/src/extensions/Blocks/api/blockTypes.ts b/packages/core/src/extensions/Blocks/api/blockTypes.ts deleted file mode 100644 index cb686eabc7..0000000000 --- a/packages/core/src/extensions/Blocks/api/blockTypes.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** Define the main block types **/ -import { Node, NodeConfig } from "@tiptap/core"; -import { BlockNoteEditor } from "../../../BlockNoteEditor"; -import { InlineContent, PartialInlineContent } from "./inlineContentTypes"; -import { DefaultBlockSchema } from "./defaultBlocks"; - -export type BlockNoteDOMElement = - | "editor" - | "blockContainer" - | "blockGroup" - | "blockContent" - | "inlineContent"; - -export type BlockNoteDOMAttributes = Partial<{ - [DOMElement in BlockNoteDOMElement]: Record; -}>; - -// A configuration for a TipTap node, but with stricter type constraints on the -// "name" and "content" properties. The "name" property is now always a string -// literal type, and the "content" property can only be "inline*" or "". Used as -// the parameter in `createTipTapNode`. The "group" is also removed as -// `createTipTapNode` always sets it to "blockContent" -export type TipTapNodeConfig< - Name extends string, - ContainsInlineContent extends boolean, - Options extends { - domAttributes?: BlockNoteDOMAttributes; - } = { - domAttributes?: BlockNoteDOMAttributes; - }, - Storage = any -> = { - [K in keyof NodeConfig]: K extends "name" - ? Name - : K extends "content" - ? ContainsInlineContent extends true - ? "inline*" - : "" - : K extends "group" - ? never - : NodeConfig[K]; -} & { - name: Name; - content: ContainsInlineContent extends true ? "inline*" : ""; -}; - -// A TipTap node with stricter type constraints on the "name", "group", and -// "content properties. The "name" property is now a string literal type, and -// the "blockGroup" property is now "blockContent", and the "content" property -// can only be "inline*" or "". Returned by `createTipTapNode`. -export type TipTapNode< - Name extends string, - ContainsInlineContent extends boolean, - Options extends { - domAttributes?: BlockNoteDOMAttributes; - } = { - domAttributes?: BlockNoteDOMAttributes; - }, - Storage = any -> = { - [Key in keyof Node]: Key extends "name" - ? Name - : Key extends "config" - ? { - [ConfigKey in keyof Node< - Options, - Storage - >["config"]]: ConfigKey extends "group" - ? "blockContent" - : ConfigKey extends "content" - ? ContainsInlineContent extends true - ? "inline*" - : "" - : NodeConfig["config"][ConfigKey]; - } & { - group: "blockContent"; - content: ContainsInlineContent extends true ? "inline*" : ""; - } - : Node["config"][Key]; -}; - -// Defines a single prop spec, which includes the default value the prop should -// take and possible values it can take. -export type PropSpec = { - values?: readonly PType[]; - default: PType; -}; - -// Defines multiple block prop specs. The key of each prop is the name of the -// prop, while the value is a corresponding prop spec. This should be included -// in a block config or schema. From a prop schema, we can derive both the props' -// internal implementation (as TipTap node attributes) and the type information -// for the external API. -export type PropSchema = Record>; - -// Defines Props objects for use in Block objects in the external API. Converts -// each prop spec into a union type of its possible values, or a string if no -// values are specified. -export type Props = { - [PName in keyof PSchema]: PSchema[PName]["default"] extends boolean - ? PSchema[PName]["values"] extends readonly boolean[] - ? PSchema[PName]["values"][number] - : boolean - : PSchema[PName]["default"] extends number - ? PSchema[PName]["values"] extends readonly number[] - ? PSchema[PName]["values"][number] - : number - : PSchema[PName]["default"] extends string - ? PSchema[PName]["values"] extends readonly string[] - ? PSchema[PName]["values"][number] - : string - : never; -}; - -// Defines the config for a single block. Meant to be used as an argument to -// `createBlockSpec`, which will create a new block spec from it. This is the -// main way we expect people to create custom blocks as consumers don't need to -// know anything about the TipTap API since the associated nodes are created -// automatically. -export type BlockConfig< - Type extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean, - BSchema extends BlockSchema -> = { - // Attributes to define block in the API as well as a TipTap node. - type: Type; - readonly propSchema: PSchema; - - // Additional attributes to help define block as a TipTap node. - containsInlineContent: ContainsInlineContent; - render: ( - /** - * The custom block to render - */ - block: SpecificBlock< - BSchema & { - [k in Type]: BlockSpec; - }, - Type - >, - /** - * The BlockNote editor instance - * This is typed generically. If you want an editor with your custom schema, you need to - * cast it manually, e.g.: `const e = editor as BlockNoteEditor;` - */ - editor: BlockNoteEditor< - BSchema & { [k in Type]: BlockSpec } - > - // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations - // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics - ) => ContainsInlineContent extends true - ? { - dom: HTMLElement; - contentDOM: HTMLElement; - destroy?: () => void; - } - : { - dom: HTMLElement; - destroy?: () => void; - }; -}; - -// Defines a single block spec, which includes the props that the block has and -// the TipTap node used to implement it. Usually created using `createBlockSpec` -// though it can also be defined from scratch by providing your own TipTap node, -// allowing for more advanced custom blocks. -export type BlockSpec< - Type extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean -> = { - node: TipTapNode; - readonly propSchema: PSchema; -}; - -// Utility type. For a given object block schema, ensures that the key of each -// block spec matches the name of the TipTap node in it. -type NamesMatch< - Blocks extends Record> -> = Blocks extends { - [Type in keyof Blocks]: Type extends string - ? Blocks[Type] extends BlockSpec - ? Blocks[Type] - : never - : never; -} - ? Blocks - : never; - -// Defines multiple block specs. Also ensures that the key of each block schema -// is the same as name of the TipTap node in it. This should be passed in the -// `blocks` option of the BlockNoteEditor. From a block schema, we can derive -// both the blocks' internal implementation (as TipTap nodes) and the type -// information for the external API. -export type BlockSchema = NamesMatch< - Record> ->; - -// Converts each block spec into a Block object without children. We later merge -// them into a union type and add a children property to create the Block and -// PartialBlock objects we use in the external API. -type BlocksWithoutChildren = { - [BType in keyof BSchema]: { - id: string; - type: BType; - props: Props; - content: BSchema[BType]["node"]["config"]["content"] extends "inline*" - ? InlineContent[] - : undefined; - }; -}; - -// Converts each block spec into a Block object without children, merges them -// into a union type, and adds a children property -export type Block = - BlocksWithoutChildren[keyof BlocksWithoutChildren] & { - children: Block[]; - }; - -export type SpecificBlock< - BSchema extends BlockSchema, - BlockType extends keyof BSchema -> = BlocksWithoutChildren[BlockType] & { - children: Block[]; -}; - -// Same as BlockWithoutChildren, but as a partial type with some changes to make -// it easier to create/update blocks in the editor. -type PartialBlocksWithoutChildren = { - [BType in keyof BSchema]: Partial<{ - id: string; - type: BType; - props: Partial>; - content: BSchema[BType]["node"]["config"]["content"] extends "inline*" - ? PartialInlineContent[] | string - : undefined; - }>; -}; - -// Same as Block, but as a partial type with some changes to make it easier to -// create/update blocks in the editor. -export type PartialBlock = - PartialBlocksWithoutChildren[keyof PartialBlocksWithoutChildren] & - Partial<{ - children: PartialBlock[]; - }>; - -export type BlockIdentifier = { id: string } | string; diff --git a/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts b/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts deleted file mode 100644 index eb17e098f3..0000000000 --- a/packages/core/src/extensions/Blocks/api/cursorPositionTypes.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Block, BlockSchema } from "./blockTypes"; - -export type TextCursorPosition = { - block: Block; - prevBlock: Block | undefined; - nextBlock: Block | undefined; -}; diff --git a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts b/packages/core/src/extensions/Blocks/api/defaultBlocks.ts deleted file mode 100644 index 88c7f0f640..0000000000 --- a/packages/core/src/extensions/Blocks/api/defaultBlocks.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Heading } from "../nodes/BlockContent/HeadingBlockContent/HeadingBlockContent"; -import { BulletListItem } from "../nodes/BlockContent/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent"; -import { NumberedListItem } from "../nodes/BlockContent/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent"; -import { Paragraph } from "../nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent"; -import { Image } from "../nodes/BlockContent/ImageBlockContent/ImageBlockContent"; -import { BlockSchema } from "./blockTypes"; - -export const defaultBlockSchema = { - paragraph: Paragraph, - heading: Heading, - bulletListItem: BulletListItem, - numberedListItem: NumberedListItem, - image: Image, -} as const satisfies BlockSchema; - -export type DefaultBlockSchema = typeof defaultBlockSchema; diff --git a/packages/core/src/extensions/Blocks/api/defaultProps.ts b/packages/core/src/extensions/Blocks/api/defaultProps.ts deleted file mode 100644 index b4fa7b97c5..0000000000 --- a/packages/core/src/extensions/Blocks/api/defaultProps.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Props, PropSchema } from "./blockTypes"; - -export const defaultProps = { - backgroundColor: { - default: "default" as const, - }, - textColor: { - default: "default" as const, - }, - textAlignment: { - default: "left" as const, - values: ["left", "center", "right", "justify"] as const, - }, -} satisfies PropSchema; - -export type DefaultProps = Props; diff --git a/packages/core/src/extensions/Blocks/api/inlineContentTypes.ts b/packages/core/src/extensions/Blocks/api/inlineContentTypes.ts deleted file mode 100644 index 9d63930d95..0000000000 --- a/packages/core/src/extensions/Blocks/api/inlineContentTypes.ts +++ /dev/null @@ -1,36 +0,0 @@ -export type Styles = { - bold?: true; - italic?: true; - underline?: true; - strike?: true; - code?: true; - textColor?: string; - backgroundColor?: string; -}; - -export type ToggledStyle = { - [K in keyof Styles]-?: Required[K] extends true ? K : never; -}[keyof Styles]; - -export type ColorStyle = { - [K in keyof Styles]-?: Required[K] extends string ? K : never; -}[keyof Styles]; - -export type StyledText = { - type: "text"; - text: string; - styles: Styles; -}; - -export type Link = { - type: "link"; - href: string; - content: StyledText[]; -}; - -export type PartialLink = Omit & { - content: string | Link["content"]; -}; - -export type InlineContent = StyledText | Link; -export type PartialInlineContent = StyledText | PartialLink; diff --git a/packages/core/src/extensions/Blocks/api/selectionTypes.ts b/packages/core/src/extensions/Blocks/api/selectionTypes.ts deleted file mode 100644 index 8a23f48094..0000000000 --- a/packages/core/src/extensions/Blocks/api/selectionTypes.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Block, BlockSchema } from "./blockTypes"; - -export type Selection = { - blocks: Block[]; -}; diff --git a/packages/core/src/extensions/Blocks/api/serialization.ts b/packages/core/src/extensions/Blocks/api/serialization.ts deleted file mode 100644 index 58557853c3..0000000000 --- a/packages/core/src/extensions/Blocks/api/serialization.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Extension } from "@tiptap/core"; -import { Plugin } from "prosemirror-state"; -import { DOMSerializer, Schema } from "prosemirror-model"; - -const customBlockSerializer = (schema: Schema) => { - const defaultSerializer = DOMSerializer.fromSchema(schema); - - return new DOMSerializer( - { - ...defaultSerializer.nodes, - // TODO: If a serializer is defined in the config for a custom block, it - // should be added here. We still need to figure out how the serializer - // should be defined in the custom blocks API though, and implement that, - // before we can do this. - }, - defaultSerializer.marks - ); -}; -export const CustomBlockSerializerExtension = Extension.create({ - addProseMirrorPlugins() { - return [ - new Plugin({ - props: { - clipboardSerializer: customBlockSerializer(this.editor.schema), - }, - }), - ]; - }, -}); \ No newline at end of file diff --git a/packages/core/src/extensions/Blocks/helpers/findBlock.ts b/packages/core/src/extensions/Blocks/helpers/findBlock.ts deleted file mode 100644 index 8aa24cc90c..0000000000 --- a/packages/core/src/extensions/Blocks/helpers/findBlock.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { findParentNode } from "@tiptap/core"; - -export const findBlock = findParentNode( - (node) => node.type.name === "blockContainer" -); diff --git a/packages/core/src/extensions/Blocks/index.ts b/packages/core/src/extensions/Blocks/index.ts deleted file mode 100644 index 4df0534a64..0000000000 --- a/packages/core/src/extensions/Blocks/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Node } from "@tiptap/core"; -export { BlockContainer } from "./nodes/BlockContainer"; -export { BlockGroup } from "./nodes/BlockGroup"; -export const Doc = Node.create({ - name: "doc", - topNode: true, - content: "blockGroup", -}); diff --git a/packages/core/src/extensions/Blocks/nodes/BlockAttributes.ts b/packages/core/src/extensions/Blocks/nodes/BlockAttributes.ts deleted file mode 100644 index 4109c7bb6e..0000000000 --- a/packages/core/src/extensions/Blocks/nodes/BlockAttributes.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Object containing all possible block attributes. -const BlockAttributes: Record = { - blockColor: "data-block-color", - blockStyle: "data-block-style", - id: "data-id", - depth: "data-depth", - depthChange: "data-depth-change", -}; - -export default BlockAttributes; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts deleted file mode 100644 index ad9d7b73d8..0000000000 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/HeadingBlockContent/HeadingBlockContent.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { InputRule, mergeAttributes } from "@tiptap/core"; -import { defaultProps } from "../../../api/defaultProps"; -import { createTipTapBlock } from "../../../api/block"; -import { BlockSpec, PropSchema } from "../../../api/blockTypes"; -import { mergeCSSClasses } from "../../../../../shared/utils"; -import styles from "../../Block.module.css"; - -export const headingPropSchema = { - ...defaultProps, - level: { default: 1, values: [1, 2, 3] as const }, -} satisfies PropSchema; - -const HeadingBlockContent = createTipTapBlock<"heading", true>({ - name: "heading", - content: "inline*", - - addAttributes() { - return { - level: { - default: 1, - // instead of "level" attributes, use "data-level" - parseHTML: (element) => element.getAttribute("data-level")!, - renderHTML: (attributes) => { - return { - "data-level": (attributes.level as number).toString(), - }; - }, - }, - }; - }, - - addInputRules() { - return [ - ...[1, 2, 3].map((level) => { - // Creates a heading of appropriate level when starting with "#", "##", or "###". - return new InputRule({ - find: new RegExp(`^(#{${level}})\\s$`), - handler: ({ state, chain, range }) => { - chain() - .BNUpdateBlock<{ - heading: BlockSpec<"heading", typeof headingPropSchema, true>; - }>(state.selection.from, { - type: "heading", - props: { - level: level as 1 | 2 | 3, - }, - }) - // Removes the "#" character(s) used to set the heading. - .deleteRange({ from: range.from, to: range.to }); - }, - }); - }), - ]; - }, - - addKeyboardShortcuts() { - return { - "Mod-Alt-1": () => - this.editor.commands.BNUpdateBlock<{ - heading: BlockSpec<"heading", typeof headingPropSchema, true>; - }>(this.editor.state.selection.anchor, { - type: "heading", - props: { - level: 1, - }, - }), - "Mod-Alt-2": () => - this.editor.commands.BNUpdateBlock<{ - heading: BlockSpec<"heading", typeof headingPropSchema, true>; - }>(this.editor.state.selection.anchor, { - type: "heading", - props: { - level: 2, - }, - }), - "Mod-Alt-3": () => - this.editor.commands.BNUpdateBlock<{ - heading: BlockSpec<"heading", typeof headingPropSchema, true>; - }>(this.editor.state.selection.anchor, { - type: "heading", - props: { - level: 3, - }, - }), - }; - }, - - parseHTML() { - return [ - { - tag: "h1", - attrs: { level: 1 }, - node: "heading", - }, - { - tag: "h2", - attrs: { level: 2 }, - node: "heading", - }, - { - tag: "h3", - attrs: { level: 3 }, - node: "heading", - }, - ]; - }, - - renderHTML({ node, HTMLAttributes }) { - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; - const inlineContentDOMAttributes = - this.options.domAttributes?.inlineContent || {}; - - return [ - "div", - mergeAttributes(HTMLAttributes, { - ...blockContentDOMAttributes, - class: mergeCSSClasses( - styles.blockContent, - blockContentDOMAttributes.class - ), - "data-content-type": this.name, - }), - [ - `h${node.attrs.level}`, - { - ...inlineContentDOMAttributes, - class: mergeCSSClasses( - styles.inlineContent, - inlineContentDOMAttributes.class - ), - }, - 0, - ], - ]; - }, -}); - -export const Heading = { - node: HeadingBlockContent, - propSchema: headingPropSchema, -} satisfies BlockSpec<"heading", typeof headingPropSchema, true>; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts b/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts deleted file mode 100644 index 98d5b40435..0000000000 --- a/packages/core/src/extensions/Blocks/nodes/BlockContent/ParagraphBlockContent/ParagraphBlockContent.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { mergeAttributes } from "@tiptap/core"; -import { defaultProps } from "../../../api/defaultProps"; -import { createTipTapBlock } from "../../../api/block"; -import { mergeCSSClasses } from "../../../../../shared/utils"; -import styles from "../../Block.module.css"; - -export const paragraphPropSchema = { - ...defaultProps, -}; - -export const ParagraphBlockContent = createTipTapBlock<"paragraph", true>({ - name: "paragraph", - content: "inline*", - - parseHTML() { - return [ - { - tag: "p", - priority: 200, - node: "paragraph", - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; - const inlineContentDOMAttributes = - this.options.domAttributes?.inlineContent || {}; - - return [ - "div", - mergeAttributes( - { - ...blockContentDOMAttributes, - class: mergeCSSClasses( - styles.blockContent, - blockContentDOMAttributes.class - ), - "data-content-type": this.name, - }, - HTMLAttributes - ), - [ - "p", - { - ...inlineContentDOMAttributes, - class: mergeCSSClasses( - styles.inlineContent, - inlineContentDOMAttributes.class - ), - }, - 0, - ], - ]; - }, -}); - -export const Paragraph = { - node: ParagraphBlockContent, - propSchema: paragraphPropSchema, -}; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts b/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts deleted file mode 100644 index 2f18094b3d..0000000000 --- a/packages/core/src/extensions/Blocks/nodes/BlockGroup.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { mergeAttributes, Node } from "@tiptap/core"; -import styles from "./Block.module.css"; -import { BlockNoteDOMAttributes } from "../api/blockTypes"; -import { mergeCSSClasses } from "../../../shared/utils"; - -export const BlockGroup = Node.create<{ - domAttributes?: BlockNoteDOMAttributes; -}>({ - name: "blockGroup", - group: "blockGroup", - content: "blockContainer+", - - parseHTML() { - return [ - { - tag: "div", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - if (element.getAttribute("data-node-type") === "blockGroup") { - // Null means the element matches, but we don't want to add any attributes to the node. - return null; - } - - return false; - }, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - const blockGroupDOMAttributes = - this.options.domAttributes?.blockGroup || {}; - - return [ - "div", - mergeAttributes( - { - ...blockGroupDOMAttributes, - class: mergeCSSClasses( - styles.blockGroup, - blockGroupDOMAttributes.class - ), - "data-node-type": "blockGroup", - }, - HTMLAttributes - ), - 0, - ]; - }, -}); diff --git a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts index 29d893fccb..0dcdf50262 100644 --- a/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +++ b/packages/core/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts @@ -1,19 +1,20 @@ -import { isNodeSelection, isTextSelection, posToDOMRect } from "@tiptap/core"; +import { isNodeSelection, posToDOMRect } from "@tiptap/core"; import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; + +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { BaseUiElementCallbacks, BaseUiElementState, - BlockNoteEditor, - BlockSchema, -} from "../.."; -import { EventEmitter } from "../../shared/EventEmitter"; +} from "../../extensions-shared/BaseUiElementTypes"; +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; +import { EventEmitter } from "../../util/EventEmitter"; export type FormattingToolbarCallbacks = BaseUiElementCallbacks; export type FormattingToolbarState = BaseUiElementState; -export class FormattingToolbarView { +export class FormattingToolbarView { private formattingToolbarState?: FormattingToolbarState; public updateFormattingToolbar: () => void; @@ -26,21 +27,14 @@ export class FormattingToolbarView { state: EditorState; from: number; to: number; - }) => boolean = ({ 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); - }; + }) => boolean = ({ state }) => !state.selection.empty; constructor( - private readonly editor: BlockNoteEditor, + private readonly editor: BlockNoteEditor< + BlockSchema, + InlineContentSchema, + StyleSchema + >, private readonly pmView: EditorView, updateFormattingToolbar: ( formattingToolbarState: FormattingToolbarState @@ -216,13 +210,11 @@ export const formattingToolbarPluginKey = new PluginKey( "FormattingToolbarPlugin" ); -export class FormattingToolbarProsemirrorPlugin< - BSchema extends BlockSchema -> extends EventEmitter { - private view: FormattingToolbarView | undefined; +export class FormattingToolbarProsemirrorPlugin extends EventEmitter { + private view: FormattingToolbarView | undefined; public readonly plugin: Plugin; - constructor(editor: BlockNoteEditor) { + constructor(editor: BlockNoteEditor) { super(); this.plugin = new Plugin({ key: formattingToolbarPluginKey, diff --git a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts index 65ae274279..30aa623af3 100644 --- a/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts +++ b/packages/core/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts @@ -2,10 +2,10 @@ import { getMarkRange, posToDOMRect, Range } from "@tiptap/core"; import { EditorView } from "@tiptap/pm/view"; import { Mark } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; -import { BlockNoteEditor } from "../../BlockNoteEditor"; -import { BaseUiElementState } from "../../shared/BaseUiElementTypes"; -import { EventEmitter } from "../../shared/EventEmitter"; -import { BlockSchema } from "../Blocks/api/blockTypes"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { BaseUiElementState } from "../../extensions-shared/BaseUiElementTypes"; +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; +import { EventEmitter } from "../../util/EventEmitter"; export type HyperlinkToolbarState = BaseUiElementState & { // The hovered hyperlink's URL, and the text it's displayed with in the @@ -14,11 +14,11 @@ export type HyperlinkToolbarState = BaseUiElementState & { text: string; }; -class HyperlinkToolbarView { +class HyperlinkToolbarView { private hyperlinkToolbarState?: HyperlinkToolbarState; public updateHyperlinkToolbar: () => void; - menuUpdateTimer: NodeJS.Timeout | undefined; + menuUpdateTimer: ReturnType | undefined; startMenuUpdateTimer: () => void; stopMenuUpdateTimer: () => void; @@ -32,7 +32,7 @@ class HyperlinkToolbarView { hyperlinkMarkRange: Range | undefined; constructor( - private readonly editor: BlockNoteEditor, + private readonly editor: BlockNoteEditor, private readonly pmView: EditorView, updateHyperlinkToolbar: ( hyperlinkToolbarState: HyperlinkToolbarState @@ -275,12 +275,14 @@ export const hyperlinkToolbarPluginKey = new PluginKey( ); export class HyperlinkToolbarProsemirrorPlugin< - BSchema extends BlockSchema + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema > extends EventEmitter { - private view: HyperlinkToolbarView | undefined; + private view: HyperlinkToolbarView | undefined; public readonly plugin: Plugin; - constructor(editor: BlockNoteEditor) { + constructor(editor: BlockNoteEditor) { super(); this.plugin = new Plugin({ key: hyperlinkToolbarPluginKey, diff --git a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts index 40a8cd2d59..98fe041755 100644 --- a/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts +++ b/packages/core/src/extensions/ImageToolbar/ImageToolbarPlugin.ts @@ -1,48 +1,44 @@ -import { Node as PMNode } from "prosemirror-model"; import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; + +import { EventEmitter } from "../../util/EventEmitter"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { - BaseUiElementCallbacks, - BaseUiElementState, - BlockNoteEditor, BlockSchema, - BlockSpec, + InlineContentSchema, SpecificBlock, -} from "../.."; -import { EventEmitter } from "../../shared/EventEmitter"; - + StyleSchema, +} from "../../schema"; +import { + BaseUiElementCallbacks, + BaseUiElementState, +} from "../../extensions-shared/BaseUiElementTypes"; export type ImageToolbarCallbacks = BaseUiElementCallbacks; -export type ImageToolbarState = BaseUiElementState & { - block: SpecificBlock< - BlockSchema & { - image: BlockSpec< - "image", - { - src: { default: string }; - }, - false - >; - }, - "image" - >; +export type ImageToolbarState< + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema = StyleSchema +> = BaseUiElementState & { + block: SpecificBlock; }; -export class ImageToolbarView { - private imageToolbarState?: ImageToolbarState; +export class ImageToolbarView< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> { + private imageToolbarState?: ImageToolbarState; public updateImageToolbar: () => void; public prevWasEditable: boolean | null = null; - public shouldShow: (state: EditorState) => boolean = (state) => - "node" in state.selection && - (state.selection.node as PMNode).type.name === "image" && - (state.selection.node as PMNode).attrs.src === ""; - constructor( private readonly pluginKey: PluginKey, private readonly pmView: EditorView, - updateImageToolbar: (imageToolbarState: ImageToolbarState) => void + updateImageToolbar: ( + imageToolbarState: ImageToolbarState + ) => void ) { this.updateImageToolbar = () => { if (!this.imageToolbarState) { @@ -112,18 +108,7 @@ export class ImageToolbarView { update(view: EditorView, prevState: EditorState) { const pluginState: { - block: SpecificBlock< - BlockSchema & { - image: BlockSpec< - "image", - { - src: { default: string }; - }, - false - >; - }, - "image" - >; + block: SpecificBlock; } = this.pluginKey.getState(view.state); if (!this.imageToolbarState?.show && pluginState.block) { @@ -168,28 +153,17 @@ export class ImageToolbarView { export const imageToolbarPluginKey = new PluginKey("ImageToolbarPlugin"); export class ImageToolbarProsemirrorPlugin< - BSchema extends BlockSchema + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema > extends EventEmitter { - private view: ImageToolbarView | undefined; + private view: ImageToolbarView | undefined; public readonly plugin: Plugin; - constructor(_editor: BlockNoteEditor) { + constructor(_editor: BlockNoteEditor) { super(); this.plugin = new Plugin<{ - block: - | SpecificBlock< - BlockSchema & { - image: BlockSpec< - "image", - { - src: { default: string }; - }, - false - >; - }, - "image" - > - | undefined; + block: SpecificBlock | undefined; }>({ key: imageToolbarPluginKey, view: (editorView) => { @@ -210,20 +184,8 @@ export class ImageToolbarProsemirrorPlugin< }; }, apply: (transaction) => { - const block: - | SpecificBlock< - BlockSchema & { - image: BlockSpec< - "image", - { - src: { default: string }; - }, - false - >; - }, - "image" - > - | undefined = transaction.getMeta(imageToolbarPluginKey)?.block; + const block: SpecificBlock | undefined = + transaction.getMeta(imageToolbarPluginKey)?.block; return { block, @@ -233,7 +195,7 @@ export class ImageToolbarProsemirrorPlugin< }); } - public onUpdate(callback: (state: ImageToolbarState) => void) { + public onUpdate(callback: (state: ImageToolbarState) => void) { return this.on("update", callback); } } diff --git a/packages/core/src/extensions/Blocks/NonEditableBlockPlugin.ts b/packages/core/src/extensions/NonEditableBlocks/NonEditableBlockPlugin.ts similarity index 100% rename from packages/core/src/extensions/Blocks/NonEditableBlockPlugin.ts rename to packages/core/src/extensions/NonEditableBlocks/NonEditableBlockPlugin.ts diff --git a/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts b/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts index 7f31321c59..4bfa6ec41f 100644 --- a/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts +++ b/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts @@ -36,10 +36,10 @@ export const Placeholder = Extension.create({ addOptions() { return { - emptyEditorClass: "is-editor-empty", - emptyNodeClass: "is-empty", - isFilterClass: "is-filter", - hasAnchorClass: "has-anchor", + emptyEditorClass: "bn-is-editor-empty", + emptyNodeClass: "bn-is-empty", + isFilterClass: "bn-is-filter", + hasAnchorClass: "bn-has-anchor", placeholder: "Write something …", showOnlyWhenEditable: true, showOnlyCurrent: true, diff --git a/packages/core/src/extensions/Blocks/PreviousBlockTypePlugin.ts b/packages/core/src/extensions/PreviousBlockType/PreviousBlockTypePlugin.ts similarity index 100% rename from packages/core/src/extensions/Blocks/PreviousBlockTypePlugin.ts rename to packages/core/src/extensions/PreviousBlockType/PreviousBlockTypePlugin.ts diff --git a/packages/core/src/extensions/README.md b/packages/core/src/extensions/README.md new file mode 100644 index 0000000000..9272867e4f --- /dev/null +++ b/packages/core/src/extensions/README.md @@ -0,0 +1,3 @@ +### @blocknote/core/src/extensions + +All extra extensions for TipTap / Prosemirror needed to implement the Prosemirror UX and editor behavior. \ No newline at end of file diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index aeee6ce41b..9332b15c36 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -1,28 +1,35 @@ import { PluginView } from "@tiptap/pm/state"; 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 { BlockNoteEditor } from "../../BlockNoteEditor"; -import styles from "../../editor.module.css"; -import { BaseUiElementState } from "../../shared/BaseUiElementTypes"; -import { EventEmitter } from "../../shared/EventEmitter"; -import { Block, BlockSchema } from "../Blocks/api/blockTypes"; -import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos"; +import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTMLExporter"; +import { createInternalHTMLSerializer } from "../../api/exporters/html/internalHTMLSerializer"; +import { cleanHTMLToMarkdown } from "../../api/exporters/markdown/markdownExporter"; +import { getBlockInfoFromPos } from "../../api/getBlockInfoFromPos"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { BaseUiElementState } from "../../extensions-shared/BaseUiElementTypes"; +import { + Block, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../schema"; +import { EventEmitter } from "../../util/EventEmitter"; import { slashMenuPluginKey } from "../SlashMenu/SlashMenuPlugin"; import { MultipleNodeSelection } from "./MultipleNodeSelection"; -const serializeForClipboard = (pv as any).__serializeForClipboard; -// code based on https://github.com/ueberdosis/tiptap/issues/323#issuecomment-506637799 - let dragImageElement: Element | undefined; -export type SideMenuState = BaseUiElementState & { +export type SideMenuState< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = BaseUiElementState & { // The block that the side menu is attached to. - block: Block; + block: Block; }; -function getDraggableBlockFromCoords( +export function getDraggableBlockFromCoords( coords: { left: number; top: number }, view: EditorView ) { @@ -153,18 +160,14 @@ function setDragImage(view: EditorView, from: number, to = from) { const inheritedClasses = classes .filter( (className) => - !className.includes("bn") && - !className.includes("ProseMirror") && - !className.includes("editor") + className !== "ProseMirror" && + className !== "bn-root" && + className !== "bn-editor" ) .join(" "); dragImageElement.className = - dragImageElement.className + - " " + - styles.dragPreview + - " " + - inheritedClasses; + dragImageElement.className + " bn-drag-preview " + inheritedClasses; document.body.appendChild(dragImageElement); } @@ -176,14 +179,20 @@ function unsetDragImage() { } } -function dragStart( +function dragStart< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( e: { dataTransfer: DataTransfer | null; clientY: number }, - view: EditorView + editor: BlockNoteEditor ) { if (!e.dataTransfer) { return; } + const view = editor.prosemirrorView; + const editorBoundingBox = view.dom.getBoundingClientRect(); const coords = { @@ -215,20 +224,38 @@ function dragStart( setDragImage(view, pos); } - const slice = view.state.selection.content(); - const { dom, text } = serializeForClipboard(view, slice); + const selectedSlice = view.state.selection.content(); + const schema = editor._tiptapEditor.schema; + + const internalHTMLSerializer = createInternalHTMLSerializer(schema, editor); + const internalHTML = internalHTMLSerializer.serializeProseMirrorFragment( + selectedSlice.content + ); + + const externalHTMLExporter = createExternalHTMLExporter(schema, editor); + const externalHTML = externalHTMLExporter.exportProseMirrorFragment( + selectedSlice.content + ); + + const plainText = cleanHTMLToMarkdown(externalHTML); e.dataTransfer.clearData(); - e.dataTransfer.setData("text/html", dom.innerHTML); - e.dataTransfer.setData("text/plain", text); + e.dataTransfer.setData("blocknote/html", internalHTML); + e.dataTransfer.setData("text/html", externalHTML); + e.dataTransfer.setData("text/plain", plainText); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setDragImage(dragImageElement!, 0, 0); - view.dragging = { slice, move: true }; + view.dragging = { slice: selectedSlice, move: true }; } } -export class SideMenuView implements PluginView { - private sideMenuState?: SideMenuState; +export class SideMenuView< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> implements PluginView +{ + private sideMenuState?: SideMenuState; // 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 @@ -244,10 +271,10 @@ export class SideMenuView implements PluginView { public menuFrozen = false; constructor( - private readonly editor: BlockNoteEditor, + private readonly editor: BlockNoteEditor, private readonly pmView: EditorView, private readonly updateSideMenu: ( - sideMenuState: SideMenuState + sideMenuState: SideMenuState ) => void ) { this.horizontalPosAnchoredAtRoot = true; @@ -552,12 +579,14 @@ export class SideMenuView implements PluginView { export const sideMenuPluginKey = new PluginKey("SideMenuPlugin"); export class SideMenuProsemirrorPlugin< - BSchema extends BlockSchema + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema > extends EventEmitter { - private sideMenuView: SideMenuView | undefined; + private sideMenuView: SideMenuView | undefined; public readonly plugin: Plugin; - constructor(private readonly editor: BlockNoteEditor) { + constructor(private readonly editor: BlockNoteEditor) { super(); this.plugin = new Plugin({ key: sideMenuPluginKey, @@ -574,7 +603,7 @@ export class SideMenuProsemirrorPlugin< }); } - public onUpdate(callback: (state: SideMenuState) => void) { + public onUpdate(callback: (state: SideMenuState) => void) { return this.on("update", callback); } @@ -592,7 +621,7 @@ export class SideMenuProsemirrorPlugin< clientY: number; }) => { this.sideMenuView!.isDragging = true; - dragStart(event, this.editor.prosemirrorView); + dragStart(event, this.editor); }; /** diff --git a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts b/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts index 41fc78917c..42d42bebbd 100644 --- a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts +++ b/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts @@ -1,11 +1,12 @@ -import { SuggestionItem } from "../../shared/plugins/suggestion/SuggestionItem"; -import { BlockNoteEditor } from "../../BlockNoteEditor"; -import { BlockSchema } from "../Blocks/api/blockTypes"; -import { DefaultBlockSchema } from "../Blocks/api/defaultBlocks"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { SuggestionItem } from "../../extensions-shared/suggestion/SuggestionItem"; +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; export type BaseSlashMenuItem< - BSchema extends BlockSchema = DefaultBlockSchema + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema > = SuggestionItem & { - execute: (editor: BlockNoteEditor) => void; + execute: (editor: BlockNoteEditor) => void; aliases?: string[]; }; diff --git a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts index a190cc3209..c58a32cbce 100644 --- a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts +++ b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts @@ -1,26 +1,28 @@ import { Plugin, PluginKey } from "prosemirror-state"; -import { BlockNoteEditor } from "../../BlockNoteEditor"; -import { EventEmitter } from "../../shared/EventEmitter"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { SuggestionsMenuState, setupSuggestionsMenu, -} from "../../shared/plugins/suggestion/SuggestionPlugin"; -import { BlockSchema } from "../Blocks/api/blockTypes"; +} from "../../extensions-shared/suggestion/SuggestionPlugin"; +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; +import { EventEmitter } from "../../util/EventEmitter"; import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; export const slashMenuPluginKey = new PluginKey("SlashMenuPlugin"); export class SlashMenuProsemirrorPlugin< BSchema extends BlockSchema, - SlashMenuItem extends BaseSlashMenuItem + I extends InlineContentSchema, + S extends StyleSchema, + SlashMenuItem extends BaseSlashMenuItem > extends EventEmitter { public readonly plugin: Plugin; public readonly itemCallback: (item: SlashMenuItem) => void; - constructor(editor: BlockNoteEditor, items: SlashMenuItem[]) { + constructor(editor: BlockNoteEditor, items: SlashMenuItem[]) { super(); - const suggestions = setupSuggestionsMenu( + const suggestions = setupSuggestionsMenu( editor, (state) => { this.emit("update", state); diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index 8646ee1b99..b6b3aa0115 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -1,42 +1,87 @@ -import { BlockNoteEditor } from "../../BlockNoteEditor"; -import { BlockSchema, PartialBlock } from "../Blocks/api/blockTypes"; -import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; -import { defaultBlockSchema } from "../Blocks/api/defaultBlocks"; +import { defaultBlockSchema } from "../../blocks/defaultBlocks"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { + Block, + BlockSchema, + InlineContentSchema, + PartialBlock, + StyleSchema, + isStyledTextInlineContent, +} from "../../schema"; import { imageToolbarPluginKey } from "../ImageToolbar/ImageToolbarPlugin"; +import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; + +// Sets the editor's text cursor position to the next content editable block, +// so either a block with inline content or a table. The last block is always a +// paragraph, so this function won't try to set the cursor position past the +// last block. +function setSelectionToNextContentEditableBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>(editor: BlockNoteEditor) { + let block = editor.getTextCursorPosition().block; + let contentType = editor.blockSchema[block.type].content; + + while (contentType === "none") { + block = editor.getTextCursorPosition().nextBlock!; + contentType = editor.blockSchema[block.type].content as + | "inline" + | "table" + | "none"; + editor.setTextCursorPosition(block, "end"); + } +} -function insertOrUpdateBlock( - editor: BlockNoteEditor, - block: PartialBlock -) { +// Checks if the current block is empty or only contains a slash, and if so, +// updates the current block instead of inserting a new one below. If the new +// block doesn't contain editable content, the cursor is moved to the next block +// that does. +function insertOrUpdateBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor, + block: PartialBlock +): Block { const currentBlock = editor.getTextCursorPosition().block; if (currentBlock.content === undefined) { - throw new Error( - "Slash Menu open in a block that doesn't contain inline content." - ); + throw new Error("Slash Menu open in a block that doesn't contain content."); } if ( - (currentBlock.content.length === 1 && + Array.isArray(currentBlock.content) && + ((currentBlock.content.length === 1 && + isStyledTextInlineContent(currentBlock.content[0]) && currentBlock.content[0].type === "text" && currentBlock.content[0].text === "/") || - currentBlock.content.length === 0 + currentBlock.content.length === 0) ) { editor.updateBlock(currentBlock, block); } else { editor.insertBlocks([block], currentBlock, "after"); - editor.setTextCursorPosition(editor.getTextCursorPosition().nextBlock!); + editor.setTextCursorPosition( + editor.getTextCursorPosition().nextBlock!, + "end" + ); } + + const insertedBlock = editor.getTextCursorPosition().block; + setSelectionToNextContentEditableBlock(editor); + + return insertedBlock; } -export const getDefaultSlashMenuItems = ( - // This type casting is weird, but it's the best way of doing it, as it allows - // the schema type to be automatically inferred if it is defined, or be - // inferred as any if it is not defined. I don't think it's possible to make it - // infer to DefaultBlockSchema if it is not defined. +export const getDefaultSlashMenuItems = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( schema: BSchema = defaultBlockSchema as unknown as BSchema ) => { - const slashMenuItems: BaseSlashMenuItem[] = []; + const slashMenuItems: BaseSlashMenuItem[] = []; if ("heading" in schema && "level" in schema.heading.propSchema) { // Command for creating a level 1 heading @@ -48,7 +93,7 @@ export const getDefaultSlashMenuItems = ( insertOrUpdateBlock(editor, { type: "heading", props: { level: 1 }, - } as PartialBlock), + } as PartialBlock), }); } @@ -61,7 +106,7 @@ export const getDefaultSlashMenuItems = ( insertOrUpdateBlock(editor, { type: "heading", props: { level: 2 }, - } as PartialBlock), + } as PartialBlock), }); } @@ -74,7 +119,7 @@ export const getDefaultSlashMenuItems = ( insertOrUpdateBlock(editor, { type: "heading", props: { level: 3 }, - } as PartialBlock), + } as PartialBlock), }); } } @@ -86,7 +131,7 @@ export const getDefaultSlashMenuItems = ( execute: (editor) => insertOrUpdateBlock(editor, { type: "bulletListItem", - } as PartialBlock), + }), }); } @@ -97,7 +142,7 @@ export const getDefaultSlashMenuItems = ( execute: (editor) => insertOrUpdateBlock(editor, { type: "numberedListItem", - } as PartialBlock), + }), }); } @@ -108,7 +153,30 @@ export const getDefaultSlashMenuItems = ( execute: (editor) => insertOrUpdateBlock(editor, { type: "paragraph", - } as PartialBlock), + }), + }); + } + + if ("table" in schema) { + slashMenuItems.push({ + name: "Table", + aliases: ["table"], + execute: (editor) => { + insertOrUpdateBlock(editor, { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: ["", "", ""], + }, + { + cells: ["", "", ""], + }, + ], + }, + } as PartialBlock); + }, }); } @@ -127,19 +195,14 @@ export const getDefaultSlashMenuItems = ( "dropbox", ], execute: (editor) => { - insertOrUpdateBlock(editor, { + const insertedBlock = insertOrUpdateBlock(editor, { type: "image", - } as PartialBlock); - // Don't want to select the add image button, instead select the block - // below it - editor.setTextCursorPosition( - editor.getTextCursorPosition().nextBlock!, - "start" - ); + }); + // Immediately open the image toolbar editor._tiptapEditor.view.dispatch( editor._tiptapEditor.state.tr.setMeta(imageToolbarPluginKey, { - block: editor.getTextCursorPosition().prevBlock, + block: insertedBlock, }) ); }, diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts new file mode 100644 index 0000000000..9a8f03615e --- /dev/null +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -0,0 +1,617 @@ +import { Plugin, PluginKey, PluginView } from "prosemirror-state"; +import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; +import { EventEmitter } from "../../util/EventEmitter"; +import { nodeToBlock } from "../../api/nodeConversions/nodeConversions"; +import { DefaultBlockSchema } from "../../blocks/defaultBlocks"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { + Block, + BlockFromConfigNoChildren, + BlockSchemaWithBlock, + InlineContentSchema, + PartialBlock, + SpecificBlock, + StyleSchema, +} from "../../schema"; +import { getDraggableBlockFromCoords } from "../SideMenu/SideMenuPlugin"; + +let dragImageElement: HTMLElement | undefined; + +function setHiddenDragImage() { + if (dragImageElement) { + return; + } + + dragImageElement = document.createElement("div"); + dragImageElement.innerHTML = "_"; + dragImageElement.style.visibility = "hidden"; + document.body.appendChild(dragImageElement); +} + +function unsetHiddenDragImage() { + if (dragImageElement) { + document.body.removeChild(dragImageElement); + dragImageElement = undefined; + } +} + +export type TableHandlesState< + I extends InlineContentSchema, + S extends StyleSchema +> = { + show: boolean; + referencePosCell: DOMRect; + referencePosTable: DOMRect; + + block: BlockFromConfigNoChildren; + colIndex: number; + rowIndex: number; + + draggingState: + | { + draggedCellOrientation: "row" | "col"; + originalIndex: number; + mousePos: number; + } + | undefined; +}; + +function getChildIndex(node: Element) { + return Array.prototype.indexOf.call(node.parentElement!.childNodes, node); +} + +// Finds the DOM element corresponding to the table cell that the target element +// is currently in. If the target element is not in a table cell, returns null. +function domCellAround(target: Element | null): Element | null { + while (target && target.nodeName !== "TD" && target.nodeName !== "TH") { + target = + target.classList && target.classList.contains("ProseMirror") + ? null + : (target.parentNode as Element); + } + return target; +} + +// Hides elements in the DOMwith the provided class names. +function hideElementsWithClassNames(classNames: string[]) { + classNames.forEach((className) => { + const elementsToHide = document.getElementsByClassName(className); + for (let i = 0; i < elementsToHide.length; i++) { + (elementsToHide[i] as HTMLElement).style.visibility = "hidden"; + } + }); +} + +export class TableHandlesView< + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I extends InlineContentSchema, + S extends StyleSchema +> implements PluginView +{ + public state?: TableHandlesState; + public updateState: () => void; + + public tableId: string | undefined; + public tablePos: number | undefined; + + public menuFrozen = false; + + public prevWasEditable: boolean | null = null; + + constructor( + private readonly editor: BlockNoteEditor, + private readonly pmView: EditorView, + updateState: (state: TableHandlesState) => void + ) { + this.updateState = () => { + if (!this.state) { + throw new Error("Attempting to update uninitialized image toolbar"); + } + + updateState(this.state); + }; + + pmView.dom.addEventListener("mousemove", this.mouseMoveHandler); + + document.addEventListener("dragover", this.dragOverHandler); + document.addEventListener("drop", this.dropHandler); + + document.addEventListener("scroll", this.scrollHandler); + } + + mouseMoveHandler = (event: MouseEvent) => { + if (this.menuFrozen) { + return; + } + + const target = domCellAround(event.target as HTMLElement); + + if (!target || !this.editor.isEditable) { + if (this.state?.show) { + this.state.show = false; + this.updateState(); + } + return; + } + + const colIndex = getChildIndex(target); + const rowIndex = getChildIndex(target.parentElement!); + const cellRect = target.getBoundingClientRect(); + const tableRect = + target.parentElement!.parentElement!.getBoundingClientRect(); + + const blockEl = getDraggableBlockFromCoords(cellRect, this.pmView); + if (!blockEl) { + throw new Error( + "Found table cell element, but could not find surrounding blockContent element." + ); + } + this.tableId = blockEl.id; + + if ( + this.state !== undefined && + this.state.show && + this.tableId === blockEl.id && + this.state.rowIndex === rowIndex && + this.state.colIndex === colIndex + ) { + return; + } + + let block: Block | undefined = undefined; + + // Copied from `getBlock`. We don't use `getBlock` since we also need the PM + // node for the table, so we would effectively be doing the same work twice. + this.editor._tiptapEditor.state.doc.descendants((node, pos) => { + if (typeof block !== "undefined") { + return false; + } + + if (node.type.name !== "blockContainer" || node.attrs.id !== blockEl.id) { + return true; + } + + block = nodeToBlock( + node, + this.editor.blockSchema, + this.editor.inlineContentSchema, + this.editor.styleSchema, + this.editor.blockCache + ); + this.tablePos = pos + 1; + + return false; + }); + + this.state = { + show: true, + referencePosCell: cellRect, + referencePosTable: tableRect, + + block: block! as SpecificBlock, + colIndex: colIndex, + rowIndex: rowIndex, + + draggingState: undefined, + }; + this.updateState(); + + return false; + }; + + dragOverHandler = (event: DragEvent) => { + if (this.state?.draggingState === undefined) { + return; + } + + event.preventDefault(); + event.dataTransfer!.dropEffect = "move"; + + hideElementsWithClassNames([ + "column-resize-handle", + "prosemirror-dropcursor-block", + "prosemirror-dropcursor-inline", + ]); + + // The mouse cursor coordinates, bounded to the table's bounding box. The + // bounding box is shrunk by 1px on each side to ensure that the bounded + // coordinates are always inside a table cell. + const boundedMouseCoords = { + left: Math.min( + Math.max(event.clientX, this.state.referencePosTable.left + 1), + this.state.referencePosTable.right - 1 + ), + top: Math.min( + Math.max(event.clientY, this.state.referencePosTable.top + 1), + this.state.referencePosTable.bottom - 1 + ), + }; + + // Gets the table cell element that the bounded mouse cursor coordinates lie + // in. + const tableCellElements = document + .elementsFromPoint(boundedMouseCoords.left, boundedMouseCoords.top) + .filter( + (element) => element.tagName === "TD" || element.tagName === "TH" + ); + if (tableCellElements.length === 0) { + throw new Error( + "Could not find table cell element that the mouse cursor is hovering over." + ); + } + const tableCellElement = tableCellElements[0]; + + let emitStateUpdate = false; + + // Gets current row and column index. + const rowIndex = getChildIndex(tableCellElement.parentElement!); + const colIndex = getChildIndex(tableCellElement); + + // Checks if the drop cursor needs to be updated. This affects decorations + // only so it doesn't trigger a state update. + const oldIndex = + this.state.draggingState.draggedCellOrientation === "row" + ? this.state.rowIndex + : this.state.colIndex; + const newIndex = + this.state.draggingState.draggedCellOrientation === "row" + ? rowIndex + : colIndex; + const dispatchDecorationsTransaction = newIndex !== oldIndex; + + // Checks if either the hovered cell has changed and updates the row and + // column index. Also updates the reference DOMRect. + if (this.state.rowIndex !== rowIndex || this.state.colIndex !== colIndex) { + this.state.rowIndex = rowIndex; + this.state.colIndex = colIndex; + + this.state.referencePosCell = tableCellElement.getBoundingClientRect(); + + emitStateUpdate = true; + } + + // Checks if the mouse cursor position along the axis that the user is + // dragging on has changed and updates it. + const mousePos = + this.state.draggingState.draggedCellOrientation === "row" + ? boundedMouseCoords.top + : boundedMouseCoords.left; + if (this.state.draggingState.mousePos !== mousePos) { + this.state.draggingState.mousePos = mousePos; + + emitStateUpdate = true; + } + + // Emits a state update if any of the fields have changed. + if (emitStateUpdate) { + this.updateState(); + } + + // Dispatches a dummy transaction to force a decorations update if + // necessary. + if (dispatchDecorationsTransaction) { + this.pmView.dispatch( + this.pmView.state.tr.setMeta(tableHandlesPluginKey, true) + ); + } + }; + + dropHandler = (event: DragEvent) => { + if (this.state === undefined || this.state.draggingState === undefined) { + return; + } + + event.preventDefault(); + + const rows = this.state.block.content.rows; + + if (this.state.draggingState.draggedCellOrientation === "row") { + const rowToMove = rows[this.state.draggingState.originalIndex]; + rows.splice(this.state.draggingState.originalIndex, 1); + rows.splice(this.state.rowIndex, 0, rowToMove); + } else { + const cellsToMove = rows.map( + (row) => row.cells[this.state!.draggingState!.originalIndex] + ); + rows.forEach((row, rowIndex) => { + row.cells.splice(this.state!.draggingState!.originalIndex, 1); + row.cells.splice(this.state!.colIndex, 0, cellsToMove[rowIndex]); + }); + } + + this.editor.updateBlock(this.state.block, { + type: "table", + content: { + type: "tableContent", + rows: rows, + }, + } as PartialBlock); + }; + + scrollHandler = () => { + if (this.state?.show) { + const tableElement = document.querySelector( + `[data-node-type="blockContainer"][data-id="${this.tableId}"] table` + )!; + const cellElement = tableElement.querySelector( + `tr:nth-child(${this.state.rowIndex + 1}) > td:nth-child(${ + this.state.colIndex + 1 + })` + )!; + + this.state.referencePosTable = tableElement.getBoundingClientRect(); + this.state.referencePosCell = cellElement.getBoundingClientRect(); + this.updateState(); + } + }; + + destroy() { + this.pmView.dom.removeEventListener("mousedown", this.mouseMoveHandler); + + document.removeEventListener("dragover", this.dragOverHandler); + document.removeEventListener("drop", this.dropHandler); + + document.removeEventListener("scroll", this.scrollHandler); + } +} + +export const tableHandlesPluginKey = new PluginKey("TableHandlesPlugin"); + +export class TableHandlesProsemirrorPlugin< + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I extends InlineContentSchema, + S extends StyleSchema +> extends EventEmitter { + private view: TableHandlesView | undefined; + public readonly plugin: Plugin; + + constructor(private readonly editor: BlockNoteEditor) { + super(); + this.plugin = new Plugin({ + key: tableHandlesPluginKey, + view: (editorView) => { + this.view = new TableHandlesView(editor, editorView, (state) => { + this.emit("update", state); + }); + return this.view; + }, + // We use decorations to render the drop cursor when dragging a table row + // or column. The decorations are updated in the `dragOverHandler` method. + props: { + decorations: (state) => { + if ( + this.view === undefined || + this.view.state === undefined || + this.view.state.draggingState === undefined || + this.view.tablePos === undefined + ) { + return; + } + + const newIndex = + this.view.state.draggingState.draggedCellOrientation === "row" + ? this.view.state.rowIndex + : this.view.state.colIndex; + + const decorations: Decoration[] = []; + + if (newIndex === this.view.state.draggingState.originalIndex) { + return DecorationSet.create(state.doc, decorations); + } + + // Gets the table to show the drop cursor in. + const tableResolvedPos = state.doc.resolve(this.view.tablePos + 1); + const tableNode = tableResolvedPos.node(); + + if (this.view.state.draggingState.draggedCellOrientation === "row") { + // Gets the row at the new index. + const rowResolvedPos = state.doc.resolve( + tableResolvedPos.posAtIndex(newIndex) + 1 + ); + const rowNode = rowResolvedPos.node(); + + // Iterates over all cells in the row. + for (let i = 0; i < rowNode.childCount; i++) { + // Gets each cell in the row. + const cellResolvedPos = state.doc.resolve( + rowResolvedPos.posAtIndex(i) + 1 + ); + const cellNode = cellResolvedPos.node(); + + // Creates a decoration at the start or end of each cell, + // depending on whether the new index is before or after the + // original index. + const decorationPos = + cellResolvedPos.pos + + (newIndex > this.view.state.draggingState.originalIndex + ? cellNode.nodeSize - 2 + : 0); + decorations.push( + // The widget is a small bar which spans the width of the cell. + Decoration.widget(decorationPos, () => { + const widget = document.createElement("div"); + widget.className = "bn-table-drop-cursor"; + widget.style.left = "0"; + widget.style.right = "0"; + // This is only necessary because the drop indicator's height + // is an even number of pixels, whereas the border between + // table cells is an odd number of pixels. So this makes the + // positioning slightly more consistent regardless of where + // the row is being dropped. + if ( + newIndex > this.view!.state!.draggingState!.originalIndex + ) { + widget.style.bottom = "-2px"; + } else { + widget.style.top = "-3px"; + } + widget.style.height = "4px"; + + return widget; + }) + ); + } + } else { + // Iterates over all rows in the table. + for (let i = 0; i < tableNode.childCount; i++) { + // Gets each row in the table. + const rowResolvedPos = state.doc.resolve( + tableResolvedPos.posAtIndex(i) + 1 + ); + + // Gets the cell at the new index in the row. + const cellResolvedPos = state.doc.resolve( + rowResolvedPos.posAtIndex(newIndex) + 1 + ); + const cellNode = cellResolvedPos.node(); + + // Creates a decoration at the start or end of each cell, + // depending on whether the new index is before or after the + // original index. + const decorationPos = + cellResolvedPos.pos + + (newIndex > this.view.state.draggingState.originalIndex + ? cellNode.nodeSize - 2 + : 0); + decorations.push( + // The widget is a small bar which spans the height of the cell. + Decoration.widget(decorationPos, () => { + const widget = document.createElement("div"); + widget.className = "bn-table-drop-cursor"; + widget.style.top = "0"; + widget.style.bottom = "0"; + // This is only necessary because the drop indicator's width + // is an even number of pixels, whereas the border between + // table cells is an odd number of pixels. So this makes the + // positioning slightly more consistent regardless of where + // the column is being dropped. + if ( + newIndex > this.view!.state!.draggingState!.originalIndex + ) { + widget.style.right = "-2px"; + } else { + widget.style.left = "-3px"; + } + widget.style.width = "4px"; + + return widget; + }) + ); + } + } + + return DecorationSet.create(state.doc, decorations); + }, + }, + }); + } + + public onUpdate(callback: (state: TableHandlesState) => void) { + return this.on("update", callback); + } + + /** + * Callback that should be set on the `dragStart` event for whichever element + * is used as the column drag handle. + */ + colDragStart = (event: { + dataTransfer: DataTransfer | null; + clientX: number; + }) => { + if (this.view!.state === undefined) { + throw new Error( + "Attempted to drag table column, but no table block was hovered prior." + ); + } + + this.view!.state.draggingState = { + draggedCellOrientation: "col", + originalIndex: this.view!.state.colIndex, + mousePos: event.clientX, + }; + this.view!.updateState(); + + this.editor._tiptapEditor.view.dispatch( + this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, { + draggedCellOrientation: + this.view!.state.draggingState.draggedCellOrientation, + originalIndex: this.view!.state.colIndex, + newIndex: this.view!.state.colIndex, + tablePos: this.view!.tablePos, + }) + ); + + setHiddenDragImage(); + event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); + event.dataTransfer!.effectAllowed = "move"; + }; + + /** + * Callback that should be set on the `dragStart` event for whichever element + * is used as the row drag handle. + */ + rowDragStart = (event: { + dataTransfer: DataTransfer | null; + clientY: number; + }) => { + if (this.view!.state === undefined) { + throw new Error( + "Attempted to drag table row, but no table block was hovered prior." + ); + } + + this.view!.state.draggingState = { + draggedCellOrientation: "row", + originalIndex: this.view!.state.rowIndex, + mousePos: event.clientY, + }; + this.view!.updateState(); + + this.editor._tiptapEditor.view.dispatch( + this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, { + draggedCellOrientation: + this.view!.state.draggingState.draggedCellOrientation, + originalIndex: this.view!.state.rowIndex, + newIndex: this.view!.state.rowIndex, + tablePos: this.view!.tablePos, + }) + ); + + setHiddenDragImage(); + event.dataTransfer!.setDragImage(dragImageElement!, 0, 0); + event.dataTransfer!.effectAllowed = "copyMove"; + }; + + /** + * Callback that should be set on the `dragEnd` event for both the element + * used as the row drag handle, and the one used as the column drag handle. + */ + dragEnd = () => { + if (this.view!.state === undefined) { + throw new Error( + "Attempted to drag table row, but no table block was hovered prior." + ); + } + + this.view!.state.draggingState = undefined; + this.view!.updateState(); + + this.editor._tiptapEditor.view.dispatch( + this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, null) + ); + + unsetHiddenDragImage(); + }; + + /** + * Freezes the drag handles. When frozen, they will stay attached to the same + * cell regardless of which cell is hovered by the mouse cursor. + */ + freezeHandles = () => (this.view!.menuFrozen = true); + + /** + * Unfreezes the drag handles. When frozen, they will stay attached to the + * same cell regardless of which cell is hovered by the mouse cursor. + */ + unfreezeHandles = () => (this.view!.menuFrozen = false); +} diff --git a/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts b/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts index 6a99548918..7f9fb505ea 100644 --- a/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts +++ b/packages/core/src/extensions/TextAlignment/TextAlignmentExtension.ts @@ -1,15 +1,4 @@ import { Extension } from "@tiptap/core"; -import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos"; - -declare module "@tiptap/core" { - interface Commands { - textAlignment: { - setTextAlignment: ( - textAlignment: "left" | "center" | "right" | "justify" - ) => ReturnType; - }; - } -} export const TextAlignmentExtension = Extension.create({ name: "textAlignment", @@ -23,7 +12,9 @@ export const TextAlignmentExtension = Extension.create({ attributes: { textAlignment: { default: "left", - parseHTML: (element) => element.getAttribute("data-text-alignment"), + parseHTML: (element) => { + return element.getAttribute("data-text-alignment"); + }, renderHTML: (attributes) => attributes.textAlignment !== "left" && { "data-text-alignment": attributes.textAlignment, @@ -33,43 +24,4 @@ export const TextAlignmentExtension = Extension.create({ }, ]; }, - - addCommands() { - return { - setTextAlignment: - (textAlignment) => - ({ state }) => { - const positionsBeforeSelectedContent = []; - - const blockInfo = getBlockInfoFromPos( - state.doc, - state.selection.from - ); - if (blockInfo === undefined) { - return false; - } - - // Finds all blockContent nodes that the current selection is in. - let pos = blockInfo.startPos; - while (pos < state.selection.to) { - if ( - state.doc.resolve(pos).node().type.spec.group === "blockContent" - ) { - positionsBeforeSelectedContent.push(pos - 1); - - pos += state.doc.resolve(pos).node().nodeSize - 1; - } else { - pos += 1; - } - } - - // Sets text alignment for all blockContent nodes that the current selection is in. - for (const pos of positionsBeforeSelectedContent) { - state.tr.setNodeAttribute(pos, "textAlignment", textAlignment); - } - - return true; - }, - }; - }, }); diff --git a/packages/core/src/extensions/TextColor/TextColorExtension.ts b/packages/core/src/extensions/TextColor/TextColorExtension.ts index a3ab7b8db8..6fe0d0f810 100644 --- a/packages/core/src/extensions/TextColor/TextColorExtension.ts +++ b/packages/core/src/extensions/TextColor/TextColorExtension.ts @@ -1,14 +1,5 @@ import { Extension } from "@tiptap/core"; -import { getBlockInfoFromPos } from "../Blocks/helpers/getBlockInfoFromPos"; -import { defaultProps } from "../Blocks/api/defaultProps"; - -declare module "@tiptap/core" { - interface Commands { - blockTextColor: { - setBlockTextColor: (posInBlock: number, color: string) => ReturnType; - }; - } -} +import { defaultProps } from "../../blocks/defaultProps"; export const TextColorExtension = Extension.create({ name: "blockTextColor", @@ -33,23 +24,4 @@ export const TextColorExtension = Extension.create({ }, ]; }, - - addCommands() { - return { - setBlockTextColor: - (posInBlock, color) => - ({ state, view }) => { - const blockInfo = getBlockInfoFromPos(state.doc, posInBlock); - if (blockInfo === undefined) { - return false; - } - - state.tr.setNodeAttribute(blockInfo.startPos - 1, "textColor", color); - - view.focus(); - - return true; - }, - }; - }, }); diff --git a/packages/core/src/extensions/TextColor/TextColorMark.ts b/packages/core/src/extensions/TextColor/TextColorMark.ts index ce8a0cb4ca..c4538f0c94 100644 --- a/packages/core/src/extensions/TextColor/TextColorMark.ts +++ b/packages/core/src/extensions/TextColor/TextColorMark.ts @@ -1,24 +1,16 @@ import { Mark } from "@tiptap/core"; -import { defaultProps } from "../Blocks/api/defaultProps"; +import { createStyleSpecFromTipTapMark } from "../../schema"; -declare module "@tiptap/core" { - interface Commands { - textColor: { - setTextColor: (color: string) => ReturnType; - }; - } -} - -export const TextColorMark = Mark.create({ +const TextColorMark = Mark.create({ name: "textColor", addAttributes() { return { - color: { + stringValue: { default: undefined, parseHTML: (element) => element.getAttribute("data-text-color"), renderHTML: (attributes) => ({ - "data-text-color": attributes.color, + "data-text-color": attributes.stringValue, }), }, }; @@ -34,7 +26,7 @@ export const TextColorMark = Mark.create({ } if (element.hasAttribute("data-text-color")) { - return { color: element.getAttribute("data-text-color") }; + return { stringValue: element.getAttribute("data-text-color") }; } return false; @@ -46,18 +38,6 @@ export const TextColorMark = Mark.create({ renderHTML({ HTMLAttributes }) { return ["span", HTMLAttributes, 0]; }, - - addCommands() { - return { - setTextColor: - (color) => - ({ commands }) => { - if (color !== defaultProps.textColor.default) { - return commands.setMark(this.name, { color: color }); - } - - return commands.unsetMark(this.name); - }, - }; - }, }); + +export const TextColor = createStyleSpecFromTipTapMark(TextColorMark, "string"); diff --git a/packages/core/src/extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/UniqueID/UniqueID.ts index 06f710a9cd..97bb69a625 100644 --- a/packages/core/src/extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/UniqueID/UniqueID.ts @@ -313,4 +313,3 @@ const UniqueID = Extension.create({ }); export { UniqueID as default, UniqueID }; -//# sourceMappingURL=tiptap-extension-unique-id.esm.js.map diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 08525b7fe2..9678ae5a55 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,14 +1,15 @@ -export * from "./BlockNoteEditor"; -export * from "./BlockNoteExtensions"; -export * from "./extensions/Blocks/api/block"; -export * from "./extensions/Blocks/api/blockTypes"; -export * from "./extensions/Blocks/api/defaultProps"; -export * from "./extensions/Blocks/api/defaultBlocks"; -export * from "./extensions/Blocks/api/inlineContentTypes"; -export * from "./extensions/Blocks/api/selectionTypes"; -export * from "./extensions/Blocks/api/serialization"; -export * as blockStyles from "./extensions/Blocks/nodes/Block.module.css"; -export * from "./extensions/Blocks/nodes/BlockContent/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY"; +export * from "./api/exporters/html/externalHTMLExporter"; +export * from "./api/exporters/html/internalHTMLSerializer"; +export * from "./api/testUtil"; +export * from "./blocks/ImageBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY"; +export * from "./blocks/defaultBlocks"; +export * from "./blocks/defaultProps"; +export * from "./editor/BlockNoteEditor"; +export * from "./editor/BlockNoteExtensions"; +export * from "./editor/selectionTypes"; +export * from "./extensions-shared/BaseUiElementTypes"; +export type { SuggestionItem } from "./extensions-shared/suggestion/SuggestionItem"; +export * from "./extensions-shared/suggestion/SuggestionPlugin"; export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; export * from "./extensions/HyperlinkToolbar/HyperlinkToolbarPlugin"; export * from "./extensions/ImageToolbar/ImageToolbarPlugin"; @@ -16,7 +17,12 @@ export * from "./extensions/SideMenu/SideMenuPlugin"; export * from "./extensions/SlashMenu/BaseSlashMenuItem"; export * from "./extensions/SlashMenu/SlashMenuPlugin"; export { getDefaultSlashMenuItems } from "./extensions/SlashMenu/defaultSlashMenuItems"; -export * from "./shared/BaseUiElementTypes"; -export type { SuggestionItem } from "./shared/plugins/suggestion/SuggestionItem"; -export * from "./shared/plugins/suggestion/SuggestionPlugin"; -export * from "./shared/utils"; +export * from "./extensions/TableHandles/TableHandlesPlugin"; +export * from "./schema"; +export * from "./util/browser"; +export * from "./util/string"; +// for testing from react (TODO: move): +export * from "./api/nodeConversions/nodeConversions"; +export * from "./api/testUtil/partialBlockTestUtil"; +export * from "./extensions/UniqueID/UniqueID"; +export { UnreachableCaseError } from "./util/typescript"; diff --git a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts similarity index 72% rename from packages/core/src/extensions/Blocks/nodes/BlockContainer.ts rename to packages/core/src/pm-nodes/BlockContainer.ts index 2265668ed0..016ac4444e 100644 --- a/packages/core/src/extensions/Blocks/nodes/BlockContainer.ts +++ b/packages/core/src/pm-nodes/BlockContainer.ts @@ -1,22 +1,34 @@ -import { mergeAttributes, Node } from "@tiptap/core"; +import { Node } from "@tiptap/core"; import { Fragment, Node as PMNode, Slice } from "prosemirror-model"; import { NodeSelection, TextSelection } from "prosemirror-state"; + +import { getBlockInfoFromPos } from "../api/getBlockInfoFromPos"; import { blockToNode, inlineContentToNodes, -} from "../../../api/nodeConversions/nodeConversions"; - + tableContentToNodes, +} from "../api/nodeConversions/nodeConversions"; +import type { BlockNoteEditor } from "../editor/BlockNoteEditor"; +import { NonEditableBlockPlugin } from "../extensions/NonEditableBlocks/NonEditableBlockPlugin"; +import { PreviousBlockTypePlugin } from "../extensions/PreviousBlockType/PreviousBlockTypePlugin"; import { BlockNoteDOMAttributes, BlockSchema, + InlineContentSchema, PartialBlock, -} from "../api/blockTypes"; -import { getBlockInfoFromPos } from "../helpers/getBlockInfoFromPos"; -import { PreviousBlockTypePlugin } from "../PreviousBlockTypePlugin"; -import styles from "./Block.module.css"; -import BlockAttributes from "./BlockAttributes"; -import { mergeCSSClasses } from "../../../shared/utils"; -import { NonEditableBlockPlugin } from "../NonEditableBlockPlugin"; + StyleSchema, +} from "../schema"; +import { mergeCSSClasses } from "../util/browser"; +import { UnreachableCaseError } from "../util/typescript"; + +// Object containing all possible block attributes. +const BlockAttributes: Record = { + blockColor: "data-block-color", + blockStyle: "data-block-style", + id: "data-id", + depth: "data-depth", + depthChange: "data-depth-change", +}; declare module "@tiptap/core" { interface Commands { @@ -25,13 +37,21 @@ declare module "@tiptap/core" { BNDeleteBlock: (posInBlock: number) => ReturnType; BNMergeBlocks: (posBetweenBlocks: number) => ReturnType; BNSplitBlock: (posInBlock: number, keepType: boolean) => ReturnType; - BNUpdateBlock: ( + BNUpdateBlock: < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema + >( posInBlock: number, - block: PartialBlock + block: PartialBlock ) => ReturnType; - BNCreateOrUpdateBlock: ( + BNCreateOrUpdateBlock: < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema + >( posInBlock: number, - block: PartialBlock + block: PartialBlock ) => ReturnType; }; } @@ -42,6 +62,7 @@ declare module "@tiptap/core" { */ export const BlockContainer = Node.create<{ domAttributes?: BlockNoteDOMAttributes; + editor: BlockNoteEditor; }>({ name: "blockContainer", group: "blockContainer", @@ -78,27 +99,34 @@ export const BlockContainer = Node.create<{ }, renderHTML({ HTMLAttributes }) { - const domAttributes = this.options.domAttributes?.blockContainer || {}; + const blockOuter = document.createElement("div"); + blockOuter.className = "bn-block-outer"; + blockOuter.setAttribute("data-node-type", "blockOuter"); + for (const [attribute, value] of Object.entries(HTMLAttributes)) { + if (attribute !== "class") { + blockOuter.setAttribute(attribute, value); + } + } + + const blockHTMLAttributes = { + ...(this.options.domAttributes?.blockContainer || {}), + ...HTMLAttributes, + }; + const block = document.createElement("div"); + block.className = mergeCSSClasses("bn-block", blockHTMLAttributes.class); + block.setAttribute("data-node-type", this.name); + for (const [attribute, value] of Object.entries(blockHTMLAttributes)) { + if (attribute !== "class") { + block.setAttribute(attribute, value); + } + } - return [ - "div", - mergeAttributes(HTMLAttributes, { - class: styles.blockOuter, - "data-node-type": "block-outer", - }), - [ - "div", - mergeAttributes( - { - ...domAttributes, - class: mergeCSSClasses(styles.block, domAttributes.class), - "data-node-type": this.name, - }, - HTMLAttributes - ), - 0, - ], - ]; + blockOuter.appendChild(block); + + return { + dom: blockOuter, + contentDOM: block, + }; }, addCommands() { @@ -151,7 +179,13 @@ export const BlockContainer = Node.create<{ // Creates ProseMirror nodes for each child block, including their descendants. for (const child of block.children) { - childNodes.push(blockToNode(child, state.schema)); + childNodes.push( + blockToNode( + child, + state.schema, + this.options.editor.styleSchema + ) + ); } // Checks if a blockGroup node already exists. @@ -171,59 +205,63 @@ export const BlockContainer = Node.create<{ } } - // Replaces the blockContent node's content if necessary. - if (block.content !== undefined) { - let content: PMNode[] = []; + const oldType = contentNode.type.name; + const newType = block.type || oldType; + + // The code below determines the new content of the block. + // or "keep" to keep as-is + let content: PMNode[] | "keep" = "keep"; - // Checks if the provided content is a string or InlineContent[] type. + // Has there been any custom content provided? + if (block.content) { if (typeof block.content === "string") { // Adds a single text node with no marks to the content. - content.push(state.schema.text(block.content)); + content = [state.schema.text(block.content)]; + } else if (Array.isArray(block.content)) { + // Adds a text node with the provided styles converted into marks to the content, + // for each InlineContent object. + content = inlineContentToNodes( + block.content, + state.schema, + this.options.editor.styleSchema + ); + } else if (block.content.type === "tableContent") { + content = tableContentToNodes( + block.content, + state.schema, + this.options.editor.styleSchema + ); } else { - // Adds a text node with the provided styles converted into marks to the content, for each InlineContent - // object. - content = inlineContentToNodes(block.content, state.schema); + throw new UnreachableCaseError(block.content.type); + } + } else { + // no custom content has been provided, use existing content IF possible + + // Since some block types contain inline content and others don't, + // we either need to call setNodeMarkup to just update type & + // attributes, or replaceWith to replace the whole blockContent. + const oldContentType = state.schema.nodes[oldType].spec.content; + const newContentType = state.schema.nodes[newType].spec.content; + + if (oldContentType === "") { + // keep old content, because it's empty anyway and should be compatible with + // any newContentType + } else if (newContentType !== oldContentType) { + // the content type changed, replace the previous content + content = []; + } else { + // keep old content, because the content type is the same and should be compatible } - - // Replaces the contents of the blockContent node with the previously created text node(s). - state.tr.replace( - startPos + 1, - startPos + contentNode.nodeSize - 1, - new Slice(Fragment.from(content), 0, 0) - ); } - // Since some block types contain inline content and others don't, - // we either need to call setNodeMarkup to just update type & - // attributes, or replaceWith to replace the whole blockContent. - const oldType = contentNode.type.name; - const newType = block.type || oldType; - - const oldContentType = state.schema.nodes[oldType].spec.content; - const newContentType = state.schema.nodes[newType].spec.content; - - if (oldContentType === "inline*" && newContentType === "") { - // Replaces the blockContent node with one of the new type and - // adds the provided props as attributes. Also preserves all - // existing attributes that are compatible with the new type. - // Need to reset the selection since replacing the block content - // sets it to the next block. - state.tr - .replaceWith( - startPos, - endPos, - state.schema.nodes[newType].create({ - ...contentNode.attrs, - ...block.props, - }) - ) - .setSelection( - new NodeSelection(state.tr.doc.resolve(startPos)) - ); - } else { - // Changes the blockContent node type and adds the provided props - // as attributes. Also preserves all existing attributes that are - // compatible with the new type. + // Now, changes the blockContent node type and adds the provided props + // as attributes. Also preserves all existing attributes that are + // compatible with the new type. + // + // Use either setNodeMarkup or replaceWith depending on whether the + // content is being replaced or not. + if (content === "keep") { + // use setNodeMarkup to only update the type and attributes state.tr.setNodeMarkup( startPos, block.type === undefined @@ -234,6 +272,35 @@ export const BlockContainer = Node.create<{ ...block.props, } ); + } else { + // use replaceWith to replace the content and the block itself + // also reset the selection since replacing the block content + // sets it to the next block. + state.tr + .replaceWith( + startPos, + endPos, + state.schema.nodes[newType].create( + { + ...contentNode.attrs, + ...block.props, + }, + content + ) + ) + // If the node doesn't contain editable content, we want to + // select the whole node. But if it does have editable content, + // we want to set the selection to the start of it. + .setSelection( + state.schema.nodes[newType].spec.content === "" + ? new NodeSelection(state.tr.doc.resolve(startPos)) + : state.schema.nodes[newType].spec.content === "inline*" + ? new TextSelection(state.tr.doc.resolve(startPos)) + : // Need to offset the position as we have to get through the + // `tableRow` and `tableCell` nodes to get to the + // `tableParagraph` node we want to set the selection in. + new TextSelection(state.tr.doc.resolve(startPos + 4)) + ); } // Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing @@ -425,13 +492,12 @@ export const BlockContainer = Node.create<{ // Reverts block content type to a paragraph if the selection is at the start of the block. () => commands.command(({ state }) => { - const { contentType } = getBlockInfoFromPos( + const { contentType, startPos } = getBlockInfoFromPos( state.doc, state.selection.from )!; - const selectionAtBlockStart = - state.selection.$anchor.parentOffset === 0; + const selectionAtBlockStart = state.selection.from === startPos + 1; const isParagraph = contentType.name === "paragraph"; if (selectionAtBlockStart && !isParagraph) { @@ -446,8 +512,12 @@ export const BlockContainer = Node.create<{ // Removes a level of nesting if the block is indented if the selection is at the start of the block. () => commands.command(({ state }) => { - const selectionAtBlockStart = - state.selection.$anchor.parentOffset === 0; + const { startPos } = getBlockInfoFromPos( + state.doc, + state.selection.from + )!; + + const selectionAtBlockStart = state.selection.from === startPos + 1; if (selectionAtBlockStart) { return commands.liftListItem("blockContainer"); @@ -464,10 +534,8 @@ export const BlockContainer = Node.create<{ state.selection.from )!; - const selectionAtBlockStart = - state.selection.$anchor.parentOffset === 0; - const selectionEmpty = - state.selection.anchor === state.selection.head; + const selectionAtBlockStart = state.selection.from === startPos + 1; + const selectionEmpty = state.selection.empty; const blockAtDocStart = startPos === 2; const posBetweenBlocks = startPos - 1; @@ -494,17 +562,14 @@ export const BlockContainer = Node.create<{ // end of the block. () => commands.command(({ state }) => { - const { node, contentNode, depth, endPos } = getBlockInfoFromPos( + const { node, depth, endPos } = getBlockInfoFromPos( state.doc, state.selection.from )!; const blockAtDocEnd = false; - const selectionAtBlockEnd = - state.selection.$anchor.parentOffset === - contentNode.firstChild!.nodeSize; - const selectionEmpty = - state.selection.anchor === state.selection.head; + const selectionAtBlockEnd = state.selection.from === endPos - 1; + const selectionEmpty = state.selection.empty; const hasChildBlocks = node.childCount === 2; if ( diff --git a/packages/core/src/pm-nodes/BlockGroup.ts b/packages/core/src/pm-nodes/BlockGroup.ts new file mode 100644 index 0000000000..ab2f771c21 --- /dev/null +++ b/packages/core/src/pm-nodes/BlockGroup.ts @@ -0,0 +1,54 @@ +import { Node } from "@tiptap/core"; +import { BlockNoteDOMAttributes } from "../schema"; +import { mergeCSSClasses } from "../util/browser"; + +export const BlockGroup = Node.create<{ + domAttributes?: BlockNoteDOMAttributes; +}>({ + name: "blockGroup", + group: "blockGroup", + content: "blockContainer+", + + parseHTML() { + return [ + { + tag: "div", + getAttrs: (element) => { + if (typeof element === "string") { + return false; + } + + if (element.getAttribute("data-node-type") === "blockGroup") { + // Null means the element matches, but we don't want to add any attributes to the node. + return null; + } + + return false; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const blockGroupHTMLAttributes = { + ...(this.options.domAttributes?.blockGroup || {}), + ...HTMLAttributes, + }; + const blockGroup = document.createElement("div"); + blockGroup.className = mergeCSSClasses( + "bn-block-group", + blockGroupHTMLAttributes.class + ); + blockGroup.setAttribute("data-node-type", "blockGroup"); + for (const [attribute, value] of Object.entries(blockGroupHTMLAttributes)) { + if (attribute !== "class") { + blockGroup.setAttribute(attribute, value); + } + } + + return { + dom: blockGroup, + contentDOM: blockGroup, + }; + }, +}); diff --git a/packages/core/src/pm-nodes/Doc.ts b/packages/core/src/pm-nodes/Doc.ts new file mode 100644 index 0000000000..69d086b5d4 --- /dev/null +++ b/packages/core/src/pm-nodes/Doc.ts @@ -0,0 +1,7 @@ +import {Node} from "@tiptap/core"; + +export const Doc = Node.create({ + name: "doc", + topNode: true, + content: "blockGroup", +}); diff --git a/packages/core/ARCHITECTURE.md b/packages/core/src/pm-nodes/README.md similarity index 93% rename from packages/core/ARCHITECTURE.md rename to packages/core/src/pm-nodes/README.md index ee07f25ea3..83ea63c6f4 100644 --- a/packages/core/ARCHITECTURE.md +++ b/packages/core/src/pm-nodes/README.md @@ -1,3 +1,8 @@ +### @blocknote/core/src/pm-nodes + +Defines the prosemirror nodes and base node structure. See below: + + # Node structure We use a Prosemirror document structure where every element is a `block` with 1 `content` element and one optional group of children (`blockgroup`). diff --git a/packages/core/src/pm-nodes/index.ts b/packages/core/src/pm-nodes/index.ts new file mode 100644 index 0000000000..bcda2eecc7 --- /dev/null +++ b/packages/core/src/pm-nodes/index.ts @@ -0,0 +1,3 @@ +export { BlockContainer } from "./BlockContainer"; +export { BlockGroup } from "./BlockGroup"; +export { Doc } from "./Doc"; \ No newline at end of file diff --git a/packages/core/src/schema/README.md b/packages/core/src/schema/README.md new file mode 100644 index 0000000000..0aaf329fa1 --- /dev/null +++ b/packages/core/src/schema/README.md @@ -0,0 +1,3 @@ +### @blocknote/core/src/schema + +The BlockNote Schema consists of Blocks, InlineContent and Styles. \ No newline at end of file diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts new file mode 100644 index 0000000000..eee2ac6788 --- /dev/null +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -0,0 +1,220 @@ +import { ParseRule } from "@tiptap/pm/model"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { InlineContentSchema } from "../inlineContent/types"; +import { StyleSchema } from "../styles/types"; +import { + createInternalBlockSpec, + createStronglyTypedTiptapNode, + getBlockFromPos, + propsToAttributes, + wrapInBlockStructure, +} from "./internal"; +import { + BlockConfig, + BlockFromConfig, + BlockSchemaWithBlock, + PartialBlockFromConfig, +} from "./types"; + +// restrict content to "inline" and "none" only +export type CustomBlockConfig = BlockConfig & { + content: "inline" | "none"; +}; + +export type CustomBlockImplementation< + T extends CustomBlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +> = { + render: ( + /** + * The custom block to render + */ + block: BlockFromConfig, + /** + * The BlockNote editor instance + * This is typed generically. If you want an editor with your custom schema, you need to + * cast it manually, e.g.: `const e = editor as BlockNoteEditor;` + */ + editor: BlockNoteEditor, I, S> + // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations + // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics + ) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + destroy?: () => void; + }; + // Exports block to external HTML. If not defined, the output will be the same + // as `render(...).dom`. Used to create clipboard data when pasting outside + // BlockNote. + // TODO: Maybe can return undefined to ignore when serializing? + toExternalHTML?: ( + block: BlockFromConfig, + editor: BlockNoteEditor, I, S> + ) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; + + parse?: ( + el: HTMLElement + ) => PartialBlockFromConfig["props"] | undefined; +}; + +// Function that uses the 'parse' function of a blockConfig to create a +// TipTap node's `parseHTML` property. This is only used for parsing content +// from the clipboard. +export function getParseRules( + config: BlockConfig, + customParseFunction: CustomBlockImplementation["parse"] +) { + const rules: ParseRule[] = [ + { + tag: "[data-content-type=" + config.type + "]", + contentElement: "[data-editable]", + }, + ]; + + if (customParseFunction) { + rules.push({ + tag: "*", + getAttrs(node: string | HTMLElement) { + if (typeof node === "string") { + return false; + } + + const props = customParseFunction?.(node); + + if (props === undefined) { + return false; + } + + return props; + }, + }); + } + // getContent(node, schema) { + // const block = blockConfig.parse?.(node as HTMLElement); + // + // if (block !== undefined && block.content !== undefined) { + // return Fragment.from( + // typeof block.content === "string" + // ? schema.text(block.content) + // : inlineContentToNodes(block.content, schema) + // ); + // } + // + // return Fragment.empty; + // }, + // }); + // } + + return rules; +} + +// A function to create custom block for API consumers +// we want to hide the tiptap node from API consumers and provide a simpler API surface instead +export function createBlockSpec< + T extends CustomBlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +>(blockConfig: T, blockImplementation: CustomBlockImplementation) { + const node = createStronglyTypedTiptapNode({ + name: blockConfig.type as T["type"], + content: (blockConfig.content === "inline" + ? "inline*" + : "") as T["content"] extends "inline" ? "inline*" : "", + group: "blockContent", + selectable: true, + + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, + + parseHTML() { + return getParseRules(blockConfig, blockImplementation.parse); + }, + + renderHTML() { + // renderHTML is not really used, as we always use a nodeView, and we use toExternalHTML / toInternalHTML for serialization + // There's an edge case when this gets called nevertheless; before the nodeviews have been mounted + // this is why we implement it with a temporary placeholder + const div = document.createElement("div"); + div.setAttribute("data-tmp-placeholder", "true"); + return { + dom: div, + }; + }, + + addNodeView() { + return ({ getPos }) => { + // Gets the BlockNote editor instance + const editor = this.options.editor; + // Gets the block + const block = getBlockFromPos( + getPos, + editor, + this.editor, + blockConfig.type + ); + // Gets the custom HTML attributes for `blockContent` nodes + const blockContentDOMAttributes = + this.options.domAttributes?.blockContent || {}; + + const output = blockImplementation.render(block as any, editor); + + return wrapInBlockStructure( + output, + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + }; + }, + }); + + if (node.name !== blockConfig.type) { + throw new Error( + "Node name does not match block type. This is a bug in BlockNote." + ); + } + + return createInternalBlockSpec(blockConfig, { + node, + toInternalHTML: (block, editor) => { + const blockContentDOMAttributes = + node.options.domAttributes?.blockContent || {}; + + const output = blockImplementation.render(block as any, editor as any); + + return wrapInBlockStructure( + output, + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + }, + toExternalHTML: (block, editor) => { + const blockContentDOMAttributes = + node.options.domAttributes?.blockContent || {}; + + let output = blockImplementation.toExternalHTML?.( + block as any, + editor as any + ); + if (output === undefined) { + output = blockImplementation.render(block as any, editor as any); + } + + return wrapInBlockStructure( + output, + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + }, + }); +} diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts new file mode 100644 index 0000000000..7ab8b1d4c5 --- /dev/null +++ b/packages/core/src/schema/blocks/internal.ts @@ -0,0 +1,253 @@ +import { + Attribute, + Attributes, + Editor, + Extension, + Node, + NodeConfig, +} from "@tiptap/core"; +import { defaultBlockToHTML } from "../../blocks/defaultBlockHelpers"; +import { inheritedProps } from "../../blocks/defaultProps"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { mergeCSSClasses } from "../../util/browser"; +import { camelToDataKebab } from "../../util/string"; +import { InlineContentSchema } from "../inlineContent/types"; +import { PropSchema, Props } from "../propTypes"; +import { StyleSchema } from "../styles/types"; +import { + BlockConfig, + BlockSchemaFromSpecs, + BlockSchemaWithBlock, + BlockSpec, + BlockSpecs, + SpecificBlock, + TiptapBlockImplementation, +} from "./types"; + +// Function that uses the 'propSchema' of a blockConfig to create a TipTap +// node's `addAttributes` property. +// TODO: extract function +export function propsToAttributes(propSchema: PropSchema): Attributes { + const tiptapAttributes: Record = {}; + + Object.entries(propSchema) + .filter(([name, _spec]) => !inheritedProps.includes(name)) + .forEach(([name, spec]) => { + tiptapAttributes[name] = { + default: spec.default, + keepOnSplit: true, + // Props are displayed in kebab-case as HTML attributes. If a prop's + // value is the same as its default, we don't display an HTML + // attribute for it. + parseHTML: (element) => { + const value = element.getAttribute(camelToDataKebab(name)); + + if (value === null) { + return null; + } + + if (typeof spec.default === "boolean") { + if (value === "true") { + return true; + } + + if (value === "false") { + return false; + } + + return null; + } + + if (typeof spec.default === "number") { + const asNumber = parseFloat(value); + const isNumeric = + !Number.isNaN(asNumber) && Number.isFinite(asNumber); + + if (isNumeric) { + return asNumber; + } + + return null; + } + + return value; + }, + renderHTML: (attributes) => + attributes[name] !== spec.default + ? { + [camelToDataKebab(name)]: attributes[name], + } + : {}, + }; + }); + + return tiptapAttributes; +} + +// Used to figure out which block should be rendered. This block is then used to +// create the node view. +export function getBlockFromPos< + BType extends string, + Config extends BlockConfig, + BSchema extends BlockSchemaWithBlock, + I extends InlineContentSchema, + S extends StyleSchema +>( + getPos: (() => number) | boolean, + editor: BlockNoteEditor, + tipTapEditor: Editor, + type: BType +) { + // Gets position of the node + if (typeof getPos === "boolean") { + throw new Error( + "Cannot find node position as getPos is a boolean, not a function." + ); + } + const pos = getPos(); + // Gets parent blockContainer node + const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); + // Gets block identifier + const blockIdentifier = blockContainer.attrs.id; + // Gets the block + const block = editor.getBlock(blockIdentifier)! as SpecificBlock< + BSchema, + BType, + I, + S + >; + if (block.type !== type) { + throw new Error("Block type does not match"); + } + + return block; +} + +// Function that wraps the `dom` element returned from 'blockConfig.render' in a +// `blockContent` div, which contains the block type and props as HTML +// attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds +// an `inlineContent` class to it. +export function wrapInBlockStructure< + BType extends string, + PSchema extends PropSchema +>( + element: { + dom: HTMLElement; + contentDOM?: HTMLElement; + destroy?: () => void; + }, + blockType: BType, + blockProps: Props, + propSchema: PSchema, + domAttributes?: Record +): { + dom: HTMLElement; + contentDOM?: HTMLElement; + destroy?: () => void; +} { + // Creates `blockContent` element + const blockContent = document.createElement("div"); + + // Adds custom HTML attributes + if (domAttributes !== undefined) { + for (const [attr, value] of Object.entries(domAttributes)) { + if (attr !== "class") { + blockContent.setAttribute(attr, value); + } + } + } + // Sets blockContent class + blockContent.className = mergeCSSClasses( + "bn-block-content", + domAttributes?.class || "" + ); + // Sets content type attribute + blockContent.setAttribute("data-content-type", blockType); + // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips props + // which are already added as HTML attributes to the parent `blockContent` + // element (inheritedProps) and props set to their default values. + for (const [prop, value] of Object.entries(blockProps)) { + if (!inheritedProps.includes(prop) && value !== propSchema[prop].default) { + blockContent.setAttribute(camelToDataKebab(prop), value); + } + } + + blockContent.appendChild(element.dom); + + if (element.contentDOM !== undefined) { + element.contentDOM.className = mergeCSSClasses( + "bn-inline-content", + element.contentDOM.className + ); + element.contentDOM.setAttribute("data-editable", ""); + } + + return { + ...element, + dom: blockContent, + }; +} + +// Helper type to keep track of the `name` and `content` properties after calling Node.create. +type StronglyTypedTipTapNode< + Name extends string, + Content extends "inline*" | "tableRow+" | "" +> = Node & { name: Name; config: { content: Content } }; + +export function createStronglyTypedTiptapNode< + Name extends string, + Content extends "inline*" | "tableRow+" | "" +>(config: NodeConfig & { name: Name; content: Content }) { + return Node.create(config) as StronglyTypedTipTapNode; // force re-typing (should be safe as it's type-checked from the config) +} + +// This helper function helps to instantiate a blockspec with a +// config and implementation that conform to the type of Config +export function createInternalBlockSpec( + config: T, + implementation: TiptapBlockImplementation< + T, + any, + InlineContentSchema, + StyleSchema + > +) { + return { + config, + implementation, + } satisfies BlockSpec; +} + +export function createBlockSpecFromStronglyTypedTiptapNode< + T extends Node, + P extends PropSchema +>(node: T, propSchema: P, requiredExtensions?: Array) { + return createInternalBlockSpec( + { + type: node.name as T["name"], + content: (node.config.content === "inline*" + ? "inline" + : node.config.content === "tableRow+" + ? "table" + : "none") as T["config"]["content"] extends "inline*" + ? "inline" + : T["config"]["content"] extends "tableRow+" + ? "table" + : "none", + propSchema, + }, + { + node, + requiredExtensions, + toInternalHTML: defaultBlockToHTML, + toExternalHTML: defaultBlockToHTML, + // parse: () => undefined, // parse rules are in node already + } + ); +} + +export function getBlockSchemaFromSpecs(specs: T) { + return Object.fromEntries( + Object.entries(specs).map(([key, value]) => [key, value.config]) + ) as BlockSchemaFromSpecs; +} diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts new file mode 100644 index 0000000000..65b231c3a9 --- /dev/null +++ b/packages/core/src/schema/blocks/types.ts @@ -0,0 +1,252 @@ +/** Define the main block types **/ +import { Extension, Node } from "@tiptap/core"; + +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { + InlineContent, + InlineContentSchema, + PartialInlineContent, +} from "../inlineContent/types"; +import { PropSchema, Props } from "../propTypes"; +import { StyleSchema } from "../styles/types"; + +export type BlockNoteDOMElement = + | "editor" + | "blockContainer" + | "blockGroup" + | "blockContent" + | "inlineContent"; + +export type BlockNoteDOMAttributes = Partial<{ + [DOMElement in BlockNoteDOMElement]: Record; +}>; + +// BlockConfig contains the "schema" info about a Block type +// i.e. what props it supports, what content it supports, etc. +export type BlockConfig = { + type: string; + readonly propSchema: PropSchema; + content: "inline" | "none" | "table"; +}; + +// Block implementation contains the "implementation" info about a Block +// such as the functions / Nodes required to render and / or serialize it +export type TiptapBlockImplementation< + T extends BlockConfig, + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { + requiredExtensions?: Array; + node: Node; + toInternalHTML: ( + block: BlockFromConfigNoChildren & { + children: Block[]; + }, + editor: BlockNoteEditor + ) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; + toExternalHTML: ( + block: BlockFromConfigNoChildren & { + children: Block[]; + }, + editor: BlockNoteEditor + ) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; +}; + +// A Spec contains both the Config and Implementation +export type BlockSpec< + T extends BlockConfig, + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { + config: T; + implementation: TiptapBlockImplementation; +}; + +// Utility type. For a given object block schema, ensures that the key of each +// block spec matches the name of the TipTap node in it. +type NamesMatch> = Blocks extends { + [Type in keyof Blocks]: Type extends string + ? Blocks[Type] extends { type: Type } + ? Blocks[Type] + : never + : never; +} + ? Blocks + : never; + +// A Schema contains all the types (Configs) supported in an editor +// The keys are the "type" of a block +export type BlockSchema = NamesMatch>; + +export type BlockSpecs = Record< + string, + BlockSpec +>; + +export type BlockImplementations = Record< + string, + TiptapBlockImplementation +>; + +export type BlockSchemaFromSpecs = { + [K in keyof T]: T[K]["config"]; +}; + +export type BlockSchemaWithBlock< + BType extends string, + C extends BlockConfig +> = { + [k in BType]: C; +}; + +export type TableContent< + I extends InlineContentSchema, + S extends StyleSchema = StyleSchema +> = { + type: "tableContent"; + rows: { + cells: InlineContent[][]; + }[]; +}; + +// A BlockConfig has all the information to get the type of a Block (which is a specific instance of the BlockConfig. +// i.e.: paragraphConfig: BlockConfig defines what a "paragraph" is / supports, and BlockFromConfigNoChildren is the shape of a specific paragraph block. +// (for internal use) +export type BlockFromConfigNoChildren< + B extends BlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +> = { + id: string; + type: B["type"]; + props: Props; + content: B["content"] extends "inline" + ? InlineContent[] + : B["content"] extends "table" + ? TableContent + : B["content"] extends "none" + ? undefined + : never; +}; + +export type BlockFromConfig< + B extends BlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +> = BlockFromConfigNoChildren & { + children: Block[]; +}; + +// Converts each block spec into a Block object without children. We later merge +// them into a union type and add a children property to create the Block and +// PartialBlock objects we use in the external API. +type BlocksWithoutChildren< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { + [BType in keyof BSchema]: BlockFromConfigNoChildren; +}; + +// Converts each block spec into a Block object without children, merges them +// into a union type, and adds a children property +export type Block< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = BlocksWithoutChildren[keyof BSchema] & { + children: Block[]; +}; + +export type SpecificBlock< + BSchema extends BlockSchema, + BType extends keyof BSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = BlocksWithoutChildren[BType] & { + children: Block[]; +}; + +/** CODE FOR PARTIAL BLOCKS, analogous to above + * + * Partial blocks are convenience-wrappers to make it easier to + *create/update blocks in the editor. + * + */ + +export type PartialTableContent< + I extends InlineContentSchema, + S extends StyleSchema = StyleSchema +> = { + type: "tableContent"; + rows: { + cells: PartialInlineContent[]; + }[]; +}; + +type PartialBlockFromConfigNoChildren< + B extends BlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +> = { + id?: string; + type?: B["type"]; + props?: Partial>; + content?: B["content"] extends "inline" + ? PartialInlineContent + : B["content"] extends "table" + ? PartialTableContent + : undefined; +}; + +type PartialBlocksWithoutChildren< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { + [BType in keyof BSchema]: PartialBlockFromConfigNoChildren< + BSchema[BType], + I, + S + >; +}; + +export type PartialBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = PartialBlocksWithoutChildren< + BSchema, + I, + S +>[keyof PartialBlocksWithoutChildren] & + Partial<{ + children: PartialBlock[]; + }>; + +export type SpecificPartialBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + BType extends keyof BSchema, + S extends StyleSchema +> = PartialBlocksWithoutChildren[BType] & { + children?: Block[]; +}; + +export type PartialBlockFromConfig< + B extends BlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +> = PartialBlockFromConfigNoChildren & { + children?: Block[]; +}; + +export type BlockIdentifier = { id: string } | string; diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts new file mode 100644 index 0000000000..29b85a42cc --- /dev/null +++ b/packages/core/src/schema/index.ts @@ -0,0 +1,10 @@ +export * from "./blocks/createSpec"; +export * from "./blocks/internal"; +export * from "./blocks/types"; +export * from "./inlineContent/createSpec"; +export * from "./inlineContent/internal"; +export * from "./inlineContent/types"; +export * from "./propTypes"; +export * from "./styles/createSpec"; +export * from "./styles/internal"; +export * from "./styles/types"; diff --git a/packages/core/src/schema/inlineContent/createSpec.ts b/packages/core/src/schema/inlineContent/createSpec.ts new file mode 100644 index 0000000000..693fdc7b9e --- /dev/null +++ b/packages/core/src/schema/inlineContent/createSpec.ts @@ -0,0 +1,119 @@ +import { Node } from "@tiptap/core"; +import { ParseRule } from "@tiptap/pm/model"; +import { nodeToCustomInlineContent } from "../../api/nodeConversions/nodeConversions"; +import { propsToAttributes } from "../blocks/internal"; +import { Props } from "../propTypes"; +import { StyleSchema } from "../styles/types"; +import { + addInlineContentAttributes, + addInlineContentKeyboardShortcuts, + createInlineContentSpecFromTipTapNode, +} from "./internal"; +import { + CustomInlineContentConfig, + InlineContentConfig, + InlineContentFromConfig, + InlineContentSpec, +} from "./types"; + +// TODO: support serialization + +export type CustomInlineContentImplementation< + T extends InlineContentConfig, + // B extends BlockSchema, + // I extends InlineContentSchema, + S extends StyleSchema +> = { + render: ( + /** + * The custom inline content to render + */ + inlineContent: InlineContentFromConfig + /** + * The BlockNote editor instance + * This is typed generically. If you want an editor with your custom schema, you need to + * cast it manually, e.g.: `const e = editor as BlockNoteEditor;` + */ + // editor: BlockNoteEditor + // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations + // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics + ) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + // destroy?: () => void; + }; +}; + +export function getInlineContentParseRules( + config: CustomInlineContentConfig +): ParseRule[] { + return [ + { + tag: `[data-inline-content-type="${config.type}"]`, + contentElement: (element) => { + const htmlElement = element as HTMLElement; + + if (htmlElement.matches("[data-editable]")) { + return htmlElement; + } + + return htmlElement.querySelector("[data-editable]") || htmlElement; + }, + }, + ]; +} + +export function createInlineContentSpec< + T extends CustomInlineContentConfig, + S extends StyleSchema +>( + inlineContentConfig: T, + inlineContentImplementation: CustomInlineContentImplementation +): InlineContentSpec { + const node = Node.create({ + name: inlineContentConfig.type, + inline: true, + group: "inline", + selectable: inlineContentConfig.content === "styled", + atom: inlineContentConfig.content === "none", + content: (inlineContentConfig.content === "styled" + ? "inline*" + : "") as T["content"] extends "styled" ? "inline*" : "", + + addAttributes() { + return propsToAttributes(inlineContentConfig.propSchema); + }, + + addKeyboardShortcuts() { + return addInlineContentKeyboardShortcuts(inlineContentConfig); + }, + + parseHTML() { + return getInlineContentParseRules(inlineContentConfig); + }, + + renderHTML({ node }) { + const editor = this.options.editor; + + const output = inlineContentImplementation.render( + nodeToCustomInlineContent( + node, + editor.inlineContentSchema, + editor.styleSchema + ) as any as InlineContentFromConfig // TODO: fix cast + ); + + return addInlineContentAttributes( + output, + inlineContentConfig.type, + node.attrs as Props, + inlineContentConfig.propSchema + ); + }, + }); + + return createInlineContentSpecFromTipTapNode( + node, + inlineContentConfig.propSchema + ) as InlineContentSpec; // TODO: fix cast +} diff --git a/packages/core/src/schema/inlineContent/internal.ts b/packages/core/src/schema/inlineContent/internal.ts new file mode 100644 index 0000000000..c1b70a28c0 --- /dev/null +++ b/packages/core/src/schema/inlineContent/internal.ts @@ -0,0 +1,105 @@ +import { KeyboardShortcutCommand, Node } from "@tiptap/core"; + +import { camelToDataKebab } from "../../util/string"; +import { PropSchema, Props } from "../propTypes"; +import { + CustomInlineContentConfig, + InlineContentConfig, + InlineContentImplementation, + InlineContentSchemaFromSpecs, + InlineContentSpec, + InlineContentSpecs, +} from "./types"; + +// Function that adds necessary classes and attributes to the `dom` element +// returned from a custom inline content's 'render' function, to ensure no data +// is lost on internal copy & paste. +export function addInlineContentAttributes< + IType extends string, + PSchema extends PropSchema +>( + element: { + dom: HTMLElement; + contentDOM?: HTMLElement; + }, + inlineContentType: IType, + inlineContentProps: Props, + propSchema: PSchema +): { + dom: HTMLElement; + contentDOM?: HTMLElement; +} { + // Sets content type attribute + element.dom.setAttribute("data-inline-content-type", inlineContentType); + // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips props + // set to their default values. + Object.entries(inlineContentProps) + .filter(([prop, value]) => value !== propSchema[prop].default) + .map(([prop, value]) => { + return [camelToDataKebab(prop), value]; + }) + .forEach(([prop, value]) => element.dom.setAttribute(prop, value)); + + if (element.contentDOM !== undefined) { + element.contentDOM.setAttribute("data-editable", ""); + } + + return element; +} + +// see https://github.com/TypeCellOS/BlockNote/pull/435 +export function addInlineContentKeyboardShortcuts< + T extends CustomInlineContentConfig +>( + config: T +): { + [p: string]: KeyboardShortcutCommand; +} { + return { + Backspace: ({ editor }) => { + const resolvedPos = editor.state.selection.$from; + + return ( + editor.state.selection.empty && + resolvedPos.node().type.name === config.type && + resolvedPos.parentOffset === 0 + ); + }, + }; +} + +// This helper function helps to instantiate a InlineContentSpec with a +// config and implementation that conform to the type of Config +export function createInternalInlineContentSpec( + config: T, + implementation: InlineContentImplementation +) { + return { + config, + implementation, + } satisfies InlineContentSpec; +} + +export function createInlineContentSpecFromTipTapNode< + T extends Node, + P extends PropSchema +>(node: T, propSchema: P) { + return createInternalInlineContentSpec( + { + type: node.name as T["name"], + propSchema, + content: node.config.content === "inline*" ? "styled" : "none", + }, + { + node, + } + ); +} + +export function getInlineContentSchemaFromSpecs( + specs: T +) { + return Object.fromEntries( + Object.entries(specs).map(([key, value]) => [key, value.config]) + ) as InlineContentSchemaFromSpecs; +} diff --git a/packages/core/src/schema/inlineContent/types.ts b/packages/core/src/schema/inlineContent/types.ts new file mode 100644 index 0000000000..eb4c852ab3 --- /dev/null +++ b/packages/core/src/schema/inlineContent/types.ts @@ -0,0 +1,144 @@ +import { Node } from "@tiptap/core"; +import { PropSchema, Props } from "../propTypes"; +import { StyleSchema, Styles } from "../styles/types"; + +export type CustomInlineContentConfig = { + type: string; + content: "styled" | "none"; // | "plain" + readonly propSchema: PropSchema; + // content: "inline" | "none" | "table"; +}; +// InlineContentConfig contains the "schema" info about an InlineContent type +// i.e. what props it supports, what content it supports, etc. +export type InlineContentConfig = CustomInlineContentConfig | "text" | "link"; + +// InlineContentImplementation contains the "implementation" info about an InlineContent element +// such as the functions / Nodes required to render and / or serialize it +// @ts-ignore +export type InlineContentImplementation = + T extends "link" | "text" + ? undefined + : { + node: Node; + }; + +// Container for both the config and implementation of InlineContent, +// and the type of `implementation` is based on that of the config +export type InlineContentSpec = { + config: T; + implementation: InlineContentImplementation; +}; + +// A Schema contains all the types (Configs) supported in an editor +// The keys are the "type" of InlineContent elements +export type InlineContentSchema = Record; + +export type InlineContentSpecs = { + text: { config: "text"; implementation: undefined }; + link: { config: "link"; implementation: undefined }; +} & Record>; + +export type InlineContentSchemaFromSpecs = { + [K in keyof T]: T[K]["config"]; +}; + +export type CustomInlineContentFromConfig< + I extends CustomInlineContentConfig, + S extends StyleSchema +> = { + type: I["type"]; + props: Props; + content: I["content"] extends "styled" + ? StyledText[] + : I["content"] extends "plain" + ? string + : I["content"] extends "none" + ? undefined + : never; +}; + +export type InlineContentFromConfig< + I extends InlineContentConfig, + S extends StyleSchema +> = I extends "text" + ? StyledText + : I extends "link" + ? Link + : I extends CustomInlineContentConfig + ? CustomInlineContentFromConfig + : never; + +export type PartialCustomInlineContentFromConfig< + I extends CustomInlineContentConfig, + S extends StyleSchema +> = { + type: I["type"]; + props?: Props; + content: I["content"] extends "styled" + ? StyledText[] | string + : I["content"] extends "plain" + ? string + : I["content"] extends "none" + ? undefined + : never; +}; + +export type PartialInlineContentFromConfig< + I extends InlineContentConfig, + S extends StyleSchema +> = I extends "text" + ? string | StyledText + : I extends "link" + ? PartialLink + : I extends CustomInlineContentConfig + ? PartialCustomInlineContentFromConfig + : never; + +export type StyledText = { + type: "text"; + text: string; + styles: Styles; +}; + +export type Link = { + type: "link"; + href: string; + content: StyledText[]; +}; + +export type PartialLink = Omit, "content"> & { + content: string | Link["content"]; +}; + +export type InlineContent< + I extends InlineContentSchema, + T extends StyleSchema +> = InlineContentFromConfig; + +type PartialInlineContentElement< + I extends InlineContentSchema, + T extends StyleSchema +> = PartialInlineContentFromConfig; + +export type PartialInlineContent< + I extends InlineContentSchema, + T extends StyleSchema +> = PartialInlineContentElement[] | string; + +export function isLinkInlineContent( + content: InlineContent +): content is Link { + return content.type === "link"; +} + +export function isPartialLinkInlineContent( + content: PartialInlineContentElement +): content is PartialLink { + return typeof content !== "string" && content.type === "link"; +} + +export function isStyledTextInlineContent( + content: PartialInlineContentElement +): content is StyledText { + return typeof content !== "string" && content.type === "text"; +} diff --git a/packages/core/src/schema/propTypes.ts b/packages/core/src/schema/propTypes.ts new file mode 100644 index 0000000000..2bf3e6dfc4 --- /dev/null +++ b/packages/core/src/schema/propTypes.ts @@ -0,0 +1,32 @@ +// Defines a single prop spec, which includes the default value the prop should +// take and possible values it can take. +export type PropSpec = { + values?: readonly PType[]; + default: PType; +}; + +// Defines multiple block prop specs. The key of each prop is the name of the +// prop, while the value is a corresponding prop spec. This should be included +// in a block config or schema. From a prop schema, we can derive both the props' +// internal implementation (as TipTap node attributes) and the type information +// for the external API. +export type PropSchema = Record>; + +// Defines Props objects for use in Block objects in the external API. Converts +// each prop spec into a union type of its possible values, or a string if no +// values are specified. +export type Props = { + [PName in keyof PSchema]: PSchema[PName]["default"] extends boolean + ? PSchema[PName]["values"] extends readonly boolean[] + ? PSchema[PName]["values"][number] + : boolean + : PSchema[PName]["default"] extends number + ? PSchema[PName]["values"] extends readonly number[] + ? PSchema[PName]["values"][number] + : number + : PSchema[PName]["default"] extends string + ? PSchema[PName]["values"] extends readonly string[] + ? PSchema[PName]["values"][number] + : string + : never; +}; diff --git a/packages/core/src/schema/styles/createSpec.ts b/packages/core/src/schema/styles/createSpec.ts new file mode 100644 index 0000000000..37d7424d28 --- /dev/null +++ b/packages/core/src/schema/styles/createSpec.ts @@ -0,0 +1,85 @@ +import { Mark } from "@tiptap/core"; +import { ParseRule } from "@tiptap/pm/model"; +import { + addStyleAttributes, + createInternalStyleSpec, + stylePropsToAttributes, +} from "./internal"; +import { StyleConfig, StyleSpec } from "./types"; +import {UnreachableCaseError} from "../../util/typescript"; + +export type CustomStyleImplementation = { + render: T["propSchema"] extends "boolean" + ? () => { + dom: HTMLElement; + contentDOM?: HTMLElement; + } + : (value: string) => { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; +}; + +// TODO: support serialization + +export function getStyleParseRules(config: StyleConfig): ParseRule[] { + return [ + { + tag: `[data-style-type="${config.type}"]`, + contentElement: (element) => { + const htmlElement = element as HTMLElement; + + if (htmlElement.matches("[data-editable]")) { + return htmlElement; + } + + return htmlElement.querySelector("[data-editable]") || htmlElement; + }, + }, + ]; +} + +export function createStyleSpec( + styleConfig: T, + styleImplementation: CustomStyleImplementation +): StyleSpec { + const mark = Mark.create({ + name: styleConfig.type, + + addAttributes() { + return stylePropsToAttributes(styleConfig.propSchema); + }, + + parseHTML() { + return getStyleParseRules(styleConfig); + }, + + renderHTML({ mark }) { + let renderResult: { + dom: HTMLElement; + contentDOM?: HTMLElement; + }; + + if (styleConfig.propSchema === "boolean") { + // @ts-ignore not sure why this is complaining + renderResult = styleImplementation.render(); + } else if (styleConfig.propSchema === "string") { + renderResult = styleImplementation.render(mark.attrs.stringValue); + } else { + throw new UnreachableCaseError(styleConfig.propSchema); + } + + // const renderResult = styleImplementation.render(); + return addStyleAttributes( + renderResult, + styleConfig.type, + mark.attrs.stringValue, + styleConfig.propSchema + ); + }, + }); + + return createInternalStyleSpec(styleConfig, { + mark, + }); +} diff --git a/packages/core/src/schema/styles/internal.ts b/packages/core/src/schema/styles/internal.ts new file mode 100644 index 0000000000..daf99e74f1 --- /dev/null +++ b/packages/core/src/schema/styles/internal.ts @@ -0,0 +1,96 @@ +import { Attributes, Mark } from "@tiptap/core"; +import { + StyleConfig, + StyleImplementation, + StylePropSchema, + StyleSchemaFromSpecs, + StyleSpec, + StyleSpecs, +} from "./types"; + +export function stylePropsToAttributes( + propSchema: StylePropSchema +): Attributes { + if (propSchema === "boolean") { + return {}; + } + return { + stringValue: { + default: undefined, + keepOnSplit: true, + parseHTML: (element) => element.getAttribute("data-value"), + renderHTML: (attributes) => + attributes.stringValue !== undefined + ? { + "data-value": attributes.stringValue, + } + : {}, + }, + }; +} + +// Function that adds necessary classes and attributes to the `dom` element +// returned from a custom style's 'render' function, to ensure no data is lost +// on internal copy & paste. +export function addStyleAttributes< + SType extends string, + PSchema extends StylePropSchema +>( + element: { + dom: HTMLElement; + contentDOM?: HTMLElement; + }, + styleType: SType, + styleValue: PSchema extends "boolean" ? undefined : string, + propSchema: PSchema +): { + dom: HTMLElement; + contentDOM?: HTMLElement; +} { + // Sets content type attribute + element.dom.setAttribute("data-style-type", styleType); + // Adds style value as an HTML attribute in kebab-case with "data-" prefix, if + // the style takes a string value. + if (propSchema === "string") { + element.dom.setAttribute("data-value", styleValue as string); + } + + if (element.contentDOM !== undefined) { + element.contentDOM.setAttribute("data-editable", ""); + } + + return element; +} + +// This helper function helps to instantiate a stylespec with a +// config and implementation that conform to the type of Config +export function createInternalStyleSpec( + config: T, + implementation: StyleImplementation +) { + return { + config, + implementation, + } satisfies StyleSpec; +} + +export function createStyleSpecFromTipTapMark< + T extends Mark, + P extends StylePropSchema +>(mark: T, propSchema: P) { + return createInternalStyleSpec( + { + type: mark.name as T["name"], + propSchema, + }, + { + mark, + } + ); +} + +export function getStyleSchemaFromSpecs(specs: T) { + return Object.fromEntries( + Object.entries(specs).map(([key, value]) => [key, value.config]) + ) as StyleSchemaFromSpecs; +} diff --git a/packages/core/src/schema/styles/types.ts b/packages/core/src/schema/styles/types.ts new file mode 100644 index 0000000000..69caf021c9 --- /dev/null +++ b/packages/core/src/schema/styles/types.ts @@ -0,0 +1,42 @@ +import { Mark } from "@tiptap/core"; + +export type StylePropSchema = "boolean" | "string"; // TODO: use PropSchema as name? Use objects as type similar to blocks? + +// StyleConfig contains the "schema" info about a Style type +// i.e. what props it supports, what content it supports, etc. +export type StyleConfig = { + type: string; + readonly propSchema: StylePropSchema; + // content: "inline" | "none" | "table"; +}; + +// StyleImplementation contains the "implementation" info about a Style element. +// Currently, the implementation is always a TipTap Mark +export type StyleImplementation = { + mark: Mark; +}; + +// Container for both the config and implementation of a Style, +// and the type of `implementation` is based on that of the config +export type StyleSpec = { + config: T; + implementation: StyleImplementation; +}; + +// A Schema contains all the types (Configs) supported in an editor +// The keys are the "type" of Styles supported +export type StyleSchema = Record; + +export type StyleSpecs = Record>; + +export type StyleSchemaFromSpecs = { + [K in keyof T]: T[K]["config"]; +}; + +export type Styles = { + [K in keyof T]?: T[K]["propSchema"] extends "boolean" + ? boolean + : T[K]["propSchema"] extends "string" + ? string + : never; +}; diff --git a/packages/core/src/shared/EventEmitter.ts b/packages/core/src/util/EventEmitter.ts similarity index 100% rename from packages/core/src/shared/EventEmitter.ts rename to packages/core/src/util/EventEmitter.ts diff --git a/packages/core/src/util/README.md b/packages/core/src/util/README.md new file mode 100644 index 0000000000..4fe456fd0e --- /dev/null +++ b/packages/core/src/util/README.md @@ -0,0 +1,3 @@ +### @blocknote/core/src/@util + +Contains generic utility files with helper functions / classes. \ No newline at end of file diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/util/browser.ts similarity index 56% rename from packages/core/src/shared/utils.ts rename to packages/core/src/util/browser.ts index e421f9f5aa..d32090c334 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/util/browser.ts @@ -1,7 +1,8 @@ export const isAppleOS = () => - /Mac/.test(navigator.platform) || - (/AppleWebKit/.test(navigator.userAgent) && - /Mobile\/\w+/.test(navigator.userAgent)); + typeof navigator !== "undefined" && + (/Mac/.test(navigator.platform) || + (/AppleWebKit/.test(navigator.userAgent) && + /Mobile\/\w+/.test(navigator.userAgent))); export function formatKeyboardShortcut(shortcut: string) { if (isAppleOS()) { @@ -15,8 +16,3 @@ export function mergeCSSClasses(...classes: string[]) { return classes.filter((c) => c).join(" "); } -export class UnreachableCaseError extends Error { - constructor(val: never) { - super(`Unreachable case: ${val}`); - } -} diff --git a/packages/core/src/util/string.ts b/packages/core/src/util/string.ts new file mode 100644 index 0000000000..d08881ca37 --- /dev/null +++ b/packages/core/src/util/string.ts @@ -0,0 +1,3 @@ +export function camelToDataKebab(str: string): string { + return "data-" + str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} diff --git a/packages/core/src/util/typescript.ts b/packages/core/src/util/typescript.ts new file mode 100644 index 0000000000..93b94f8b51 --- /dev/null +++ b/packages/core/src/util/typescript.ts @@ -0,0 +1,5 @@ +export class UnreachableCaseError extends Error { + constructor(val: never) { + super(`Unreachable case: ${val}`); + } +} \ No newline at end of file diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 807d1a6627..6824341113 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -10,6 +10,7 @@ const deps = Object.keys(pkg.dependencies); export default defineConfig({ test: { environment: "jsdom", + setupFiles: ["./vitestSetup.ts"], }, plugins: [webpackStats()], build: { diff --git a/packages/core/vitestSetup.ts b/packages/core/vitestSetup.ts new file mode 100644 index 0000000000..78f5b890bf --- /dev/null +++ b/packages/core/vitestSetup.ts @@ -0,0 +1,9 @@ +import { beforeEach, afterEach } from "vitest"; + +beforeEach(() => { + (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; +}); + +afterEach(() => { + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; +}); diff --git a/packages/react/package.json b/packages/react/package.json index 37ea6e0bd5..0bab9c6025 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -44,7 +44,9 @@ "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" + "lint": "eslint src --max-warnings 0", + "test": "vitest --run", + "test:watch": "vitest --watch" }, "dependencies": { "@blocknote/core": "^0.9.6", @@ -55,8 +57,11 @@ "@tippyjs/react": "^4.2.6", "@tiptap/core": "^2.0.3", "@tiptap/react": "^2.0.3", - "lodash": "^4.17.21", - "react": "^18.2.0", + "lodash.foreach": "^4.5.0", + "lodash.groupby": "^4.6.0", + "lodash.merge": "^4.6.2", + "react": "^18", + "react-dom": "^18.2.0", "react-icons": "^4.3.1", "tippy.js": "^6.3.7", "use-prefers-color-scheme": "^1.1.3" @@ -64,6 +69,9 @@ "devDependencies": { "@types/react": "^18.0.25", "@types/react-dom": "^18.0.9", + "@types/lodash.groupby": "^4.6.9", + "@types/lodash.merge": "^4.6.9", + "@types/lodash.foreach": "^4.5.9", "@vitejs/plugin-react": "^4.0.4", "eslint": "^8.10.0", "prettier": "^2.7.1", @@ -71,7 +79,8 @@ "typescript": "^5.0.4", "vite": "^4.4.8", "vite-plugin-eslint": "^1.8.1", - "vite-plugin-externalize-deps": "^0.7.0" + "vite-plugin-externalize-deps": "^0.7.0", + "vitest": "^0.34.1" }, "peerDependencies": { "react": "^18", diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx deleted file mode 100644 index d748edfa4a..0000000000 --- a/packages/react/src/ReactBlockSpec.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { - BlockConfig, - BlockNoteDOMAttributes, - BlockNoteEditor, - BlockSchema, - BlockSpec, - blockStyles, - camelToDataKebab, - createTipTapBlock, - mergeCSSClasses, - parse, - PropSchema, - propsToAttributes, - render, -} from "@blocknote/core"; -import { - NodeViewContent, - NodeViewProps, - NodeViewWrapper, - ReactNodeViewRenderer, -} from "@tiptap/react"; -import { createContext, ElementType, FC, HTMLProps, useContext } from "react"; - -// extend BlockConfig but use a React render function -export type ReactBlockConfig< - Type extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean, - BSchema extends BlockSchema -> = Omit< - BlockConfig, - "render" -> & { - render: FC<{ - block: Parameters< - BlockConfig["render"] - >[0]; - editor: Parameters< - BlockConfig["render"] - >[1]; - }>; -}; - -const BlockNoteDOMAttributesContext = createContext({}); - -export const InlineContent = ( - props: { as?: Tag } & HTMLProps -) => { - const inlineContentDOMAttributes = - useContext(BlockNoteDOMAttributesContext).inlineContent || {}; - - const classNames = mergeCSSClasses( - props.className || "", - blockStyles.inlineContent, - inlineContentDOMAttributes.class - ); - - return ( - key !== "class" - ) - )} - {...props} - className={classNames} - /> - ); -}; - -// A function to create custom block for API consumers -// we want to hide the tiptap node from API consumers and provide a simpler API surface instead -export function createReactBlockSpec< - BType extends string, - PSchema extends PropSchema, - ContainsInlineContent extends boolean, - BSchema extends BlockSchema ->( - blockConfig: ReactBlockConfig -): BlockSpec { - const node = createTipTapBlock< - BType, - ContainsInlineContent, - { - editor: BlockNoteEditor; - domAttributes?: BlockNoteDOMAttributes; - } - >({ - name: blockConfig.type, - content: (blockConfig.containsInlineContent - ? "inline*" - : "") as ContainsInlineContent extends true ? "inline*" : "", - selectable: true, - - addAttributes() { - return propsToAttributes(blockConfig); - }, - - parseHTML() { - return parse(blockConfig); - }, - - renderHTML({ HTMLAttributes }) { - return render(blockConfig, HTMLAttributes); - }, - - addNodeView() { - const BlockContent: FC = (props: NodeViewProps) => { - const Content = blockConfig.render; - - // Add custom HTML attributes - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; - - // Add props as HTML attributes in kebab-case with "data-" prefix - const htmlAttributes: Record = {}; - for (const [attribute, value] of Object.entries(props.node.attrs)) { - if ( - attribute in blockConfig.propSchema && - value !== blockConfig.propSchema[attribute].default - ) { - htmlAttributes[camelToDataKebab(attribute)] = value; - } - } - - // Gets BlockNote editor instance - const editor = this.options.editor! as BlockNoteEditor< - BSchema & { - [k in BType]: BlockSpec; - } - >; - // Gets position of the node - const pos = - typeof props.getPos === "function" ? props.getPos() : undefined; - // Gets TipTap editor instance - const tipTapEditor = editor._tiptapEditor; - // Gets parent blockContainer node - const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); - // Gets block identifier - const blockIdentifier = blockContainer.attrs.id; - // Get the block - const block = editor.getBlock(blockIdentifier)!; - if (block.type !== blockConfig.type) { - throw new Error("Block type does not match"); - } - - return ( - key !== "class" - ) - )} - className={mergeCSSClasses( - blockStyles.blockContent, - blockContentDOMAttributes.class - )} - data-content-type={blockConfig.type} - {...htmlAttributes}> - - - - - ); - }; - - return ReactNodeViewRenderer(BlockContent, { - className: blockStyles.reactNodeViewRenderer, - }); - }, - }); - - return { - node: node, - propSchema: blockConfig.propSchema, - }; -} diff --git a/packages/react/src/SideMenu/components/DefaultSideMenu.tsx b/packages/react/src/SideMenu/components/DefaultSideMenu.tsx deleted file mode 100644 index 46623a9c12..0000000000 --- a/packages/react/src/SideMenu/components/DefaultSideMenu.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { BlockSchema } from "@blocknote/core"; - -import { SideMenuProps } from "./SideMenuPositioner"; -import { SideMenu } from "./SideMenu"; -import { AddBlockButton } from "./DefaultButtons/AddBlockButton"; -import { DragHandle } from "./DefaultButtons/DragHandle"; - -export const DefaultSideMenu = ( - props: SideMenuProps -) => ( - - - - -); diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx b/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx deleted file mode 100644 index b67dd98836..0000000000 --- a/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ReactNode } from "react"; -import { createStyles, Menu } from "@mantine/core"; -import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core"; - -export type DragHandleMenuProps = { - editor: BlockNoteEditor; - block: Block; -}; - -export const DragHandleMenu = (props: { children: ReactNode }) => { - const { classes } = createStyles({ root: {} })(undefined, { - name: "DragHandleMenu", - }); - - return ( - - {props.children} - - ); -}; diff --git a/packages/react/src/SlashMenu/ReactSlashMenuItem.ts b/packages/react/src/SlashMenu/ReactSlashMenuItem.ts deleted file mode 100644 index b5e6f24091..0000000000 --- a/packages/react/src/SlashMenu/ReactSlashMenuItem.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - BaseSlashMenuItem, - BlockSchema, - DefaultBlockSchema, -} from "@blocknote/core"; - -export type ReactSlashMenuItem< - BSchema extends BlockSchema = DefaultBlockSchema -> = BaseSlashMenuItem & { - group: string; - icon: JSX.Element; - hint?: string; - shortcut?: string; -}; diff --git a/packages/react/src/SharedComponents/ColorPicker/components/ColorIcon.tsx b/packages/react/src/components-shared/ColorPicker/ColorIcon.tsx similarity index 100% rename from packages/react/src/SharedComponents/ColorPicker/components/ColorIcon.tsx rename to packages/react/src/components-shared/ColorPicker/ColorIcon.tsx diff --git a/packages/react/src/SharedComponents/ColorPicker/components/ColorPicker.tsx b/packages/react/src/components-shared/ColorPicker/ColorPicker.tsx similarity index 100% rename from packages/react/src/SharedComponents/ColorPicker/components/ColorPicker.tsx rename to packages/react/src/components-shared/ColorPicker/ColorPicker.tsx diff --git a/packages/react/src/SharedComponents/Toolbar/components/Toolbar.tsx b/packages/react/src/components-shared/Toolbar/Toolbar.tsx similarity index 100% rename from packages/react/src/SharedComponents/Toolbar/components/Toolbar.tsx rename to packages/react/src/components-shared/Toolbar/Toolbar.tsx diff --git a/packages/react/src/SharedComponents/Toolbar/components/ToolbarButton.tsx b/packages/react/src/components-shared/Toolbar/ToolbarButton.tsx similarity index 96% rename from packages/react/src/SharedComponents/Toolbar/components/ToolbarButton.tsx rename to packages/react/src/components-shared/Toolbar/ToolbarButton.tsx index ef55f69c1b..d964859c29 100644 --- a/packages/react/src/SharedComponents/Toolbar/components/ToolbarButton.tsx +++ b/packages/react/src/components-shared/Toolbar/ToolbarButton.tsx @@ -1,7 +1,7 @@ import { ActionIcon, Button } from "@mantine/core"; import Tippy from "@tippyjs/react"; import { ForwardedRef, forwardRef, MouseEvent } from "react"; -import { TooltipContent } from "../../Tooltip/components/TooltipContent"; +import { TooltipContent } from "../Tooltip/TooltipContent"; import { IconType } from "react-icons"; export type ToolbarButtonProps = { diff --git a/packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdown.tsx b/packages/react/src/components-shared/Toolbar/ToolbarDropdown.tsx similarity index 93% rename from packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdown.tsx rename to packages/react/src/components-shared/Toolbar/ToolbarDropdown.tsx index 176c72faa3..890d4734dd 100644 --- a/packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdown.tsx +++ b/packages/react/src/components-shared/Toolbar/ToolbarDropdown.tsx @@ -4,7 +4,7 @@ import { ToolbarDropdownItemProps, } from "./ToolbarDropdownItem"; import { ToolbarDropdownTarget } from "./ToolbarDropdownTarget"; -import { usePreventMenuOverflow } from "../../../hooks/usePreventMenuOverflow"; +import { usePreventMenuOverflow } from "../../hooks/usePreventMenuOverflow"; export type ToolbarDropdownProps = { items: ToolbarDropdownItemProps[]; diff --git a/packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdownItem.tsx b/packages/react/src/components-shared/Toolbar/ToolbarDropdownItem.tsx similarity index 100% rename from packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdownItem.tsx rename to packages/react/src/components-shared/Toolbar/ToolbarDropdownItem.tsx diff --git a/packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdownTarget.tsx b/packages/react/src/components-shared/Toolbar/ToolbarDropdownTarget.tsx similarity index 100% rename from packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdownTarget.tsx rename to packages/react/src/components-shared/Toolbar/ToolbarDropdownTarget.tsx diff --git a/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdown.tsx b/packages/react/src/components-shared/Toolbar/ToolbarInputDropdown.tsx similarity index 100% rename from packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdown.tsx rename to packages/react/src/components-shared/Toolbar/ToolbarInputDropdown.tsx diff --git a/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownButton.tsx b/packages/react/src/components-shared/Toolbar/ToolbarInputDropdownButton.tsx similarity index 100% rename from packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownButton.tsx rename to packages/react/src/components-shared/Toolbar/ToolbarInputDropdownButton.tsx diff --git a/packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownItem.tsx b/packages/react/src/components-shared/Toolbar/ToolbarInputDropdownItem.tsx similarity index 100% rename from packages/react/src/SharedComponents/Toolbar/components/ToolbarInputDropdownItem.tsx rename to packages/react/src/components-shared/Toolbar/ToolbarInputDropdownItem.tsx diff --git a/packages/react/src/SharedComponents/Tooltip/components/TooltipContent.tsx b/packages/react/src/components-shared/Tooltip/TooltipContent.tsx similarity index 100% rename from packages/react/src/SharedComponents/Tooltip/components/TooltipContent.tsx rename to packages/react/src/components-shared/Tooltip/TooltipContent.tsx diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx similarity index 84% rename from packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx rename to packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx index 1af9e4de01..3fad508d24 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx @@ -1,16 +1,25 @@ -import { useCallback, useMemo, useState } from "react"; +import { + BlockNoteEditor, + BlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, +} from "@blocknote/core"; import { Menu } from "@mantine/core"; -import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { useCallback, useMemo, useState } from "react"; -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; -import { ColorIcon } from "../../../SharedComponents/ColorPicker/components/ColorIcon"; -import { ColorPicker } from "../../../SharedComponents/ColorPicker/components/ColorPicker"; -import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; +import { ColorIcon } from "../../../components-shared/ColorPicker/ColorIcon"; +import { ColorPicker } from "../../../components-shared/ColorPicker/ColorPicker"; +import { ToolbarButton } from "../../../components-shared/Toolbar/ToolbarButton"; import { useEditorChange } from "../../../hooks/useEditorChange"; import { usePreventMenuOverflow } from "../../../hooks/usePreventMenuOverflow"; +import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; export const ColorStyleButton = (props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor< + BSchema, + DefaultInlineContentSchema, + DefaultStyleSchema + >; }) => { const selectedBlocks = useSelectedBlocks(props.editor); diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx similarity index 79% rename from packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx rename to packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index 188a3bf32c..df3cdb0a90 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -2,12 +2,12 @@ import { useCallback, useMemo, useState } from "react"; import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { RiLink } from "react-icons/ri"; -import { ToolbarInputDropdownButton } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownButton"; -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; -import { EditHyperlinkMenu } from "../../../HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu"; +import { ToolbarInputDropdownButton } from "../../../components-shared/Toolbar/ToolbarInputDropdownButton"; +import { ToolbarButton } from "../../../components-shared/Toolbar/ToolbarButton"; +import { EditHyperlinkMenu } from "../../HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu"; import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; import { useEditorChange } from "../../../hooks/useEditorChange"; -import { formatKeyboardShortcut } from "../../../utils"; +import { formatKeyboardShortcut } from "@blocknote/core"; export const CreateLinkButton = (props: { editor: BlockNoteEditor; diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/ImageCaptionButton.tsx similarity index 65% rename from packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx rename to packages/react/src/components/FormattingToolbar/DefaultButtons/ImageCaptionButton.tsx index b0a7e7d840..509e54e0a1 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ImageCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/ImageCaptionButton.tsx @@ -1,3 +1,4 @@ +import { BlockNoteEditor, BlockSchema, PartialBlock } from "@blocknote/core"; import { ChangeEvent, KeyboardEvent, @@ -6,17 +7,16 @@ import { useMemo, useState, } from "react"; -import { BlockNoteEditor, BlockSchema, PartialBlock } from "@blocknote/core"; import { RiText } from "react-icons/ri"; -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; -import { ToolbarInputDropdownButton } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownButton"; -import { ToolbarInputDropdown } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdown"; +import { ToolbarButton } from "../../../components-shared/Toolbar/ToolbarButton"; +import { ToolbarInputDropdown } from "../../../components-shared/Toolbar/ToolbarInputDropdown"; +import { ToolbarInputDropdownButton } from "../../../components-shared/Toolbar/ToolbarInputDropdownButton"; +import { ToolbarInputDropdownItem } from "../../../components-shared/Toolbar/ToolbarInputDropdownItem"; import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; -import { ToolbarInputDropdownItem } from "../../../SharedComponents/Toolbar/components/ToolbarInputDropdownItem"; export const ImageCaptionButton = (props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor; }) => { const selectedBlocks = useSelectedBlocks(props.editor); @@ -28,17 +28,19 @@ export const ImageCaptionButton = (props: { selectedBlocks[0].type === "image" && // Checks if the block has a `caption` prop which can take any string // value. - "caption" in props.editor.schema["image"].propSchema && - typeof props.editor.schema["image"].propSchema.caption.default === + "caption" in props.editor.blockSchema["image"].propSchema && + typeof props.editor.blockSchema["image"].propSchema.caption.default === + "string" && + props.editor.blockSchema["image"].propSchema.caption.values === + undefined && + // Checks if the block has a `url` prop which can take any string value. + "url" in props.editor.blockSchema["image"].propSchema && + typeof props.editor.blockSchema["image"].propSchema.url.default === "string" && - props.editor.schema["image"].propSchema.caption.values === undefined && - // Checks if the block has a `src` prop which can take any string value. - "src" in props.editor.schema["image"].propSchema && - typeof props.editor.schema["image"].propSchema.src.default === "string" && - props.editor.schema["image"].propSchema.src.values === undefined && - // Checks if the `src` prop is not set to an empty string. - selectedBlocks[0].props.src !== "", - [props.editor.schema, selectedBlocks] + props.editor.blockSchema["image"].propSchema.url.values === undefined && + // Checks if the `url` prop is not set to an empty string. + selectedBlocks[0].props.url !== "", + [props.editor.blockSchema, selectedBlocks] ); const [currentCaption, setCurrentCaption] = useState( @@ -62,7 +64,7 @@ export const ImageCaptionButton = (props: { props: { caption: currentCaption, }, - } as PartialBlock); + } as PartialBlock); } }, [currentCaption, props.editor, selectedBlocks] diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/NestBlockButtons.tsx similarity index 90% rename from packages/react/src/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx rename to packages/react/src/components/FormattingToolbar/DefaultButtons/NestBlockButtons.tsx index 68a12d2ddd..57f72124c3 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/NestBlockButtons.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/NestBlockButtons.tsx @@ -2,9 +2,9 @@ import { useCallback, useState } from "react"; import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { RiIndentDecrease, RiIndentIncrease } from "react-icons/ri"; -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; +import { ToolbarButton } from "../../../components-shared/Toolbar/ToolbarButton"; import { useEditorChange } from "../../../hooks/useEditorChange"; -import { formatKeyboardShortcut } from "../../../utils"; +import { formatKeyboardShortcut } from "@blocknote/core"; export const NestBlockButton = (props: { editor: BlockNoteEditor; diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/ReplaceImageButton.tsx similarity index 86% rename from packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx rename to packages/react/src/components/FormattingToolbar/DefaultButtons/ReplaceImageButton.tsx index 2169e3ff7e..c684b03770 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ReplaceImageButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/ReplaceImageButton.tsx @@ -3,8 +3,8 @@ import Tippy from "@tippyjs/react"; import { useEffect, useState } from "react"; import { RiImageEditFill } from "react-icons/ri"; -import { DefaultImageToolbar } from "../../../ImageToolbar/components/DefaultImageToolbar"; -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; +import { DefaultImageToolbar } from "../../ImageToolbar/DefaultImageToolbar"; +import { ToolbarButton } from "../../../components-shared/Toolbar/ToolbarButton"; import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; export const ReplaceImageButton = (props: { diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx similarity index 91% rename from packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx rename to packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx index cf89ecef97..145711e522 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/TextAlignButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx @@ -13,7 +13,7 @@ import { RiAlignRight, } from "react-icons/ri"; -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; +import { ToolbarButton } from "../../../components-shared/Toolbar/ToolbarButton"; import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; type TextAlignment = DefaultProps["textAlignment"]; @@ -26,7 +26,7 @@ const icons: Record = { }; export const TextAlignButton = (props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor; textAlignment: TextAlignment; }) => { const selectedBlocks = useSelectedBlocks(props.editor); @@ -48,7 +48,7 @@ export const TextAlignButton = (props: { for (const block of selectedBlocks) { props.editor.updateBlock(block, { props: { textAlignment: textAlignment }, - } as PartialBlock); + } as PartialBlock); } }, [props.editor, selectedBlocks] diff --git a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/ToggledStyleButton.tsx similarity index 63% rename from packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx rename to packages/react/src/components/FormattingToolbar/DefaultButtons/ToggledStyleButton.tsx index c71824e873..53a51ca958 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultButtons/ToggledStyleButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/ToggledStyleButton.tsx @@ -1,6 +1,9 @@ -import { BlockNoteEditor, BlockSchema, ToggledStyle } from "@blocknote/core"; +import { + BlockNoteEditor, + BlockSchema, + InlineContentSchema, +} from "@blocknote/core"; import { useMemo, useState } from "react"; -import { IconType } from "react-icons"; import { RiBold, RiCodeFill, @@ -9,12 +12,13 @@ import { RiUnderline, } from "react-icons/ri"; -import { ToolbarButton } from "../../../SharedComponents/Toolbar/components/ToolbarButton"; +import { StyleSchema } from "@blocknote/core"; +import { ToolbarButton } from "../../../components-shared/Toolbar/ToolbarButton"; import { useEditorChange } from "../../../hooks/useEditorChange"; import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; -import { formatKeyboardShortcut } from "../../../utils"; +import { formatKeyboardShortcut } from "@blocknote/core"; -const shortcuts: Record = { +const shortcuts = { bold: "Mod+B", italic: "Mod+I", underline: "Mod+U", @@ -22,7 +26,7 @@ const shortcuts: Record = { code: "", }; -const icons: Record = { +const icons = { bold: RiBold, italic: RiItalic, underline: RiUnderline, @@ -30,9 +34,13 @@ const icons: Record = { code: RiCodeFill, }; -export const ToggledStyleButton = (props: { - editor: BlockNoteEditor; - toggledStyle: ToggledStyle; +export const ToggledStyleButton = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>(props: { + editor: BlockNoteEditor; + toggledStyle: keyof typeof shortcuts; }) => { const selectedBlocks = useSelectedBlocks(props.editor); @@ -44,9 +52,12 @@ export const ToggledStyleButton = (props: { setActive(props.toggledStyle in props.editor.getActiveStyles()); }); - const toggleStyle = (style: ToggledStyle) => { + const toggleStyle = (style: typeof props.toggledStyle) => { props.editor.focus(); - props.editor.toggleStyles({ [style]: true }); + if (props.editor.styleSchema[style].propSchema !== "boolean") { + throw new Error("can only toggle boolean styles"); + } + props.editor.toggleStyles({ [style]: true } as any); }; const show = useMemo(() => { diff --git a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx b/packages/react/src/components/FormattingToolbar/DefaultDropdowns/BlockTypeDropdown.tsx similarity index 84% rename from packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx rename to packages/react/src/components/FormattingToolbar/DefaultDropdowns/BlockTypeDropdown.tsx index 34868362bc..613c3cf513 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultDropdowns/BlockTypeDropdown.tsx @@ -1,10 +1,5 @@ +import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core"; import { useMemo, useState } from "react"; -import { - Block, - BlockNoteEditor, - BlockSchema, - PartialBlock, -} from "@blocknote/core"; import { IconType } from "react-icons"; import { RiH1, @@ -15,17 +10,17 @@ import { RiText, } from "react-icons/ri"; -import { ToolbarDropdown } from "../../../SharedComponents/Toolbar/components/ToolbarDropdown"; -import { ToolbarDropdownItemProps } from "../../../SharedComponents/Toolbar/components/ToolbarDropdownItem"; import { useEditorChange } from "../../../hooks/useEditorChange"; import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks"; +import { ToolbarDropdown } from "../../../components-shared/Toolbar/ToolbarDropdown"; +import type { ToolbarDropdownItemProps } from "../../../components-shared/Toolbar/ToolbarDropdownItem"; export type BlockTypeDropdownItem = { name: string; type: string; props?: Record; icon: IconType; - isSelected: (block: Block) => boolean; + isSelected: (block: Block) => boolean; }; export const defaultBlockTypeDropdownItems: BlockTypeDropdownItem[] = [ @@ -92,13 +87,13 @@ export const BlockTypeDropdown = (props: { const filteredItems: BlockTypeDropdownItem[] = useMemo(() => { return (props.items || defaultBlockTypeDropdownItems).filter((item) => { // Checks if block type exists in the schema - if (!(item.type in props.editor.schema)) { + if (!(item.type in props.editor.blockSchema)) { return false; } // Checks if props for the block type are valid for (const [prop, value] of Object.entries(item.props || {})) { - const propSchema = props.editor.schema[item.type].propSchema; + const propSchema = props.editor.blockSchema[item.type].propSchema; // Checks if the prop exists for the block type if (!(prop in propSchema)) { @@ -129,9 +124,9 @@ export const BlockTypeDropdown = (props: { for (const block of selectedBlocks) { props.editor.updateBlock(block, { - type: item.type, - props: item.props, - } as PartialBlock); + type: item.type as any, + props: item.props as any, + }); } }; @@ -139,7 +134,7 @@ export const BlockTypeDropdown = (props: { text: item.name, icon: item.icon, onClick: () => onClick(item), - isSelected: item.isSelected(block as Block), + isSelected: item.isSelected(block as Block), })); }, [block, filteredItems, props.editor, selectedBlocks]); diff --git a/packages/react/src/FormattingToolbar/components/DefaultFormattingToolbar.tsx b/packages/react/src/components/FormattingToolbar/DefaultFormattingToolbar.tsx similarity index 92% rename from packages/react/src/FormattingToolbar/components/DefaultFormattingToolbar.tsx rename to packages/react/src/components/FormattingToolbar/DefaultFormattingToolbar.tsx index 7843311293..ca0cffbc06 100644 --- a/packages/react/src/FormattingToolbar/components/DefaultFormattingToolbar.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultFormattingToolbar.tsx @@ -1,21 +1,21 @@ import { BlockSchema } from "@blocknote/core"; -import { FormattingToolbarProps } from "./FormattingToolbarPositioner"; -import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar"; -import { - BlockTypeDropdown, - BlockTypeDropdownItem, -} from "./DefaultDropdowns/BlockTypeDropdown"; -import { ToggledStyleButton } from "./DefaultButtons/ToggledStyleButton"; -import { TextAlignButton } from "./DefaultButtons/TextAlignButton"; +import { Toolbar } from "../../components-shared/Toolbar/Toolbar"; import { ColorStyleButton } from "./DefaultButtons/ColorStyleButton"; +import { CreateLinkButton } from "./DefaultButtons/CreateLinkButton"; +import { ImageCaptionButton } from "./DefaultButtons/ImageCaptionButton"; import { NestBlockButton, UnnestBlockButton, } from "./DefaultButtons/NestBlockButtons"; -import { CreateLinkButton } from "./DefaultButtons/CreateLinkButton"; import { ReplaceImageButton } from "./DefaultButtons/ReplaceImageButton"; -import { ImageCaptionButton } from "./DefaultButtons/ImageCaptionButton"; +import { TextAlignButton } from "./DefaultButtons/TextAlignButton"; +import { ToggledStyleButton } from "./DefaultButtons/ToggledStyleButton"; +import { + BlockTypeDropdown, + BlockTypeDropdownItem, +} from "./DefaultDropdowns/BlockTypeDropdown"; +import type { FormattingToolbarProps } from "./FormattingToolbarPositioner"; export const DefaultFormattingToolbar = ( props: FormattingToolbarProps & { diff --git a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx b/packages/react/src/components/FormattingToolbar/FormattingToolbarPositioner.tsx similarity index 97% rename from packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx rename to packages/react/src/components/FormattingToolbar/FormattingToolbarPositioner.tsx index 891c5fc0d5..9441f5e5fa 100644 --- a/packages/react/src/FormattingToolbar/components/FormattingToolbarPositioner.tsx +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbarPositioner.tsx @@ -8,8 +8,8 @@ import Tippy, { tippy } from "@tippyjs/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; import { sticky } from "tippy.js"; -import { DefaultFormattingToolbar } from "./DefaultFormattingToolbar"; import { useEditorChange } from "../../hooks/useEditorChange"; +import { DefaultFormattingToolbar } from "./DefaultFormattingToolbar"; const textAlignmentToPlacement = ( textAlignment: DefaultProps["textAlignment"] @@ -29,13 +29,13 @@ const textAlignmentToPlacement = ( export type FormattingToolbarProps< BSchema extends BlockSchema = DefaultBlockSchema > = { - editor: BlockNoteEditor; + editor: BlockNoteEditor; }; export const FormattingToolbarPositioner = < BSchema extends BlockSchema = DefaultBlockSchema >(props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor; formattingToolbar?: FC>; }) => { const [show, setShow] = useState(false); diff --git a/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx b/packages/react/src/components/HyperlinkToolbar/DefaultHyperlinkToolbar.tsx similarity index 73% rename from packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx rename to packages/react/src/components/HyperlinkToolbar/DefaultHyperlinkToolbar.tsx index dc4a1a5f5f..4e9e72f1ce 100644 --- a/packages/react/src/HyperlinkToolbar/components/DefaultHyperlinkToolbar.tsx +++ b/packages/react/src/components/HyperlinkToolbar/DefaultHyperlinkToolbar.tsx @@ -1,14 +1,19 @@ +import { BlockSchema, InlineContentSchema } from "@blocknote/core"; import { useRef, useState } from "react"; import { RiExternalLinkFill, RiLinkUnlink } from "react-icons/ri"; -import { BlockSchema } from "@blocknote/core"; -import { HyperlinkToolbarProps } from "./HyperlinkToolbarPositioner"; -import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar"; -import { ToolbarButton } from "../../SharedComponents/Toolbar/components/ToolbarButton"; +import { StyleSchema } from "@blocknote/core"; +import { Toolbar } from "../../components-shared/Toolbar/Toolbar"; +import { ToolbarButton } from "../../components-shared/Toolbar/ToolbarButton"; import { EditHyperlinkMenu } from "./EditHyperlinkMenu/components/EditHyperlinkMenu"; +import type { HyperlinkToolbarProps } from "./HyperlinkToolbarPositioner"; -export const DefaultHyperlinkToolbar = ( - props: HyperlinkToolbarProps +export const DefaultHyperlinkToolbar = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + props: HyperlinkToolbarProps ) => { const [isEditing, setIsEditing] = useState(false); const editMenuRef = useRef(null); diff --git a/packages/react/src/HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx b/packages/react/src/components/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx similarity index 90% rename from packages/react/src/HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx rename to packages/react/src/components/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx index 96a936206b..82ecb898dd 100644 --- a/packages/react/src/HyperlinkToolbar/components/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx +++ b/packages/react/src/components/HyperlinkToolbar/EditHyperlinkMenu/components/EditHyperlinkMenu.tsx @@ -8,8 +8,8 @@ import { useState, } from "react"; import { RiLink, RiText } from "react-icons/ri"; -import { ToolbarInputDropdown } from "../../../../SharedComponents/Toolbar/components/ToolbarInputDropdown"; -import { ToolbarInputDropdownItem } from "../../../../SharedComponents/Toolbar/components/ToolbarInputDropdownItem"; +import { ToolbarInputDropdown } from "../../../../components-shared/Toolbar/ToolbarInputDropdown"; +import { ToolbarInputDropdownItem } from "../../../../components-shared/Toolbar/ToolbarInputDropdownItem"; export type EditHyperlinkMenuProps = { url: string; diff --git a/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx b/packages/react/src/components/HyperlinkToolbar/HyperlinkToolbarPositioner.tsx similarity index 79% rename from packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx rename to packages/react/src/components/HyperlinkToolbar/HyperlinkToolbarPositioner.tsx index 66b76706ce..6890e5df61 100644 --- a/packages/react/src/HyperlinkToolbar/components/HyperlinkToolbarPositioner.tsx +++ b/packages/react/src/components/HyperlinkToolbar/HyperlinkToolbarPositioner.tsx @@ -3,25 +3,35 @@ import { BlockNoteEditor, BlockSchema, DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, HyperlinkToolbarProsemirrorPlugin, HyperlinkToolbarState, + InlineContentSchema, } from "@blocknote/core"; import Tippy from "@tippyjs/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; +import { StyleSchema } from "@blocknote/core"; import { DefaultHyperlinkToolbar } from "./DefaultHyperlinkToolbar"; -export type HyperlinkToolbarProps = Pick< - HyperlinkToolbarProsemirrorPlugin, +export type HyperlinkToolbarProps< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = Pick< + HyperlinkToolbarProsemirrorPlugin, "editHyperlink" | "deleteHyperlink" | "startHideTimer" | "stopHideTimer" > & Omit; export const HyperlinkToolbarPositioner = < - BSchema extends BlockSchema = DefaultBlockSchema + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema >(props: { - editor: BlockNoteEditor; - hyperlinkToolbar?: FC>; + editor: BlockNoteEditor; + hyperlinkToolbar?: FC>; }) => { const [show, setShow] = useState(false); const [url, setUrl] = useState(); diff --git a/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx b/packages/react/src/components/ImageToolbar/DefaultImageToolbar.tsx similarity index 92% rename from packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx rename to packages/react/src/components/ImageToolbar/DefaultImageToolbar.tsx index d04226f116..ad639076b6 100644 --- a/packages/react/src/ImageToolbar/components/DefaultImageToolbar.tsx +++ b/packages/react/src/components/ImageToolbar/DefaultImageToolbar.tsx @@ -1,14 +1,5 @@ import { BlockSchema, PartialBlock } from "@blocknote/core"; -import { ImageToolbarProps } from "./ImageToolbarPositioner"; -import { Toolbar } from "../../SharedComponents/Toolbar/components/Toolbar"; -import { - ChangeEvent, - KeyboardEvent, - useCallback, - useEffect, - useState, -} from "react"; import { Button, FileInput, @@ -17,9 +8,18 @@ import { Text, TextInput, } from "@mantine/core"; +import { + ChangeEvent, + KeyboardEvent, + useCallback, + useEffect, + useState, +} from "react"; +import { Toolbar } from "../../components-shared/Toolbar/Toolbar"; +import type { ImageToolbarProps } from "./ImageToolbarPositioner"; export const DefaultImageToolbar = ( - props: ImageToolbarProps + props: ImageToolbarProps ) => { const [openTab, setOpenTab] = useState<"upload" | "embed">( props.editor.uploadFile !== undefined ? "upload" : "embed" @@ -46,7 +46,7 @@ export const DefaultImageToolbar = ( props: { url: uploaded, }, - } as PartialBlock); + } as PartialBlock); } catch (e) { setUploadFailed(true); } finally { @@ -75,7 +75,7 @@ export const DefaultImageToolbar = ( props: { url: currentURL, }, - } as PartialBlock); + } as PartialBlock); } }, [currentURL, props.block, props.editor] @@ -87,7 +87,7 @@ export const DefaultImageToolbar = ( props: { url: currentURL, }, - } as PartialBlock); + } as PartialBlock); }, [currentURL, props.block, props.editor]); return ( diff --git a/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx b/packages/react/src/components/ImageToolbar/ImageToolbarPositioner.tsx similarity index 73% rename from packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx rename to packages/react/src/components/ImageToolbar/ImageToolbarPositioner.tsx index 9400cf75d9..7bcfdd8615 100644 --- a/packages/react/src/ImageToolbar/components/ImageToolbarPositioner.tsx +++ b/packages/react/src/components/ImageToolbar/ImageToolbarPositioner.tsx @@ -2,9 +2,10 @@ import { BaseUiElementState, BlockNoteEditor, BlockSchema, - BlockSpec, DefaultBlockSchema, + DefaultInlineContentSchema, ImageToolbarState, + InlineContentSchema, SpecificBlock, } from "@blocknote/core"; import Tippy, { tippy } from "@tippyjs/react"; @@ -13,32 +14,21 @@ import { FC, useEffect, useMemo, useRef, useState } from "react"; import { DefaultImageToolbar } from "./DefaultImageToolbar"; export type ImageToolbarProps< - BSchema extends BlockSchema = DefaultBlockSchema -> = Omit & { - editor: BlockNoteEditor; + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema +> = Omit, keyof BaseUiElementState> & { + editor: BlockNoteEditor; }; export const ImageToolbarPositioner = < - BSchema extends BlockSchema = DefaultBlockSchema + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema >(props: { - editor: BlockNoteEditor; - imageToolbar?: FC>; + editor: BlockNoteEditor; + imageToolbar?: FC>; }) => { const [show, setShow] = useState(false); - const [block, setBlock] = useState< - SpecificBlock< - BlockSchema & { - image: BlockSpec< - "image", - { - src: { default: string }; - }, - false - >; - }, - "image" - > - >(); + const [block, setBlock] = useState>(); const referencePos = useRef(); diff --git a/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx b/packages/react/src/components/SideMenu/DefaultButtons/AddBlockButton.tsx similarity index 78% rename from packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx rename to packages/react/src/components/SideMenu/DefaultButtons/AddBlockButton.tsx index aa4985d700..ea7fba393f 100644 --- a/packages/react/src/SideMenu/components/DefaultButtons/AddBlockButton.tsx +++ b/packages/react/src/components/SideMenu/DefaultButtons/AddBlockButton.tsx @@ -1,10 +1,10 @@ +import { BlockSchema } from "@blocknote/core"; import { AiOutlinePlus } from "react-icons/ai"; import { SideMenuButton } from "../SideMenuButton"; -import { SideMenuProps } from "../SideMenuPositioner"; -import { BlockSchema } from "@blocknote/core"; +import type { SideMenuProps } from "../SideMenuPositioner"; export const AddBlockButton = ( - props: SideMenuProps + props: SideMenuProps ) => ( ( - props: SideMenuProps + props: SideMenuProps ) => { const DragHandleMenu = props.dragHandleMenu || DefaultDragHandleMenu; diff --git a/packages/react/src/components/SideMenu/DefaultSideMenu.tsx b/packages/react/src/components/SideMenu/DefaultSideMenu.tsx new file mode 100644 index 0000000000..9bdf66991e --- /dev/null +++ b/packages/react/src/components/SideMenu/DefaultSideMenu.tsx @@ -0,0 +1,20 @@ +import { BlockSchema, InlineContentSchema } from "@blocknote/core"; + +import { StyleSchema } from "@blocknote/core"; +import { AddBlockButton } from "./DefaultButtons/AddBlockButton"; +import { DragHandle } from "./DefaultButtons/DragHandle"; +import { SideMenu } from "./SideMenu"; +import type { SideMenuProps } from "./SideMenuPositioner"; + +export const DefaultSideMenu = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + props: SideMenuProps +) => ( + + + + +); diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx similarity index 87% rename from packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx rename to packages/react/src/components/SideMenu/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx index f8ea41fa56..7800df7b97 100644 --- a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx +++ b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx @@ -1,21 +1,21 @@ -import { ReactNode, useCallback, useRef, useState } from "react"; +import { BlockSchema, PartialBlock } from "@blocknote/core"; import { Box, Menu } from "@mantine/core"; +import { ReactNode, useCallback, useRef, useState } from "react"; import { HiChevronRight } from "react-icons/hi"; -import { BlockSchema, PartialBlock } from "@blocknote/core"; -import { DragHandleMenuProps } from "../DragHandleMenu"; -import { DragHandleMenuItem } from "../DragHandleMenuItem"; -import { ColorPicker } from "../../../../SharedComponents/ColorPicker/components/ColorPicker"; import { usePreventMenuOverflow } from "../../../../hooks/usePreventMenuOverflow"; +import { ColorPicker } from "../../../../components-shared/ColorPicker/ColorPicker"; +import type { DragHandleMenuProps } from "../DragHandleMenu"; +import { DragHandleMenuItem } from "../DragHandleMenuItem"; export const BlockColorsButton = ( - props: DragHandleMenuProps & { children: ReactNode } + props: DragHandleMenuProps & { children: ReactNode } ) => { const [opened, setOpened] = useState(false); const { ref, updateMaxHeight } = usePreventMenuOverflow(); - const menuCloseTimer = useRef(); + const menuCloseTimer = useRef | undefined>(); const startMenuCloseTimer = useCallback(() => { if (menuCloseTimer.current) { @@ -73,7 +73,7 @@ export const BlockColorsButton = ( setColor: (color) => props.editor.updateBlock(props.block, { props: { textColor: color }, - } as PartialBlock), + } as PartialBlock), } : undefined } @@ -85,7 +85,7 @@ export const BlockColorsButton = ( setColor: (color) => props.editor.updateBlock(props.block, { props: { backgroundColor: color }, - } as PartialBlock), + } as PartialBlock), } : undefined } diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx similarity index 73% rename from packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx rename to packages/react/src/components/SideMenu/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx index bbd5e2331c..1be12bd629 100644 --- a/packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx +++ b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultButtons/RemoveBlockButton.tsx @@ -1,11 +1,11 @@ -import { ReactNode } from "react"; import { BlockSchema } from "@blocknote/core"; +import { ReactNode } from "react"; -import { DragHandleMenuProps } from "../DragHandleMenu"; +import type { DragHandleMenuProps } from "../DragHandleMenu"; import { DragHandleMenuItem } from "../DragHandleMenuItem"; export const RemoveBlockButton = ( - props: DragHandleMenuProps & { children: ReactNode } + props: DragHandleMenuProps & { children: ReactNode } ) => { return ( ( - props: DragHandleMenuProps + props: DragHandleMenuProps ) => ( Delete diff --git a/packages/react/src/components/SideMenu/DragHandleMenu/DragHandleMenu.tsx b/packages/react/src/components/SideMenu/DragHandleMenu/DragHandleMenu.tsx new file mode 100644 index 0000000000..be806fd5a1 --- /dev/null +++ b/packages/react/src/components/SideMenu/DragHandleMenu/DragHandleMenu.tsx @@ -0,0 +1,30 @@ +import { + Block, + BlockNoteEditor, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; +import { Menu, createStyles } from "@mantine/core"; +import { ReactNode } from "react"; + +export type DragHandleMenuProps< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { + editor: BlockNoteEditor; + block: Block; +}; + +export const DragHandleMenu = (props: { children: ReactNode }) => { + const { classes } = createStyles({ root: {} })(undefined, { + name: "DragHandleMenu", + }); + + return ( + + {props.children} + + ); +}; diff --git a/packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenuItem.tsx b/packages/react/src/components/SideMenu/DragHandleMenu/DragHandleMenuItem.tsx similarity index 100% rename from packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenuItem.tsx rename to packages/react/src/components/SideMenu/DragHandleMenu/DragHandleMenuItem.tsx diff --git a/packages/react/src/SideMenu/components/SideMenu.tsx b/packages/react/src/components/SideMenu/SideMenu.tsx similarity index 100% rename from packages/react/src/SideMenu/components/SideMenu.tsx rename to packages/react/src/components/SideMenu/SideMenu.tsx diff --git a/packages/react/src/SideMenu/components/SideMenuButton.tsx b/packages/react/src/components/SideMenu/SideMenuButton.tsx similarity index 100% rename from packages/react/src/SideMenu/components/SideMenuButton.tsx rename to packages/react/src/components/SideMenu/SideMenuButton.tsx diff --git a/packages/react/src/SideMenu/components/SideMenuPositioner.tsx b/packages/react/src/components/SideMenu/SideMenuPositioner.tsx similarity index 71% rename from packages/react/src/SideMenu/components/SideMenuPositioner.tsx rename to packages/react/src/components/SideMenu/SideMenuPositioner.tsx index c77caa6cea..b0c94d7b71 100644 --- a/packages/react/src/SideMenu/components/SideMenuPositioner.tsx +++ b/packages/react/src/components/SideMenu/SideMenuPositioner.tsx @@ -3,36 +3,41 @@ import { BlockNoteEditor, BlockSchema, DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, SideMenuProsemirrorPlugin, } from "@blocknote/core"; import Tippy from "@tippyjs/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; +import { StyleSchema } from "@blocknote/core"; import { DefaultSideMenu } from "./DefaultSideMenu"; import { DragHandleMenuProps } from "./DragHandleMenu/DragHandleMenu"; -export type SideMenuProps = - Pick< - SideMenuProsemirrorPlugin, - | "blockDragStart" - | "blockDragEnd" - | "addBlock" - | "freezeMenu" - | "unfreezeMenu" - > & { - block: Block; - editor: BlockNoteEditor; - dragHandleMenu?: FC>; - }; +export type SideMenuProps< + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +> = Pick< + SideMenuProsemirrorPlugin, + "blockDragStart" | "blockDragEnd" | "addBlock" | "freezeMenu" | "unfreezeMenu" +> & { + block: Block; + editor: BlockNoteEditor; + dragHandleMenu?: FC>; +}; export const SideMenuPositioner = < - BSchema extends BlockSchema = DefaultBlockSchema + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema >(props: { - editor: BlockNoteEditor; - sideMenu?: FC>; + editor: BlockNoteEditor; + sideMenu?: FC>; }) => { const [show, setShow] = useState(false); - const [block, setBlock] = useState>(); + const [block, setBlock] = useState>(); const referencePos = useRef(); diff --git a/packages/react/src/SlashMenu/components/DefaultSlashMenu.tsx b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx similarity index 87% rename from packages/react/src/SlashMenu/components/DefaultSlashMenu.tsx rename to packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx index 382b6c8964..15f154e421 100644 --- a/packages/react/src/SlashMenu/components/DefaultSlashMenu.tsx +++ b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx @@ -1,9 +1,10 @@ import { createStyles, Menu } from "@mantine/core"; -import * as _ from "lodash"; +import foreach from "lodash.foreach"; +import groupBy from "lodash.groupby"; -import { SlashMenuItem } from "./SlashMenuItem"; -import { SlashMenuProps } from "./SlashMenuPositioner"; import { BlockSchema } from "@blocknote/core"; +import { SlashMenuItem } from "./SlashMenuItem"; +import type { SlashMenuProps } from "./SlashMenuPositioner"; export function DefaultSlashMenu( props: SlashMenuProps @@ -14,9 +15,9 @@ export function DefaultSlashMenu( const renderedItems: any[] = []; let index = 0; - const groups = _.groupBy(props.filteredItems, (i) => i.group); + const groups = groupBy(props.filteredItems, (i) => i.group); - _.forEach(groups, (groupedItems) => { + foreach(groups, (groupedItems) => { renderedItems.push( {groupedItems[0].group} diff --git a/packages/react/src/SlashMenu/components/SlashMenuItem.tsx b/packages/react/src/components/SlashMenu/SlashMenuItem.tsx similarity index 100% rename from packages/react/src/SlashMenu/components/SlashMenuItem.tsx rename to packages/react/src/components/SlashMenu/SlashMenuItem.tsx diff --git a/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx b/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx similarity index 92% rename from packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx rename to packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx index e3e005b68e..fa3a5247a0 100644 --- a/packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx +++ b/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx @@ -8,12 +8,12 @@ import { import Tippy from "@tippyjs/react"; import { FC, useEffect, useMemo, useRef, useState } from "react"; -import { ReactSlashMenuItem } from "../ReactSlashMenuItem"; -import { DefaultSlashMenu } from "./DefaultSlashMenu"; import { usePreventMenuOverflow } from "../../hooks/usePreventMenuOverflow"; +import { ReactSlashMenuItem } from "../../slashMenuItems/ReactSlashMenuItem"; +import { DefaultSlashMenu } from "./DefaultSlashMenu"; export type SlashMenuProps = - Pick, "itemCallback"> & + Pick, "itemCallback"> & Pick< SuggestionsMenuState>, "filteredItems" | "keyboardHoveredItemIndex" @@ -22,7 +22,7 @@ export type SlashMenuProps = export const SlashMenuPositioner = < BSchema extends BlockSchema = DefaultBlockSchema >(props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor; slashMenu?: FC>; }) => { const [show, setShow] = useState(false); diff --git a/packages/react/src/components/TableHandles/DefaultTableHandle.tsx b/packages/react/src/components/TableHandles/DefaultTableHandle.tsx new file mode 100644 index 0000000000..ead2db25dd --- /dev/null +++ b/packages/react/src/components/TableHandles/DefaultTableHandle.tsx @@ -0,0 +1,23 @@ +import { BlockSchemaWithBlock, DefaultBlockSchema } from "@blocknote/core"; +import { MdDragIndicator } from "react-icons/md"; +import { TableHandle } from "./TableHandle"; +import type { TableHandleProps } from "./TableHandlePositioner"; + +export const DefaultTableHandle = < + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> +>( + props: TableHandleProps +) => ( + +
            + +
            +
            +); diff --git a/packages/react/src/components/TableHandles/TableHandle.tsx b/packages/react/src/components/TableHandles/TableHandle.tsx new file mode 100644 index 0000000000..8b0213adbc --- /dev/null +++ b/packages/react/src/components/TableHandles/TableHandle.tsx @@ -0,0 +1,62 @@ +import { BlockSchemaWithBlock, DefaultBlockSchema } from "@blocknote/core"; +import { Menu, createStyles } from "@mantine/core"; +import { ReactNode, useState } from "react"; +import { DefaultTableHandleMenu } from "./TableHandleMenu/DefaultTableHandleMenu"; +import type { TableHandleProps } from "./TableHandlePositioner"; + +export const TableHandle = < + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> +>( + props: TableHandleProps & { children: ReactNode } +) => { + const { classes } = createStyles({ root: {} })(undefined, { + name: "TableHandle", + }); + + const TableHandleMenu = props.tableHandleMenu || DefaultTableHandleMenu; + + const [isDragging, setIsDragging] = useState(false); + + return ( + { + props.freezeHandles(); + props.hideOtherSide(); + }} + onClose={() => { + props.unfreezeHandles(); + props.showOtherSide(); + }} + position={"right"}> + +
            { + setIsDragging(true); + props.dragStart(e); + }} + onDragEnd={() => { + props.dragEnd(); + setIsDragging(false); + }} + style={ + props.orientation === "column" + ? { transform: "rotate(0.25turn)" } + : undefined + }> +
            + {props.children} +
            +
            +
            + +
            + ); +}; diff --git a/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/AddButton.tsx b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/AddButton.tsx new file mode 100644 index 0000000000..67c008b427 --- /dev/null +++ b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/AddButton.tsx @@ -0,0 +1,74 @@ +import { + DefaultBlockSchema, + PartialBlock, + TableContent, +} from "@blocknote/core"; +import { TableHandleMenuProps } from "../TableHandleMenu"; +import { TableHandleMenuItem } from "../TableHandleMenuItem"; + +export const AddRowButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps & { side: "above" | "below" } +) => ( + { + const emptyCol = props.block.content.rows[props.index].cells.map( + () => [] + ); + const rows = [...props.block.content.rows]; + rows.splice(props.index + (props.side === "below" ? 1 : 0), 0, { + cells: emptyCol, + }); + + props.editor.updateBlock(props.block, { + type: "table", + content: { + type: "tableContent", + rows, + }, + } as PartialBlock); + }}> + {`Add row ${props.side}`} + +); + +export const AddColumnButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps & { side: "left" | "right" } +) => ( + { + const content: TableContent = { + type: "tableContent", + rows: props.block.content.rows.map((row) => { + const cells = [...row.cells]; + cells.splice(props.index + (props.side === "right" ? 1 : 0), 0, []); + return { cells }; + }), + }; + + props.editor.updateBlock(props.block, { + type: "table", + content: content, + } as PartialBlock); + }}> + {`Add column ${props.side}`} + +); + +export const AddButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps & + ( + | { orientation: "row"; side: "above" | "below" } + | { orientation: "column"; side: "left" | "right" } + ) +) => + props.orientation === "row" ? ( + + ) : ( + + ); diff --git a/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/DeleteButton.tsx b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/DeleteButton.tsx new file mode 100644 index 0000000000..2a8af93c37 --- /dev/null +++ b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/DeleteButton.tsx @@ -0,0 +1,64 @@ +import { + DefaultBlockSchema, + PartialBlock, + TableContent, +} from "@blocknote/core"; +import { TableHandleMenuProps } from "../TableHandleMenu"; +import { TableHandleMenuItem } from "../TableHandleMenuItem"; + +export const DeleteRowButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps +) => ( + { + const content: TableContent = { + type: "tableContent", + rows: props.block.content.rows.filter( + (_, index) => index !== props.index + ), + }; + + props.editor.updateBlock(props.block, { + type: "table", + content, + } as PartialBlock); + }}> + Delete row + +); + +export const DeleteColumnButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps +) => ( + { + const content: TableContent = { + type: "tableContent", + rows: props.block.content.rows.map((row) => ({ + cells: row.cells.filter((_, index) => index !== props.index), + })), + }; + + props.editor.updateBlock(props.block, { + type: "table", + content, + } as PartialBlock); + }}> + Delete column + +); + +export const DeleteButton = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps & { orientation: "row" | "column" } +) => + props.orientation === "row" ? ( + + ) : ( + + ); diff --git a/packages/react/src/components/TableHandles/TableHandleMenu/DefaultTableHandleMenu.tsx b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultTableHandleMenu.tsx new file mode 100644 index 0000000000..28b418abdd --- /dev/null +++ b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultTableHandleMenu.tsx @@ -0,0 +1,33 @@ +import { DefaultBlockSchema } from "@blocknote/core"; +import { TableHandleMenu, TableHandleMenuProps } from "./TableHandleMenu"; +import { AddButton } from "./DefaultButtons/AddButton"; +import { DeleteButton } from "./DefaultButtons/DeleteButton"; + +export const DefaultTableHandleMenu = < + BSchema extends { table: DefaultBlockSchema["table"] } +>( + props: TableHandleMenuProps +) => ( + + + + + +); diff --git a/packages/react/src/components/TableHandles/TableHandleMenu/TableHandleMenu.tsx b/packages/react/src/components/TableHandles/TableHandleMenu/TableHandleMenu.tsx new file mode 100644 index 0000000000..24e649aa5c --- /dev/null +++ b/packages/react/src/components/TableHandles/TableHandleMenu/TableHandleMenu.tsx @@ -0,0 +1,33 @@ +import { + BlockNoteEditor, + DefaultBlockSchema, + SpecificBlock, +} from "@blocknote/core"; +import { Menu, createStyles } from "@mantine/core"; +import { ReactNode } from "react"; + +export type TableHandleMenuProps< + BSchema extends { table: DefaultBlockSchema["table"] } +> = { + orientation: "row" | "column"; + editor: BlockNoteEditor; + block: SpecificBlock< + { table: DefaultBlockSchema["table"] }, + "table", + any, + any + >; + index: number; +}; + +export const TableHandleMenu = (props: { children: ReactNode }) => { + const { classes } = createStyles({ root: {} })(undefined, { + name: "TableHandleMenu", + }); + + return ( + + {props.children} + + ); +}; diff --git a/packages/react/src/components/TableHandles/TableHandleMenu/TableHandleMenuItem.tsx b/packages/react/src/components/TableHandles/TableHandleMenu/TableHandleMenuItem.tsx new file mode 100644 index 0000000000..05518f027a --- /dev/null +++ b/packages/react/src/components/TableHandles/TableHandleMenu/TableHandleMenuItem.tsx @@ -0,0 +1,9 @@ +import { Menu, MenuItemProps } from "@mantine/core"; +import { PolymorphicComponentProps } from "@mantine/utils"; + +export const TableHandleMenuItem = ( + props: PolymorphicComponentProps<"button"> & MenuItemProps +) => { + const { children, ...remainingProps } = props; + return {children}; +}; diff --git a/packages/react/src/components/TableHandles/TableHandlePositioner.tsx b/packages/react/src/components/TableHandles/TableHandlePositioner.tsx new file mode 100644 index 0000000000..fd2ac9b7e0 --- /dev/null +++ b/packages/react/src/components/TableHandles/TableHandlePositioner.tsx @@ -0,0 +1,219 @@ +import { + BlockFromConfigNoChildren, + BlockNoteEditor, + BlockSchemaWithBlock, + DefaultBlockSchema, + InlineContentSchema, + StyleSchema, + TableHandlesProsemirrorPlugin, + TableHandlesState, +} from "@blocknote/core"; +import Tippy, { tippy } from "@tippyjs/react"; +import { DragEvent, FC, useEffect, useMemo, useRef, useState } from "react"; +import { DragHandleMenuProps } from "../SideMenu/DragHandleMenu/DragHandleMenu"; +import { DefaultTableHandle } from "./DefaultTableHandle"; + +export type TableHandleProps< + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I extends InlineContentSchema, + S extends StyleSchema +> = Pick< + TableHandlesProsemirrorPlugin, + "dragEnd" | "freezeHandles" | "unfreezeHandles" +> & + Omit< + TableHandlesState, + | "rowIndex" + | "colIndex" + | "referencePosCell" + | "referencePosTable" + | "show" + | "draggingState" + > & { + orientation: "row" | "column"; + editor: BlockNoteEditor< + BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]> + >; + tableHandleMenu?: FC>; + dragStart: (e: DragEvent) => void; + index: number; + // TODO: document this, explain why we need it + showOtherSide: () => void; + hideOtherSide: () => void; + }; + +export const TableHandlesPositioner = < + BSchema extends BlockSchemaWithBlock<"table", DefaultBlockSchema["table"]>, + I extends InlineContentSchema, + S extends StyleSchema +>(props: { + editor: BlockNoteEditor; + tableHandle?: FC>; +}) => { + const [show, setShow] = useState(false); + const [hideRow, setHideRow] = useState(false); + const [hideCol, setHideCol] = useState(false); + const [block, setBlock] = + useState>(); + + const [rowIndex, setRowIndex] = useState(); + const [colIndex, setColIndex] = useState(); + + const [draggedCellOrientation, setDraggedCellOrientation] = useState< + "row" | "col" | undefined + >(undefined); + const [mousePos, setMousePos] = useState(); + + const [, setForceUpdate] = useState(0); + + const referencePosCell = useRef(); + const referencePosTable = useRef(); + + useEffect(() => { + tippy.setDefaultProps({ maxWidth: "" }); + + return props.editor.tableHandles!.onUpdate((state) => { + // console.log("update", state.draggingState); + setShow(state.show); + setBlock(state.block); + setRowIndex(state.rowIndex); + setColIndex(state.colIndex); + + if (state.draggingState) { + setDraggedCellOrientation(state.draggingState.draggedCellOrientation); + setMousePos(state.draggingState.mousePos); + } else { + setDraggedCellOrientation(undefined); + setMousePos(undefined); + } + + setForceUpdate(Math.random()); + + referencePosCell.current = state.referencePosCell; + referencePosTable.current = state.referencePosTable; + }); + }, [props.editor]); + + const getReferenceClientRectRow = useMemo( + () => { + if (!referencePosCell.current || !referencePosTable.current) { + return undefined; + } + + if (draggedCellOrientation === "row") { + return () => + new DOMRect( + referencePosTable.current!.x, + mousePos!, + referencePosTable.current!.width, + 0 + ); + } + + return () => + new DOMRect( + referencePosTable.current!.x, + referencePosCell.current!.y, + referencePosTable.current!.width, + referencePosCell.current!.height + ); + }, + [referencePosTable.current, draggedCellOrientation, mousePos] // eslint-disable-line + ); + + const getReferenceClientRectColumn = useMemo( + () => { + if (!referencePosCell.current || !referencePosTable.current) { + return undefined; + } + + if (draggedCellOrientation === "col") { + return () => + new DOMRect( + mousePos!, + referencePosTable.current!.y, + 0, + referencePosTable.current!.height + ); + } + + return () => + new DOMRect( + referencePosCell.current!.x, + referencePosTable.current!.y, + referencePosCell.current!.width, + referencePosTable.current!.height + ); + }, + [referencePosTable.current, draggedCellOrientation, mousePos] // eslint-disable-line + ); + + const columnTableHandle = useMemo(() => { + const TableHandle = props.tableHandle || DefaultTableHandle; + + return ( + setHideRow(false)} + hideOtherSide={() => setHideRow(true)} + /> + ); + }, [block, props.editor, props.tableHandle, colIndex]); + + const rowTableHandle = useMemo(() => { + const TableHandle = props.tableHandle || DefaultTableHandle; + + return ( + setHideCol(false)} + hideOtherSide={() => setHideCol(true)} + /> + ); + }, [block, props.editor, props.tableHandle, rowIndex]); + + return ( + <> + + + + ); +}; + +const rowOffset: [number, number] = [0, -12]; +const columnOffset: [number, number] = [0, -12]; diff --git a/packages/react/src/BlockNoteTheme.ts b/packages/react/src/editor/BlockNoteTheme.ts similarity index 87% rename from packages/react/src/BlockNoteTheme.ts rename to packages/react/src/editor/BlockNoteTheme.ts index 3bad92071b..3a8a8d3436 100644 --- a/packages/react/src/BlockNoteTheme.ts +++ b/packages/react/src/editor/BlockNoteTheme.ts @@ -1,6 +1,5 @@ import { CSSObject, MantineThemeOverride } from "@mantine/core"; -import { blockStyles } from "@blocknote/core"; -import _ from "lodash"; +import merge from "lodash.merge"; export type CombinedColor = { text: string; @@ -41,6 +40,10 @@ export type ComponentStyles = Partial<{ Editor: CSSObject; // Used in the Image Toolbar FileInput: CSSObject; + // Handle that appears next to tables and the menu that opens when clicking it + TableHandle: CSSObject; + TableHandleMenu: CSSObject; + // Used in the Image Toolbar Tabs: CSSObject; TextInput: CSSObject; // Wraps Formatting Toolbar & Hyperlink Toolbar @@ -61,6 +64,7 @@ export type Theme = { export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { const shadow = `0 4px 12px ${theme.colors.shadow}`; + const lightShadow = `0 2px 6px ${theme.colors.border}`; const border = `1px solid ${theme.colors.border}`; const textColors = { @@ -102,7 +106,7 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { // Slash Menu, Formatting Toolbar dropdown, color picker dropdown Menu: { styles: () => ({ - dropdown: _.merge( + dropdown: merge( { backgroundColor: theme.colors.menu.background, border: border, @@ -131,9 +135,43 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { ), }), }, + TableHandle: { + styles: () => ({ + root: merge( + { + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.menu.background, + border: border, + borderRadius: innerBorderRadius, + boxShadow: lightShadow, + color: theme.colors.sideMenu, + ":hover, div.bn-table-handle-dragging": { + backgroundColor: theme.colors.hovered.background, + }, + cursor: "pointer", + }, + theme.componentStyles?.(theme).TableHandle || {} + ), + }), + }, + TableHandleMenu: { + styles: () => ({ + root: merge( + { + ".mantine-Menu-item": { + fontSize: "12px", + height: "30px", + }, + }, + theme.componentStyles?.(theme).TableHandleMenu || {} + ), + }), + }, Tabs: { styles: () => ({ - root: _.merge( + root: merge( { width: "100%", backgroundColor: theme.colors.menu.background, @@ -211,7 +249,7 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { }, ColorIcon: { styles: () => ({ - root: _.merge( + root: merge( { border: border, borderRadius: innerBorderRadius, @@ -222,7 +260,7 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { }, DragHandleMenu: { styles: () => ({ - root: _.merge( + root: merge( { ".mantine-Menu-item": { fontSize: "12px", @@ -235,7 +273,7 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { }, Editor: { styles: () => ({ - root: _.merge( + root: merge( { ".ProseMirror": { backgroundColor: theme.colors.editor.background, @@ -244,17 +282,16 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { fontFamily: theme.fontFamily, }, // Placeholders - [`.${blockStyles.isEmpty} .${blockStyles.inlineContent}:before, .${blockStyles.isFilter} .${blockStyles.inlineContent}:before`]: + ".bn-is-empty .bn-inline-content:before, .bn-is-filter .bn-inline-content:before": { color: theme.colors.sideMenu, }, // Indent lines - [`.${blockStyles.blockGroup}`]: { - [`.${blockStyles.blockGroup}`]: { - [`.${blockStyles.blockOuter}:not([data-prev-depth-changed])::before`]: - { - borderLeft: `1px solid ${theme.colors.sideMenu}`, - }, + ".bn-block-group": { + ".bn-block-group": { + ".bn-block-outer:not([data-prev-depth-changed])::before": { + borderLeft: `1px solid ${theme.colors.sideMenu}`, + }, }, }, // Highlight text colors @@ -278,7 +315,7 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { }, Toolbar: { styles: () => ({ - root: _.merge( + root: merge( { backgroundColor: theme.colors.menu.background, boxShadow: shadow, @@ -336,7 +373,7 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { }, ToolbarInputDropdown: { styles: () => ({ - root: _.merge( + root: merge( { backgroundColor: theme.colors.menu.background, border: border, @@ -381,7 +418,7 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { }, Tooltip: { styles: () => ({ - root: _.merge( + root: merge( { backgroundColor: theme.colors.tooltip.background, border: border, @@ -400,7 +437,7 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { }, SlashMenu: { styles: () => ({ - root: _.merge( + root: merge( { position: "relative", ".mantine-Menu-item": { @@ -433,7 +470,7 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => { }, SideMenu: { styles: () => ({ - root: _.merge( + root: merge( { backgroundColor: "transparent", ".mantine-UnstyledButton-root": { diff --git a/packages/react/src/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx similarity index 60% rename from packages/react/src/BlockNoteView.tsx rename to packages/react/src/editor/BlockNoteView.tsx index a5f3ee308b..30606ae4ac 100644 --- a/packages/react/src/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -1,20 +1,31 @@ -import { BlockNoteEditor, BlockSchema, mergeCSSClasses } from "@blocknote/core"; -import { createStyles, MantineProvider } from "@mantine/core"; +import { + BlockNoteEditor, + BlockSchema, + InlineContentSchema, + StyleSchema, + mergeCSSClasses, +} from "@blocknote/core"; +import { MantineProvider, createStyles } from "@mantine/core"; import { EditorContent } from "@tiptap/react"; import { HTMLAttributes, ReactNode, useMemo } from "react"; import usePrefersColorScheme from "use-prefers-color-scheme"; -import { blockNoteToMantineTheme, Theme } from "./BlockNoteTheme"; -import { FormattingToolbarPositioner } from "./FormattingToolbar/components/FormattingToolbarPositioner"; -import { HyperlinkToolbarPositioner } from "./HyperlinkToolbar/components/HyperlinkToolbarPositioner"; -import { SideMenuPositioner } from "./SideMenu/components/SideMenuPositioner"; -import { SlashMenuPositioner } from "./SlashMenu/components/SlashMenuPositioner"; +import { Theme, blockNoteToMantineTheme } from "./BlockNoteTheme"; +import { FormattingToolbarPositioner } from "../components/FormattingToolbar/FormattingToolbarPositioner"; +import { HyperlinkToolbarPositioner } from "../components/HyperlinkToolbar/HyperlinkToolbarPositioner"; +import { ImageToolbarPositioner } from "../components/ImageToolbar/ImageToolbarPositioner"; +import { SideMenuPositioner } from "../components/SideMenu/SideMenuPositioner"; +import { SlashMenuPositioner } from "../components/SlashMenu/SlashMenuPositioner"; +import { TableHandlesPositioner } from "../components/TableHandles/TableHandlePositioner"; import { darkDefaultTheme, lightDefaultTheme } from "./defaultThemes"; -import { ImageToolbarPositioner } from "./ImageToolbar/components/ImageToolbarPositioner"; // Renders the editor as well as all menus & toolbars using default styles. -function BaseBlockNoteView( +function BaseBlockNoteView< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema +>( props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor; children?: ReactNode; } & HTMLAttributes ) { @@ -36,15 +47,22 @@ function BaseBlockNoteView( + {props.editor.blockSchema.table && ( + + )} )} ); } -export function BlockNoteView( +export function BlockNoteView< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema +>( props: { - editor: BlockNoteEditor; + editor: BlockNoteEditor; theme?: | "light" | "dark" diff --git a/packages/react/src/Editor/EditorContent.tsx b/packages/react/src/editor/EditorContent.tsx similarity index 100% rename from packages/react/src/Editor/EditorContent.tsx rename to packages/react/src/editor/EditorContent.tsx diff --git a/packages/react/src/defaultThemes.ts b/packages/react/src/editor/defaultThemes.ts similarity index 98% rename from packages/react/src/defaultThemes.ts rename to packages/react/src/editor/defaultThemes.ts index 1a08ac5ed5..919b33586e 100644 --- a/packages/react/src/defaultThemes.ts +++ b/packages/react/src/editor/defaultThemes.ts @@ -1,4 +1,4 @@ -import { Theme } from "./index"; +import { Theme } from "./BlockNoteTheme"; export const defaultColorScheme = [ "#FFFFFF", diff --git a/packages/react/src/hooks/useActiveStyles.ts b/packages/react/src/hooks/useActiveStyles.ts new file mode 100644 index 0000000000..93e4ad41ac --- /dev/null +++ b/packages/react/src/hooks/useActiveStyles.ts @@ -0,0 +1,22 @@ +import { BlockNoteEditor, StyleSchema } from "@blocknote/core"; +import { useState } from "react"; +import { useEditorContentChange } from "./useEditorContentChange"; +import { useEditorSelectionChange } from "./useEditorSelectionChange"; + +export function useActiveStyles( + editor: BlockNoteEditor +) { + const [styles, setStyles] = useState(() => editor.getActiveStyles()); + + // Updates state on editor content change. + useEditorContentChange(editor, () => { + setStyles(editor.getActiveStyles()); + }); + + // Updates state on selection change. + useEditorSelectionChange(editor, () => { + setStyles(editor.getActiveStyles()); + }); + + return styles; +} diff --git a/packages/react/src/hooks/useBlockNote.ts b/packages/react/src/hooks/useBlockNote.ts index 39a8dc238f..2cf1f213e8 100644 --- a/packages/react/src/hooks/useBlockNote.ts +++ b/packages/react/src/hooks/useBlockNote.ts @@ -1,19 +1,30 @@ import { BlockNoteEditor, BlockNoteEditorOptions, - BlockSchema, - defaultBlockSchema, - DefaultBlockSchema, + BlockSchemaFromSpecs, + BlockSpecs, + InlineContentSchemaFromSpecs, + InlineContentSpecs, + StyleSchemaFromSpecs, + StyleSpecs, + defaultBlockSpecs, + defaultInlineContentSpecs, + defaultStyleSpecs, + getBlockSchemaFromSpecs, } from "@blocknote/core"; import { DependencyList, useMemo, useRef } from "react"; -import { getDefaultReactSlashMenuItems } from "../SlashMenu/defaultReactSlashMenuItems"; +import { getDefaultReactSlashMenuItems } from "../slashMenuItems/defaultReactSlashMenuItems"; -const initEditor = ( - options: Partial> +const initEditor = < + BSpecs extends BlockSpecs, + ISpecs extends InlineContentSpecs, + SSpecs extends StyleSpecs +>( + options: Partial> ) => - new BlockNoteEditor({ - slashMenuItems: getDefaultReactSlashMenuItems( - options.blockSchema || defaultBlockSchema + BlockNoteEditor.create({ + slashMenuItems: getDefaultReactSlashMenuItems( + getBlockSchemaFromSpecs(options.blockSpecs || defaultBlockSpecs) ), ...options, }); @@ -21,11 +32,22 @@ const initEditor = ( /** * Main hook for importing a BlockNote editor into a React project */ -export const useBlockNote = ( - options: Partial> = {}, +export const useBlockNote = < + BSpecs extends BlockSpecs = typeof defaultBlockSpecs, + ISpecs extends InlineContentSpecs = typeof defaultInlineContentSpecs, + SSpecs extends StyleSpecs = typeof defaultStyleSpecs +>( + options: Partial> = {}, deps: DependencyList = [] -): BlockNoteEditor => { - const editorRef = useRef>(); +) => { + const editorRef = + useRef< + BlockNoteEditor< + BlockSchemaFromSpecs, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs + > + >(); return useMemo(() => { if (editorRef.current) { @@ -33,6 +55,6 @@ export const useBlockNote = ( } editorRef.current = initEditor(options); - return editorRef.current; + return editorRef.current!; }, deps); //eslint-disable-line react-hooks/exhaustive-deps }; diff --git a/packages/react/src/hooks/useEditorChange.ts b/packages/react/src/hooks/useEditorChange.ts index f9408af9ba..517f980205 100644 --- a/packages/react/src/hooks/useEditorChange.ts +++ b/packages/react/src/hooks/useEditorChange.ts @@ -1,9 +1,9 @@ -import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import type { BlockNoteEditor } from "@blocknote/core"; import { useEditorContentChange } from "./useEditorContentChange"; import { useEditorSelectionChange } from "./useEditorSelectionChange"; -export function useEditorChange( - editor: BlockNoteEditor, +export function useEditorChange( + editor: BlockNoteEditor, callback: () => void ) { useEditorContentChange(editor, callback); diff --git a/packages/react/src/hooks/useEditorContentChange.ts b/packages/react/src/hooks/useEditorContentChange.ts index 64882bcf29..2922258a60 100644 --- a/packages/react/src/hooks/useEditorContentChange.ts +++ b/packages/react/src/hooks/useEditorContentChange.ts @@ -1,8 +1,8 @@ -import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import type { BlockNoteEditor } from "@blocknote/core"; import { useEffect } from "react"; -export function useEditorContentChange( - editor: BlockNoteEditor, +export function useEditorContentChange( + editor: BlockNoteEditor, callback: () => void ) { useEffect(() => { diff --git a/packages/react/src/hooks/useEditorSelectionChange.ts b/packages/react/src/hooks/useEditorSelectionChange.ts index 1072b31973..dab5c227cf 100644 --- a/packages/react/src/hooks/useEditorSelectionChange.ts +++ b/packages/react/src/hooks/useEditorSelectionChange.ts @@ -1,8 +1,8 @@ -import { BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import type { BlockNoteEditor } from "@blocknote/core"; import { useEffect } from "react"; -export function useEditorSelectionChange( - editor: BlockNoteEditor, +export function useEditorSelectionChange( + editor: BlockNoteEditor, callback: () => void ) { useEffect(() => { diff --git a/packages/react/src/hooks/useSelectedBlocks.ts b/packages/react/src/hooks/useSelectedBlocks.ts index 1a64543948..63ab136ed7 100644 --- a/packages/react/src/hooks/useSelectedBlocks.ts +++ b/packages/react/src/hooks/useSelectedBlocks.ts @@ -1,11 +1,21 @@ -import { Block, BlockNoteEditor, BlockSchema } from "@blocknote/core"; +import { + Block, + BlockNoteEditor, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; import { useState } from "react"; import { useEditorChange } from "./useEditorChange"; -export function useSelectedBlocks( - editor: BlockNoteEditor -) { - const [selectedBlocks, setSelectedBlocks] = useState[]>( +export function useSelectedBlocks< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema +>(editor: BlockNoteEditor) { + const [selectedBlocks, setSelectedBlocks] = useState< + Block[] + >( () => editor.getSelection()?.blocks || [editor.getTextCursorPosition().block] ); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0f0c8c6977..cce489d53c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,50 +1,53 @@ // TODO: review directories -export * from "./BlockNoteView"; -export * from "./BlockNoteTheme"; -export * from "./defaultThemes"; - -export * from "./FormattingToolbar/components/FormattingToolbarPositioner"; -export * from "./FormattingToolbar/components/DefaultFormattingToolbar"; -export * from "./FormattingToolbar/components/DefaultDropdowns/BlockTypeDropdown"; -export * from "./FormattingToolbar/components/DefaultButtons/ColorStyleButton"; -export * from "./FormattingToolbar/components/DefaultButtons/CreateLinkButton"; -export * from "./FormattingToolbar/components/DefaultButtons/NestBlockButtons"; -export * from "./FormattingToolbar/components/DefaultButtons/TextAlignButton"; -export * from "./FormattingToolbar/components/DefaultButtons/ToggledStyleButton"; - -export * from "./HyperlinkToolbar/components/HyperlinkToolbarPositioner"; - -export * from "./SideMenu/components/SideMenuPositioner"; -export * from "./SideMenu/components/SideMenu"; -export * from "./SideMenu/components/SideMenuButton"; -export * from "./SideMenu/components/DefaultSideMenu"; -export * from "./SideMenu/components/DefaultButtons/AddBlockButton"; -export * from "./SideMenu/components/DefaultButtons/DragHandle"; - -export * from "./SideMenu/components/DragHandleMenu/DragHandleMenu"; -export * from "./SideMenu/components/DragHandleMenu/DragHandleMenuItem"; -export * from "./SideMenu/components/DragHandleMenu/DefaultDragHandleMenu"; -export * from "./SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton"; -export * from "./SideMenu/components/DragHandleMenu/DefaultButtons/RemoveBlockButton"; - -export * from "./SlashMenu/components/SlashMenuPositioner"; -export * from "./SlashMenu/components/SlashMenuItem"; -export * from "./SlashMenu/components/DefaultSlashMenu"; -export * from "./SlashMenu/ReactSlashMenuItem"; -export * from "./SlashMenu/defaultReactSlashMenuItems"; - -export * from "./ImageToolbar/components/ImageToolbarPositioner"; -export * from "./ImageToolbar/components/DefaultImageToolbar"; - -export * from "./SharedComponents/Toolbar/components/Toolbar"; -export * from "./SharedComponents/Toolbar/components/ToolbarButton"; -export * from "./SharedComponents/Toolbar/components/ToolbarDropdown"; - +export * from "./editor/BlockNoteTheme"; +export * from "./editor/BlockNoteView"; +export * from "./editor/defaultThemes"; + +export * from "./components/FormattingToolbar/DefaultButtons/ColorStyleButton"; +export * from "./components/FormattingToolbar/DefaultButtons/CreateLinkButton"; +export * from "./components/FormattingToolbar/DefaultButtons/NestBlockButtons"; +export * from "./components/FormattingToolbar/DefaultButtons/TextAlignButton"; +export * from "./components/FormattingToolbar/DefaultButtons/ToggledStyleButton"; +export * from "./components/FormattingToolbar/DefaultDropdowns/BlockTypeDropdown"; +export * from "./components/FormattingToolbar/DefaultFormattingToolbar"; +export * from "./components/FormattingToolbar/FormattingToolbarPositioner"; + +export * from "./components/HyperlinkToolbar/HyperlinkToolbarPositioner"; + +export * from "./components/SideMenu/DefaultButtons/AddBlockButton"; +export * from "./components/SideMenu/DefaultButtons/DragHandle"; +export * from "./components/SideMenu/DefaultSideMenu"; +export * from "./components/SideMenu/SideMenu"; +export * from "./components/SideMenu/SideMenuButton"; +export * from "./components/SideMenu/SideMenuPositioner"; + +export * from "./components/SideMenu/DragHandleMenu/DefaultButtons/BlockColorsButton"; +export * from "./components/SideMenu/DragHandleMenu/DefaultButtons/RemoveBlockButton"; +export * from "./components/SideMenu/DragHandleMenu/DefaultDragHandleMenu"; +export * from "./components/SideMenu/DragHandleMenu/DragHandleMenu"; +export * from "./components/SideMenu/DragHandleMenu/DragHandleMenuItem"; + +export * from "./slashMenuItems/ReactSlashMenuItem"; +export * from "./components/SlashMenu/DefaultSlashMenu"; +export * from "./components/SlashMenu/SlashMenuItem"; +export * from "./components/SlashMenu/SlashMenuPositioner"; +export * from "./slashMenuItems/defaultReactSlashMenuItems"; + +export * from "./components/ImageToolbar/DefaultImageToolbar"; +export * from "./components/ImageToolbar/ImageToolbarPositioner"; + +export * from "./components-shared/Toolbar/Toolbar"; +export * from "./components-shared/Toolbar/ToolbarButton"; +export * from "./components-shared/Toolbar/ToolbarDropdown"; + +export * from "./hooks/useActiveStyles"; export * from "./hooks/useBlockNote"; -export * from "./hooks/useEditorForceUpdate"; +export * from "./hooks/useEditorChange"; export * from "./hooks/useEditorContentChange"; +export * from "./hooks/useEditorForceUpdate"; export * from "./hooks/useEditorSelectionChange"; -export * from "./hooks/useEditorChange"; export * from "./hooks/useSelectedBlocks"; -export * from "./ReactBlockSpec"; +export * from "./schema/ReactBlockSpec"; +export * from "./schema/ReactInlineContentSpec"; +export * from "./schema/ReactStyleSpec"; diff --git a/packages/react/src/schema/@util/ReactRenderUtil.ts b/packages/react/src/schema/@util/ReactRenderUtil.ts new file mode 100644 index 0000000000..36262e9392 --- /dev/null +++ b/packages/react/src/schema/@util/ReactRenderUtil.ts @@ -0,0 +1,37 @@ +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; + +export function renderToDOMSpec( + fc: (refCB: (ref: HTMLElement | null) => void) => React.ReactNode +) { + let contentDOM: HTMLElement | undefined; + const div = document.createElement("div"); + const root = createRoot(div); + flushSync(() => { + root.render(fc((el) => (contentDOM = el || undefined))); + }); + + if (!div.childElementCount) { + // TODO + console.warn("ReactInlineContentSpec: renderHTML() failed"); + return { + dom: document.createElement("span"), + }; + } + + // clone so we can unmount the react root + contentDOM?.setAttribute("data-tmp-find", "true"); + const cloneRoot = div.cloneNode(true) as HTMLElement; + const dom = cloneRoot.firstElementChild! as HTMLElement; + const contentDOMClone = cloneRoot.querySelector( + "[data-tmp-find]" + ) as HTMLElement | null; + contentDOMClone?.removeAttribute("data-tmp-find"); + + root.unmount(); + + return { + dom, + contentDOM: contentDOMClone || undefined, + }; +} diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx new file mode 100644 index 0000000000..77e7e398d1 --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -0,0 +1,224 @@ +import { + BlockFromConfig, + BlockNoteEditor, + BlockSchemaWithBlock, + camelToDataKebab, + createInternalBlockSpec, + createStronglyTypedTiptapNode, + CustomBlockConfig, + getBlockFromPos, + getParseRules, + inheritedProps, + InlineContentSchema, + mergeCSSClasses, + PartialBlockFromConfig, + Props, + PropSchema, + propsToAttributes, + StyleSchema, +} from "@blocknote/core"; +import { + NodeViewContent, + NodeViewProps, + NodeViewWrapper, + ReactNodeViewRenderer, +} from "@tiptap/react"; +import { FC } from "react"; +import { renderToDOMSpec } from "./@util/ReactRenderUtil"; + +// this file is mostly analogoues to `customBlocks.ts`, but for React blocks + +// extend BlockConfig but use a React render function +export type ReactCustomBlockImplementation< + T extends CustomBlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +> = { + render: FC<{ + block: BlockFromConfig; + editor: BlockNoteEditor, I, S>; + contentRef: (node: HTMLElement | null) => void; + }>; + toExternalHTML?: FC<{ + block: BlockFromConfig; + editor: BlockNoteEditor, I, S>; + contentRef: (node: HTMLElement | null) => void; + }>; + parse?: ( + el: HTMLElement + ) => PartialBlockFromConfig["props"] | undefined; +}; + +// Function that wraps the React component returned from 'blockConfig.render' in +// a `NodeViewWrapper` which also acts as a `blockContent` div. It contains the +// block type and props as HTML attributes. +export function reactWrapInBlockStructure< + BType extends string, + PSchema extends PropSchema +>( + element: JSX.Element, + blockType: BType, + blockProps: Props, + propSchema: PSchema, + domAttributes?: Record +) { + return () => ( + // Creates `blockContent` element + key !== "class") + )} + // Sets blockContent class + className={mergeCSSClasses( + "bn-block-content", + domAttributes?.class || "" + )} + // Sets content type attribute + data-content-type={blockType} + // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips + // props which are already added as HTML attributes to the parent + // `blockContent` element (inheritedProps) and props set to their default + // values + {...Object.fromEntries( + Object.entries(blockProps) + .filter( + ([prop, value]) => + !inheritedProps.includes(prop) && + value !== propSchema[prop].default + ) + .map(([prop, value]) => { + return [camelToDataKebab(prop), value]; + }) + )}> + {element} + + ); +} + +// A function to create custom block for API consumers +// we want to hide the tiptap node from API consumers and provide a simpler API surface instead +export function createReactBlockSpec< + T extends CustomBlockConfig, + I extends InlineContentSchema, + S extends StyleSchema +>( + blockConfig: T, + blockImplementation: ReactCustomBlockImplementation +) { + const node = createStronglyTypedTiptapNode({ + name: blockConfig.type as T["type"], + content: (blockConfig.content === "inline" + ? "inline*" + : "") as T["content"] extends "inline" ? "inline*" : "", + group: "blockContent", + selectable: true, + + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, + + parseHTML() { + return getParseRules(blockConfig, blockImplementation.parse); + }, + + renderHTML() { + // renderHTML is not really used, as we always use a nodeView, and we use toExternalHTML / toInternalHTML for serialization + // There's an edge case when this gets called nevertheless; before the nodeviews have been mounted + // this is why we implement it with a temporary placeholder + const div = document.createElement("div"); + div.setAttribute("data-tmp-placeholder", "true"); + return { + dom: div, + }; + }, + + addNodeView() { + return (props) => + ReactNodeViewRenderer( + (props: NodeViewProps) => { + // Gets the BlockNote editor instance + const editor = this.options.editor! as BlockNoteEditor; + // Gets the block + const block = getBlockFromPos( + props.getPos, + editor, + this.editor, + blockConfig.type + ) as any; + // Gets the custom HTML attributes for `blockContent` nodes + const blockContentDOMAttributes = + this.options.domAttributes?.blockContent || {}; + + // hacky, should export `useReactNodeView` from tiptap to get access to ref + const ref = (NodeViewContent({}) as any).ref; + + const Content = blockImplementation.render; + const BlockContent = reactWrapInBlockStructure( + , + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + + return ; + }, + { + className: "bn-react-node-view-renderer", + } + )(props); + }, + }); + + return createInternalBlockSpec(blockConfig, { + node: node, + toInternalHTML: (block, editor) => { + const blockContentDOMAttributes = + node.options.domAttributes?.blockContent || {}; + + const Content = blockImplementation.render; + const output = renderToDOMSpec((refCB) => { + const BlockContent = reactWrapInBlockStructure( + , + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + return ; + }); + output.contentDOM?.setAttribute("data-editable", ""); + + return output; + }, + toExternalHTML: (block, editor) => { + const blockContentDOMAttributes = + node.options.domAttributes?.blockContent || {}; + + const Content = + blockImplementation.toExternalHTML || blockImplementation.render; + const output = renderToDOMSpec((refCB) => { + const BlockContent = reactWrapInBlockStructure( + , + block.type, + block.props, + blockConfig.propSchema, + blockContentDOMAttributes + ); + return ; + }); + output.contentDOM?.setAttribute("data-editable", ""); + + return output; + }, + }); +} diff --git a/packages/react/src/schema/ReactInlineContentSpec.tsx b/packages/react/src/schema/ReactInlineContentSpec.tsx new file mode 100644 index 0000000000..f8f8318ed1 --- /dev/null +++ b/packages/react/src/schema/ReactInlineContentSpec.tsx @@ -0,0 +1,173 @@ +import { + addInlineContentAttributes, + addInlineContentKeyboardShortcuts, + camelToDataKebab, + createInternalInlineContentSpec, + createStronglyTypedTiptapNode, + CustomInlineContentConfig, + getInlineContentParseRules, + InlineContentConfig, + InlineContentFromConfig, + nodeToCustomInlineContent, + Props, + PropSchema, + propsToAttributes, + StyleSchema, +} from "@blocknote/core"; +import { + NodeViewContent, + NodeViewProps, + NodeViewWrapper, + ReactNodeViewRenderer, +} from "@tiptap/react"; +// import { useReactNodeView } from "@tiptap/react/dist/packages/react/src/useReactNodeView"; +import { FC } from "react"; +import { renderToDOMSpec } from "./@util/ReactRenderUtil"; + +// this file is mostly analogoues to `customBlocks.ts`, but for React blocks + +// extend BlockConfig but use a React render function +export type ReactInlineContentImplementation< + T extends InlineContentConfig, + // I extends InlineContentSchema, + S extends StyleSchema +> = { + render: FC<{ + inlineContent: InlineContentFromConfig; + contentRef: (node: HTMLElement | null) => void; + }>; + // TODO? + // toExternalHTML?: FC<{ + // block: BlockFromConfig; + // editor: BlockNoteEditor, I, S>; + // }>; +}; + +// Function that adds a wrapper with necessary classes and attributes to the +// component returned from a custom inline content's 'render' function, to +// ensure no data is lost on internal copy & paste. +export function reactWrapInInlineContentStructure< + IType extends string, + PSchema extends PropSchema +>( + element: JSX.Element, + inlineContentType: IType, + inlineContentProps: Props, + propSchema: PSchema +) { + return () => ( + // Creates inline content section element + value !== propSchema[prop].default) + .map(([prop, value]) => { + return [camelToDataKebab(prop), value]; + }) + )}> + {element} + + ); +} + +// A function to create custom block for API consumers +// we want to hide the tiptap node from API consumers and provide a simpler API surface instead +export function createReactInlineContentSpec< + T extends CustomInlineContentConfig, + // I extends InlineContentSchema, + S extends StyleSchema +>( + inlineContentConfig: T, + inlineContentImplementation: ReactInlineContentImplementation +) { + const node = createStronglyTypedTiptapNode({ + name: inlineContentConfig.type as T["type"], + inline: true, + group: "inline", + selectable: inlineContentConfig.content === "styled", + atom: inlineContentConfig.content === "none", + content: (inlineContentConfig.content === "styled" + ? "inline*" + : "") as T["content"] extends "styled" ? "inline*" : "", + + addAttributes() { + return propsToAttributes(inlineContentConfig.propSchema); + }, + + addKeyboardShortcuts() { + return addInlineContentKeyboardShortcuts(inlineContentConfig); + }, + + parseHTML() { + return getInlineContentParseRules(inlineContentConfig); + }, + + renderHTML({ node }) { + const editor = this.options.editor; + + const ic = nodeToCustomInlineContent( + node, + editor.inlineContentSchema, + editor.styleSchema + ) as any as InlineContentFromConfig; // TODO: fix cast + const Content = inlineContentImplementation.render; + const output = renderToDOMSpec((refCB) => ( + + )); + + return addInlineContentAttributes( + output, + inlineContentConfig.type, + node.attrs as Props, + inlineContentConfig.propSchema + ); + }, + + // TODO: needed? + addNodeView() { + const editor = this.options.editor; + + return (props) => + ReactNodeViewRenderer( + (props: NodeViewProps) => { + // hacky, should export `useReactNodeView` from tiptap to get access to ref + const ref = (NodeViewContent({}) as any).ref; + + const Content = inlineContentImplementation.render; + const FullContent = reactWrapInInlineContentStructure( + // TODO: fix cast + } + />, + inlineContentConfig.type, + props.node.attrs as Props, + inlineContentConfig.propSchema + ); + return ; + }, + { + className: "bn-ic-react-node-view-renderer", + as: "span", + // contentDOMElementTag: "span", (requires tt upgrade) + } + )(props); + }, + }); + + return createInternalInlineContentSpec(inlineContentConfig, { + node: node, + } as any); +} diff --git a/packages/react/src/schema/ReactStyleSpec.tsx b/packages/react/src/schema/ReactStyleSpec.tsx new file mode 100644 index 0000000000..63f3242a06 --- /dev/null +++ b/packages/react/src/schema/ReactStyleSpec.tsx @@ -0,0 +1,62 @@ +import { + addStyleAttributes, + createInternalStyleSpec, + getStyleParseRules, + StyleConfig, + stylePropsToAttributes, +} from "@blocknote/core"; +import { Mark } from "@tiptap/react"; +import { FC } from "react"; +import { renderToDOMSpec } from "./@util/ReactRenderUtil"; + +// this file is mostly analogoues to `customBlocks.ts`, but for React blocks + +// extend BlockConfig but use a React render function +export type ReactCustomStyleImplementation = { + render: T["propSchema"] extends "boolean" + ? FC<{ contentRef: (el: HTMLElement | null) => void }> + : FC<{ contentRef: (el: HTMLElement | null) => void; value: string }>; +}; + +// A function to create custom block for API consumers +// we want to hide the tiptap node from API consumers and provide a simpler API surface instead +export function createReactStyleSpec( + styleConfig: T, + styleImplementation: ReactCustomStyleImplementation +) { + const mark = Mark.create({ + name: styleConfig.type, + + addAttributes() { + return stylePropsToAttributes(styleConfig.propSchema); + }, + + parseHTML() { + return getStyleParseRules(styleConfig); + }, + + renderHTML({ mark }) { + const props: any = {}; + + if (styleConfig.propSchema === "string") { + props.value = mark.attrs.stringValue; + } + + const Content = styleImplementation.render; + const renderResult = renderToDOMSpec((refCB) => ( + + )); + + return addStyleAttributes( + renderResult, + styleConfig.type, + mark.attrs.stringValue, + styleConfig.propSchema + ); + }, + }); + + return createInternalStyleSpec(styleConfig, { + mark, + }); +} diff --git a/packages/react/src/slashMenuItems/ReactSlashMenuItem.ts b/packages/react/src/slashMenuItems/ReactSlashMenuItem.ts new file mode 100644 index 0000000000..65ceb044f7 --- /dev/null +++ b/packages/react/src/slashMenuItems/ReactSlashMenuItem.ts @@ -0,0 +1,20 @@ +import { + BaseSlashMenuItem, + BlockSchema, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; + +export type ReactSlashMenuItem< + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +> = BaseSlashMenuItem & { + group: string; + icon: JSX.Element; + hint?: string; + shortcut?: string; +}; diff --git a/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx b/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx similarity index 76% rename from packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx rename to packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx index b8b1fe6a18..d252b8b605 100644 --- a/packages/react/src/SlashMenu/defaultReactSlashMenuItems.tsx +++ b/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx @@ -4,6 +4,8 @@ import { defaultBlockSchema, DefaultBlockSchema, getDefaultSlashMenuItems, + InlineContentSchema, + StyleSchema, } from "@blocknote/core"; import { RiH1, @@ -12,16 +14,17 @@ import { RiImage2Fill, RiListOrdered, RiListUnordered, + RiTable2, RiText, } from "react-icons/ri"; -import { formatKeyboardShortcut } from "../utils"; +import { formatKeyboardShortcut } from "@blocknote/core"; import { ReactSlashMenuItem } from "./ReactSlashMenuItem"; const extraFields: Record< string, Omit< ReactSlashMenuItem, - keyof BaseSlashMenuItem + keyof BaseSlashMenuItem > > = { Heading: { @@ -60,6 +63,12 @@ const extraFields: Record< hint: "Used for the body of your document", shortcut: formatKeyboardShortcut("Mod-Alt-0"), }, + Table: { + group: "Advanced", + icon: , + hint: "Used for for tables", + // shortcut: formatKeyboardShortcut("Mod-Alt-0"), + }, Image: { group: "Media", icon: , @@ -67,14 +76,18 @@ const extraFields: Record< }, }; -export function getDefaultReactSlashMenuItems( +export function getDefaultReactSlashMenuItems< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( // This type casting is weird, but it's the best way of doing it, as it allows // the schema type to be automatically inferred if it is defined, or be // inferred as any if it is not defined. I don't think it's possible to make it // infer to DefaultBlockSchema if it is not defined. - schema: BSchema = defaultBlockSchema as unknown as BSchema -): ReactSlashMenuItem[] { - const slashMenuItems: BaseSlashMenuItem[] = + schema: BSchema = defaultBlockSchema as any as BSchema +): ReactSlashMenuItem[] { + const slashMenuItems: BaseSlashMenuItem[] = getDefaultSlashMenuItems(schema); return slashMenuItems.map((item) => ({ diff --git a/packages/react/src/test/__snapshots__/fontSize/basic/external.html b/packages/react/src/test/__snapshots__/fontSize/basic/external.html new file mode 100644 index 0000000000..0271307b91 --- /dev/null +++ b/packages/react/src/test/__snapshots__/fontSize/basic/external.html @@ -0,0 +1 @@ +

            This is text with a custom fontSize

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/fontSize/basic/internal.html b/packages/react/src/test/__snapshots__/fontSize/basic/internal.html new file mode 100644 index 0000000000..4b0f00259b --- /dev/null +++ b/packages/react/src/test/__snapshots__/fontSize/basic/internal.html @@ -0,0 +1 @@ +

            This is text with a custom fontSize

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/mention/basic/external.html b/packages/react/src/test/__snapshots__/mention/basic/external.html new file mode 100644 index 0000000000..ac270828c3 --- /dev/null +++ b/packages/react/src/test/__snapshots__/mention/basic/external.html @@ -0,0 +1 @@ +

            I enjoy working with @Matthew

            diff --git a/packages/react/src/test/__snapshots__/mention/basic/internal.html b/packages/react/src/test/__snapshots__/mention/basic/internal.html new file mode 100644 index 0000000000..b429a68a6f --- /dev/null +++ b/packages/react/src/test/__snapshots__/mention/basic/internal.html @@ -0,0 +1 @@ +

            I enjoy working with @Matthew

            diff --git a/packages/react/src/test/__snapshots__/nodeConversion.test.tsx.snap b/packages/react/src/test/__snapshots__/nodeConversion.test.tsx.snap new file mode 100644 index 0000000000..40418cd364 --- /dev/null +++ b/packages/react/src/test/__snapshots__/nodeConversion.test.tsx.snap @@ -0,0 +1,461 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert reactCustomParagraph/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "React Custom Paragraph", + "type": "text", + }, + ], + "type": "reactCustomParagraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert reactCustomParagraph/nested to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "React Custom Paragraph", + "type": "text", + }, + ], + "type": "reactCustomParagraph", + }, + { + "content": [ + { + "attrs": { + "backgroundColor": "default", + "id": "2", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "Nested React Custom Paragraph 1", + "type": "text", + }, + ], + "type": "reactCustomParagraph", + }, + ], + "type": "blockContainer", + }, + { + "attrs": { + "backgroundColor": "default", + "id": "3", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "Nested React Custom Paragraph 2", + "type": "text", + }, + ], + "type": "reactCustomParagraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "blockGroup", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert reactCustomParagraph/styled to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "pink", + "id": "1", + "textColor": "orange", + }, + "content": [ + { + "attrs": { + "textAlignment": "center", + }, + "content": [ + { + "text": "Plain ", + "type": "text", + }, + { + "marks": [ + { + "attrs": { + "stringValue": "red", + }, + "type": "textColor", + }, + ], + "text": "Red Text ", + "type": "text", + }, + { + "marks": [ + { + "attrs": { + "stringValue": "blue", + }, + "type": "backgroundColor", + }, + ], + "text": "Blue Background ", + "type": "text", + }, + { + "marks": [ + { + "attrs": { + "stringValue": "red", + }, + "type": "textColor", + }, + { + "attrs": { + "stringValue": "blue", + }, + "type": "backgroundColor", + }, + ], + "text": "Mixed Colors", + "type": "text", + }, + ], + "type": "reactCustomParagraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert simpleReactCustomParagraph/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "React Custom Paragraph", + "type": "text", + }, + ], + "type": "simpleReactCustomParagraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert simpleReactCustomParagraph/nested to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "Custom React Paragraph", + "type": "text", + }, + ], + "type": "simpleReactCustomParagraph", + }, + { + "content": [ + { + "attrs": { + "backgroundColor": "default", + "id": "2", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "Nested React Custom Paragraph 1", + "type": "text", + }, + ], + "type": "simpleReactCustomParagraph", + }, + ], + "type": "blockContainer", + }, + { + "attrs": { + "backgroundColor": "default", + "id": "3", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "Nested React Custom Paragraph 2", + "type": "text", + }, + ], + "type": "simpleReactCustomParagraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "blockGroup", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert simpleReactCustomParagraph/styled to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "pink", + "id": "1", + "textColor": "orange", + }, + "content": [ + { + "attrs": { + "textAlignment": "center", + }, + "content": [ + { + "text": "Plain ", + "type": "text", + }, + { + "marks": [ + { + "attrs": { + "stringValue": "red", + }, + "type": "textColor", + }, + ], + "text": "Red Text ", + "type": "text", + }, + { + "marks": [ + { + "attrs": { + "stringValue": "blue", + }, + "type": "backgroundColor", + }, + ], + "text": "Blue Background ", + "type": "text", + }, + { + "marks": [ + { + "attrs": { + "stringValue": "red", + }, + "type": "textColor", + }, + { + "attrs": { + "stringValue": "blue", + }, + "type": "backgroundColor", + }, + ], + "text": "Mixed Colors", + "type": "text", + }, + ], + "type": "simpleReactCustomParagraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react inline content schema > Convert mention/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "I enjoy working with ", + "type": "text", + }, + { + "attrs": { + "user": "Matthew", + }, + "type": "mention", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react inline content schema > Convert tag/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "text": "I love ", + "type": "text", + }, + { + "content": [ + { + "text": "BlockNote", + "type": "text", + }, + ], + "type": "tag", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react style schema > Convert fontSize/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "marks": [ + { + "attrs": { + "stringValue": "18px", + }, + "type": "fontSize", + }, + ], + "text": "This is text with a custom fontSize", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", +} +`; + +exports[`Test React BlockNote-Prosemirror conversion > Case: custom react style schema > Convert small/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "attrs": { + "textAlignment": "left", + }, + "content": [ + { + "marks": [ + { + "type": "small", + }, + ], + "text": "This is a small text", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", +} +`; diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/external.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/external.html new file mode 100644 index 0000000000..2971f11056 --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/external.html @@ -0,0 +1 @@ +

            Hello World

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/internal.html new file mode 100644 index 0000000000..c9e89afa27 --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/basic/internal.html @@ -0,0 +1 @@ +

            React Custom Paragraph

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/external.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/external.html new file mode 100644 index 0000000000..bc678da1a8 --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/external.html @@ -0,0 +1 @@ +

            Hello World

            Hello World

            Hello World

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html new file mode 100644 index 0000000000..a1c64daac7 --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/nested/internal.html @@ -0,0 +1 @@ +

            React Custom Paragraph

            Nested React Custom Paragraph 1

            Nested React Custom Paragraph 2

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/external.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/external.html new file mode 100644 index 0000000000..2971f11056 --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/external.html @@ -0,0 +1 @@ +

            Hello World

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html new file mode 100644 index 0000000000..eebeaab1cb --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactCustomParagraph/styled/internal.html @@ -0,0 +1 @@ +

            Plain Red Text Blue Background Mixed Colors

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/external.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/external.html new file mode 100644 index 0000000000..e60c23faa1 --- /dev/null +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/external.html @@ -0,0 +1 @@ +

            React Custom Paragraph

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html new file mode 100644 index 0000000000..10e606f71a --- /dev/null +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/basic/internal.html @@ -0,0 +1 @@ +

            React Custom Paragraph

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/external.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/external.html new file mode 100644 index 0000000000..a8354335ca --- /dev/null +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/external.html @@ -0,0 +1 @@ +

            Custom React Paragraph

            Nested React Custom Paragraph 1

            Nested React Custom Paragraph 2

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/internal.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/internal.html new file mode 100644 index 0000000000..17437945b5 --- /dev/null +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/nested/internal.html @@ -0,0 +1 @@ +

            Custom React Paragraph

            Nested React Custom Paragraph 1

            Nested React Custom Paragraph 2

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/external.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/external.html new file mode 100644 index 0000000000..970057fffe --- /dev/null +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/external.html @@ -0,0 +1 @@ +

            Plain Red Text Blue Background Mixed Colors

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html new file mode 100644 index 0000000000..4d9598bcca --- /dev/null +++ b/packages/react/src/test/__snapshots__/simpleReactCustomParagraph/styled/internal.html @@ -0,0 +1 @@ +

            Plain Red Text Blue Background Mixed Colors

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/small/basic/external.html b/packages/react/src/test/__snapshots__/small/basic/external.html new file mode 100644 index 0000000000..50ef98b2ce --- /dev/null +++ b/packages/react/src/test/__snapshots__/small/basic/external.html @@ -0,0 +1 @@ +

            This is a small text

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/small/basic/internal.html b/packages/react/src/test/__snapshots__/small/basic/internal.html new file mode 100644 index 0000000000..2c4b0446df --- /dev/null +++ b/packages/react/src/test/__snapshots__/small/basic/internal.html @@ -0,0 +1 @@ +

            This is a small text

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/tag/basic/external.html b/packages/react/src/test/__snapshots__/tag/basic/external.html new file mode 100644 index 0000000000..c243b63eb2 --- /dev/null +++ b/packages/react/src/test/__snapshots__/tag/basic/external.html @@ -0,0 +1 @@ +

            I love #BlockNote

            \ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/tag/basic/internal.html b/packages/react/src/test/__snapshots__/tag/basic/internal.html new file mode 100644 index 0000000000..dcb80c2f33 --- /dev/null +++ b/packages/react/src/test/__snapshots__/tag/basic/internal.html @@ -0,0 +1 @@ +

            I love #BlockNote

            \ No newline at end of file diff --git a/packages/react/src/test/htmlConversion.test.tsx b/packages/react/src/test/htmlConversion.test.tsx new file mode 100644 index 0000000000..08c01088db --- /dev/null +++ b/packages/react/src/test/htmlConversion.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom + +import { + BlockNoteEditor, + BlockSchema, + InlineContentSchema, + PartialBlock, + StyleSchema, + addIdsToBlocks, + createExternalHTMLExporter, + createInternalHTMLSerializer, + partialBlocksToBlocksForTesting, +} from "@blocknote/core"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { customReactBlockSchemaTestCases } from "./testCases/customReactBlocks"; +import { customReactInlineContentTestCases } from "./testCases/customReactInlineContent"; +import { customReactStylesTestCases } from "./testCases/customReactStyles"; + +// TODO: code same from @blocknote/core, maybe create separate test util package +async function convertToHTMLAndCompareSnapshots< + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor, + blocks: PartialBlock[], + snapshotDirectory: string, + snapshotName: string +) { + addIdsToBlocks(blocks); + const serializer = createInternalHTMLSerializer( + editor._tiptapEditor.schema, + editor + ); + const internalHTML = serializer.serializeBlocks(blocks); + const internalHTMLSnapshotPath = + "./__snapshots__/" + + snapshotDirectory + + "/" + + snapshotName + + "/internal.html"; + expect(internalHTML).toMatchFileSnapshot(internalHTMLSnapshotPath); + + // turn the internalHTML back into blocks, and make sure no data was lost + const fullBlocks = partialBlocksToBlocksForTesting( + editor.blockSchema, + blocks + ); + const parsed = await editor.tryParseHTMLToBlocks(internalHTML); + + expect(parsed).toStrictEqual(fullBlocks); + + // Create the "external" HTML, which is a cleaned up HTML representation, but lossy + const exporter = createExternalHTMLExporter( + editor._tiptapEditor.schema, + editor + ); + const externalHTML = exporter.exportBlocks(blocks); + const externalHTMLSnapshotPath = + "./__snapshots__/" + + snapshotDirectory + + "/" + + snapshotName + + "/external.html"; + expect(externalHTML).toMatchFileSnapshot(externalHTMLSnapshotPath); +} + +const testCases = [ + customReactBlockSchemaTestCases, + customReactStylesTestCases, + customReactInlineContentTestCases, +]; + +describe("Test React HTML conversion", () => { + for (const testCase of testCases) { + describe("Case: " + testCase.name, () => { + let editor: BlockNoteEditor; + + beforeEach(() => { + editor = testCase.createEditor(); + }); + + afterEach(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; + }); + + for (const document of testCase.documents) { + // eslint-disable-next-line no-loop-func + it("Convert " + document.name + " to HTML", async () => { + const nameSplit = document.name.split("/"); + await convertToHTMLAndCompareSnapshots( + editor, + document.blocks, + nameSplit[0], + nameSplit[1] + ); + }); + } + }); + } +}); diff --git a/packages/react/src/test/nodeConversion.test.tsx b/packages/react/src/test/nodeConversion.test.tsx new file mode 100644 index 0000000000..6c48f557a4 --- /dev/null +++ b/packages/react/src/test/nodeConversion.test.tsx @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + BlockNoteEditor, + PartialBlock, + UniqueID, + blockToNode, + nodeToBlock, + partialBlockToBlockForTesting, +} from "@blocknote/core"; +import { customReactBlockSchemaTestCases } from "./testCases/customReactBlocks"; +import { customReactInlineContentTestCases } from "./testCases/customReactInlineContent"; +import { customReactStylesTestCases } from "./testCases/customReactStyles"; + +function addIdsToBlock(block: PartialBlock) { + if (!block.id) { + block.id = UniqueID.options.generateID(); + } + for (const child of block.children || []) { + addIdsToBlock(child); + } +} + +function validateConversion( + block: PartialBlock, + editor: BlockNoteEditor +) { + addIdsToBlock(block); + const node = blockToNode( + block, + editor._tiptapEditor.schema, + editor.styleSchema + ); + + expect(node).toMatchSnapshot(); + + const outputBlock = nodeToBlock( + node, + editor.blockSchema, + editor.inlineContentSchema, + editor.styleSchema + ); + + const fullOriginalBlock = partialBlockToBlockForTesting( + editor.blockSchema, + block + ); + + expect(outputBlock).toStrictEqual(fullOriginalBlock); +} + +const testCases = [ + customReactBlockSchemaTestCases, + customReactStylesTestCases, + customReactInlineContentTestCases, +]; + +describe("Test React BlockNote-Prosemirror conversion", () => { + for (const testCase of testCases) { + describe("Case: " + testCase.name, () => { + let editor: BlockNoteEditor; + + beforeEach(() => { + editor = testCase.createEditor(); + }); + + afterEach(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; + }); + + for (const document of testCase.documents) { + // eslint-disable-next-line no-loop-func + it("Convert " + document.name + " to/from prosemirror", () => { + // NOTE: only converts first block + validateConversion(document.blocks[0], editor); + }); + } + }); + } +}); diff --git a/packages/react/src/test/testCases/customReactBlocks.tsx b/packages/react/src/test/testCases/customReactBlocks.tsx new file mode 100644 index 0000000000..c93beab64d --- /dev/null +++ b/packages/react/src/test/testCases/customReactBlocks.tsx @@ -0,0 +1,203 @@ +import { + BlockNoteEditor, + BlockSchemaFromSpecs, + BlockSpecs, + DefaultInlineContentSchema, + DefaultStyleSchema, + EditorTestCases, + defaultBlockSpecs, + defaultProps, + uploadToTmpFilesDotOrg_DEV_ONLY, +} from "@blocknote/core"; +import { createReactBlockSpec } from "../../schema/ReactBlockSpec"; + +const ReactCustomParagraph = createReactBlockSpec( + { + type: "reactCustomParagraph" as const, + propSchema: defaultProps, + content: "inline", + }, + { + render: (props) => ( +

            + ), + toExternalHTML: () => ( +

            Hello World

            + ), + } +); + +const SimpleReactCustomParagraph = createReactBlockSpec( + { + type: "simpleReactCustomParagraph" as const, + propSchema: defaultProps, + content: "inline", + }, + { + render: (props) => ( +

            + ), + } +); + +const customSpecs = { + ...defaultBlockSpecs, + reactCustomParagraph: ReactCustomParagraph, + simpleReactCustomParagraph: SimpleReactCustomParagraph, +} satisfies BlockSpecs; + +export const customReactBlockSchemaTestCases: EditorTestCases< + BlockSchemaFromSpecs, + DefaultInlineContentSchema, + DefaultStyleSchema +> = { + name: "custom react block schema", + createEditor: () => { + return BlockNoteEditor.create({ + blockSpecs: customSpecs, + uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, + }); + }, + documents: [ + { + name: "reactCustomParagraph/basic", + blocks: [ + { + type: "reactCustomParagraph", + content: "React Custom Paragraph", + }, + ], + }, + { + name: "reactCustomParagraph/styled", + blocks: [ + { + type: "reactCustomParagraph", + props: { + textAlignment: "center", + textColor: "orange", + backgroundColor: "pink", + }, + content: [ + { + type: "text", + styles: {}, + text: "Plain ", + }, + { + type: "text", + styles: { + textColor: "red", + }, + text: "Red Text ", + }, + { + type: "text", + styles: { + backgroundColor: "blue", + }, + text: "Blue Background ", + }, + { + type: "text", + styles: { + textColor: "red", + backgroundColor: "blue", + }, + text: "Mixed Colors", + }, + ], + }, + ], + }, + { + name: "reactCustomParagraph/nested", + blocks: [ + { + type: "reactCustomParagraph", + content: "React Custom Paragraph", + children: [ + { + type: "reactCustomParagraph", + content: "Nested React Custom Paragraph 1", + }, + { + type: "reactCustomParagraph", + content: "Nested React Custom Paragraph 2", + }, + ], + }, + ], + }, + { + name: "simpleReactCustomParagraph/basic", + blocks: [ + { + type: "simpleReactCustomParagraph", + content: "React Custom Paragraph", + }, + ], + }, + { + name: "simpleReactCustomParagraph/styled", + blocks: [ + { + type: "simpleReactCustomParagraph", + props: { + textAlignment: "center", + textColor: "orange", + backgroundColor: "pink", + }, + content: [ + { + type: "text", + styles: {}, + text: "Plain ", + }, + { + type: "text", + styles: { + textColor: "red", + }, + text: "Red Text ", + }, + { + type: "text", + styles: { + backgroundColor: "blue", + }, + text: "Blue Background ", + }, + { + type: "text", + styles: { + textColor: "red", + backgroundColor: "blue", + }, + text: "Mixed Colors", + }, + ], + }, + ], + }, + { + name: "simpleReactCustomParagraph/nested", + blocks: [ + { + type: "simpleReactCustomParagraph", + content: "Custom React Paragraph", + children: [ + { + type: "simpleReactCustomParagraph", + content: "Nested React Custom Paragraph 1", + }, + { + type: "simpleReactCustomParagraph", + content: "Nested React Custom Paragraph 2", + }, + ], + }, + ], + }, + ], +}; diff --git a/packages/react/src/test/testCases/customReactInlineContent.tsx b/packages/react/src/test/testCases/customReactInlineContent.tsx new file mode 100644 index 0000000000..f211c45236 --- /dev/null +++ b/packages/react/src/test/testCases/customReactInlineContent.tsx @@ -0,0 +1,101 @@ +import { + BlockNoteEditor, + DefaultBlockSchema, + DefaultStyleSchema, + EditorTestCases, + InlineContentSchemaFromSpecs, + InlineContentSpecs, + defaultInlineContentSpecs, + uploadToTmpFilesDotOrg_DEV_ONLY, +} from "@blocknote/core"; +import { createReactInlineContentSpec } from "../../schema/ReactInlineContentSpec"; + +const mention = createReactInlineContentSpec( + { + type: "mention", + propSchema: { + user: { + default: "", + }, + }, + content: "none", + }, + { + render: (props) => { + return @{props.inlineContent.props.user}; + }, + } +); + +const tag = createReactInlineContentSpec( + { + type: "tag", + propSchema: {}, + content: "styled", + }, + { + render: (props) => { + return ( + + # + + ); + }, + } +); + +const customReactInlineContent = { + ...defaultInlineContentSpecs, + tag, + mention, +} satisfies InlineContentSpecs; + +export const customReactInlineContentTestCases: EditorTestCases< + DefaultBlockSchema, + InlineContentSchemaFromSpecs, + DefaultStyleSchema +> = { + name: "custom react inline content schema", + createEditor: () => { + return BlockNoteEditor.create({ + uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, + inlineContentSpecs: customReactInlineContent, + }); + }, + documents: [ + { + name: "mention/basic", + blocks: [ + { + type: "paragraph", + content: [ + "I enjoy working with ", + { + type: "mention", + props: { + user: "Matthew", + }, + content: undefined, + } as any, + ], + }, + ], + }, + { + name: "tag/basic", + blocks: [ + { + type: "paragraph", + content: [ + "I love ", + { + type: "tag", + // props: {}, + content: "BlockNote", + } as any, + ], + }, + ], + }, + ], +}; diff --git a/packages/react/src/test/testCases/customReactStyles.tsx b/packages/react/src/test/testCases/customReactStyles.tsx new file mode 100644 index 0000000000..0992b791a4 --- /dev/null +++ b/packages/react/src/test/testCases/customReactStyles.tsx @@ -0,0 +1,93 @@ +import { + BlockNoteEditor, + DefaultBlockSchema, + DefaultInlineContentSchema, + EditorTestCases, + StyleSchemaFromSpecs, + StyleSpecs, + defaultStyleSpecs, + uploadToTmpFilesDotOrg_DEV_ONLY, +} from "@blocknote/core"; +import { createReactStyleSpec } from "../../schema/ReactStyleSpec"; + +const small = createReactStyleSpec( + { + type: "small", + propSchema: "boolean", + }, + { + render: (props) => { + return ; + }, + } +); + +const fontSize = createReactStyleSpec( + { + type: "fontSize", + propSchema: "string", + }, + { + render: (props) => { + return ( + + ); + }, + } +); + +const customReactStyles = { + ...defaultStyleSpecs, + small, + fontSize, +} satisfies StyleSpecs; + +export const customReactStylesTestCases: EditorTestCases< + DefaultBlockSchema, + DefaultInlineContentSchema, + StyleSchemaFromSpecs +> = { + name: "custom react style schema", + createEditor: () => { + return BlockNoteEditor.create({ + uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, + styleSpecs: customReactStyles, + }); + }, + documents: [ + { + name: "small/basic", + blocks: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "This is a small text", + styles: { + small: true, + }, + }, + ], + }, + ], + }, + { + name: "fontSize/basic", + blocks: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "This is text with a custom fontSize", + styles: { + fontSize: "18px", + }, + }, + ], + }, + ], + }, + ], +}; diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts deleted file mode 100644 index e1f633eb61..0000000000 --- a/packages/react/src/utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const isAppleOS = () => - typeof navigator !== "undefined" && - (/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/vite.config.ts b/packages/react/vite.config.ts index dd9ecb9443..802a757bfe 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -6,8 +6,22 @@ import pkg from "./package.json"; // import eslintPlugin from "vite-plugin-eslint"; // https://vitejs.dev/config/ -export default defineConfig({ +export default defineConfig((conf) => ({ + test: { + environment: "jsdom", + setupFiles: ["./vitestSetup.ts"], + }, plugins: [react(), webpackStats()], + // used so that vitest resolves the core package from the sources instead of the built version + resolve: { + alias: + conf.command === "build" + ? ({} as Record) + : ({ + // load live from sources with live reload working + "@blocknote/core": path.resolve(__dirname, "../core/src/"), + } as Record), + }, build: { sourcemap: true, lib: { @@ -34,4 +48,4 @@ export default defineConfig({ }, }, }, -}); +})); diff --git a/packages/react/vitestSetup.ts b/packages/react/vitestSetup.ts new file mode 100644 index 0000000000..78f5b890bf --- /dev/null +++ b/packages/react/vitestSetup.ts @@ -0,0 +1,9 @@ +import { beforeEach, afterEach } from "vitest"; + +beforeEach(() => { + (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; +}); + +afterEach(() => { + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; +}); diff --git a/packages/website/docs/docs/block-types.md b/packages/website/docs/docs/block-types.md index 4327b121c8..eaf86cbb97 100644 --- a/packages/website/docs/docs/block-types.md +++ b/packages/website/docs/docs/block-types.md @@ -109,10 +109,10 @@ type ImageBlock = { id: string; type: "image"; props: { - url: string = "", - caption: string = "", + url: string = ""; + caption: string = ""; width: number = 512; - } & Omit + } & Omit; content: InlineContent[]; children: Block[]; }; @@ -124,6 +124,24 @@ type ImageBlock = { `width:` The image width in pixels. +### Table + +**Appearance** + +image + +**Type & Props** + +```typescript +type TableBlock = { + id: string; + type: "table"; + props: DefaultProps; + content: TableContent[]; + children: Block[]; +}; +``` + ## Default Block Properties While each type of block can have its own set of properties, there are some properties that all built-in block types have by default, which you can find in the definition for `DefaultProps`: @@ -144,78 +162,93 @@ type DefaultProps = { ## Custom Block Types -In addition to the default block types that BlockNote offers, you can also make your own custom blocks. Take a look at the demo below, in which we add a custom block containing an image and caption to a BlockNote editor, as well as a custom [Slash Menu Item](/docs/slash-menu#custom-items) to insert it. +In addition to the default block types that BlockNote offers, you can also make your own custom blocks. Take a look at the demo below, in which we add a custom block containing a paragraph with a different font to a BlockNote editor, as well as a custom [Slash Menu Item](/docs/slash-menu#custom-items) to insert it. ::: sandbox {template=react-ts} ```typescript-vue /App.tsx import { - BlockNoteEditor, - BlockSchema, - DefaultBlockSchema, defaultBlockSchema, + defaultBlockSpecs, defaultProps, } from "@blocknote/core"; import { BlockNoteView, useBlockNote, createReactBlockSpec, - InlineContent, ReactSlashMenuItem, getDefaultReactSlashMenuItems, } from "@blocknote/react"; import "@blocknote/core/style.css"; -import { RiImage2Fill } from "react-icons/ri"; +import { RiText } from "react-icons/ri"; export default function App() { - // Creates a custom image block. - const ImageBlock = createReactBlockSpec({ - type: "image", - propSchema: { - ...defaultProps, - src: { - default: "https://via.placeholder.com/1000", - }, - alt: { - default: "image", + // Creates a paragraph block with custom font. + const FontParagraphBlock = createReactBlockSpec( + { + type: "fontParagraph", + propSchema: { + ...defaultProps, + font: { + default: "Comic Sans MS", + }, }, + content: "inline", }, - containsInlineContent: true, - render: ({ block }) => ( -

            - {block.props.alt} - -
            - ), - }); + { + render: ({ block, contentRef }) => { + const style = { + fontFamily: block.props.font + }; + + return ( +

            + ); + }, + toExternalHTML: ({ contentRef }) =>

            , + parse: (element) => { + const font = element.style.fontFamily; + + if (font === "") { + return; + } + + return { + font: font || undefined, + }; + }, + } + ); - // The custom schema, which includes the default blocks and the custom image - // block. - const customSchema = { + // Our block schema, which contains the configs for blocks that we want our + // editor to use. + const blockSchema = { // Adds all default blocks. ...defaultBlockSchema, - // Adds the custom image block. - image: ImageBlock, - } satisfies BlockSchema; + // Adds the font paragraph. + fontParagraph: FontParagraphBlock.config, + }; + // Our block specs, which contain the configs and implementations for blocks + // that we want our editor to use. + const blockSpecs = { + // Adds all default blocks. + ...defaultBlockSpecs, + // Adds the font paragraph. + fontParagraph: FontParagraphBlock, + }; - // Creates a slash menu item for inserting an image block. - const insertImage: ReactSlashMenuItem = { - name: "Insert Image", + // Creates a slash menu item for inserting a font paragraph block. + const insertFontParagraph: ReactSlashMenuItem = { + name: "Insert Font Paragraph", execute: (editor) => { - const src: string | null = prompt("Enter image URL"); - const alt: string | null = prompt("Enter image alt text"); + const font = prompt("Enter font name"); editor.insertBlocks( [ { - type: "image", + type: "fontParagraph", props: { - src: src || "https://via.placeholder.com/1000", - alt: alt || "image", + font: font || undefined, }, }, ], @@ -223,19 +256,18 @@ export default function App() { "after" ); }, - aliases: ["image", "img", "picture", "media"], - group: "Media", - icon: , - hint: "Insert an image", + aliases: ["p", "paragraph", "font"], + group: "Other", + icon: , }; // Creates a new editor instance. const editor = useBlockNote({ // Tells BlockNote which blocks to use. - blockSchema: customSchema, + blockSpecs: blockSpecs, slashMenuItems: [ - ...getDefaultReactSlashMenuItems(customSchema), - insertImage, + ...getDefaultReactSlashMenuItems(blockSchema), + insertFontParagraph, ], }); @@ -270,105 +302,143 @@ We'd love to hear your feedback on GitHub or in our Discord community! To define a custom block type, we use the `createReactBlockSpec` function, for which you can see the definition below: ```typescript -type PropSchema = Record< +type PropSchema< + PrimitiveType extends "boolean" | "number" | "string" +> = Record< string, { - default: string; - values?: string[]; + default: PrimitiveType; + values?: PrimitiveType[]; }; > -function createReactBlockSpec(config: { - type: string; - propSchema: PropSchema; - containsInlineContent: boolean; - render: (props: { - block: Block, - editor: BlockNoteEditor - }) => JSX.Element; -}): BlockType; +function createReactBlockSpec( + blockConfig: { + type: string; + propSchema: PropSchema<"boolean" | "number" | "string">; + content: "inline" | "none" + }, + blockImplementation: { + render: React.FC<{ + block: Block; + editor: BlockNoteEditor; + contentRef: (node: HTMLElement | null) => void; + }>, + toExternalHTML?: React.FC<{ + block: Block; + editor: BlockNoteEditor; + contentRef: (node: HTMLElement | null) => void; + }>, + parse?: ( + element: HTMLElement + ) => PartialBlock["props"] | undefined; + } +): BlockType; ``` Let's look at our custom image block from the demo, and go over each field in-depth to explain how it works: ```typescript jsx -const ImageBlock = createReactBlockSpec({ - type: "image", - propSchema: { - src: { - default: "https://via.placeholder.com/1000", +const FontparagraphBlock = createReactBlockSpec( + { + type: "fontParagraph", + propSchema: { + ...defaultProps, + font: { + default: "Comic Sans MS", + }, }, + content: "inline", }, - containsInlineContent: true, - render: ({ block }) => ( -

            - {"Image"} - -
            - ), -}); + { + render: ({ block, contentRef }) => { + const style = { + fontFamily: block.props.font + }; + + return ( +

            + ); + }, + parse: (element) => { + const font = element.style.fontFamily; + return { + font: font || undefined, + }; + }, + } +); ``` -#### `type` +You can see that `createReactBlockSpec` takes two object arguments: + +#### `blockConfig` + +This defines the block's type, properties, and content type. It allows BlockNote to know how to handle manipulating the block internally and provide typing. + +**`type`** Defines the name of the block, in this case, `image`. -#### `propSchema` +**`content`** + +As we saw in [Block Objects](/docs/blocks#block-objects), blocks can contain editable rich text which is represented as [Inline Content](/docs/inline-content). The `content` field allows your custom block to contain an editable rich-text field. Since we want to be able to type in our paragraph, we set it to `"inline"`. + +**`propSchema`** -This is an object which defines the props that the block should have. In this case, we want the block to have a `src` prop for the URL of the image, so we add a `src` key. We also want basic styling options for the image block, so we also add the [Default Block Properties](/docs/block-types#default-block-properties) using `defaultProps`. The value of each key is an object with a mandatory `default` field and an optional `values` field: +This is an object which defines the props that the block should have. In this case, we want the block to have a `font` prop for the font that we want the paragraph to use, so we add a `font` key. We also want basic styling options, so we add the [Default Block Properties](/docs/block-types#default-block-properties) using `defaultProps`. The value of each key is an object with a mandatory `default` field and an optional `values` field: -`default:` Stores the prop's default value, so we use a placeholder image URL for `src` if no URL is provided. +`default:` Stores the prop's default value, in this case the Comic Sans MS font. -`values:` Stores an array of strings that the prop can take. If `values` is not defined, BlockNote assumes the prop can be any string, which makes sense for `src`, since it can be any image URL. +`values:` Stores an array of strings that the prop can take. If `values` is not defined, BlockNote assumes the prop can be any string, which makes sense for `font`, since we don't want to list every possible font name. -#### `containsInlineContent` +#### `blockImplementation` -As we saw in [Block Objects](/docs/blocks#block-objects), blocks can contain editable rich text which is represented as [Inline Content](/docs/inline-content). The `containsInlineContent` field allows your custom block to contain an editable rich-text field. For the custom image block, we use an inline content field to create our caption, so it's set to `true`. +This defines how the block should be rendered in the editor, and how it should be parsed from and converted to HTML. -#### `render` +**`render`** -This is a React component which defines how your custom block should be rendered in the editor, and takes two props: +This is a React component which defines how your custom block should be rendered in the editor, and takes three props: -`block:` The block that should be rendered. +`block:` The block that should be rendered. This will always have the same type, props, and content as defined in the block's config. `editor:` The BlockNote editor instance that the block is in. -For our custom image block, we use a parent `div` which contains the image and caption. Since `block` will always be an `image` block, we also know it contains a `src` prop, and can pass it to the child `img` element. +`contentRef:` A React `ref` that marks which element in your block is editable, This is only useful if your block config contains `content: "inline"`. -But what's this `InlineContent` component? Since we set `containsInlineContent` to `true`, it means we want to include an editable rich-text field somewhere in the image block. You should use the `InlineContent` component to represent this field in your `render` component. Since we're using it to create our caption, we add it below the `img` element. +**`toExternalHTML`** -In the DOM, the `InlineContent` component is rendered as a `div` by default, but you can make it use a different element by passing `as={"elementTag"}` as a prop. For example, `as={"p"}` will make the `InlineContent` component get rendered to a paragraph. +This is identical in definition as `render`, but is used whenever the block is being exported to HTML for use outside BlockNote, namely when copying it to the clipboard. If it's not defined, BlockNote will just use `render` for the HTML conversion. -While the `InlineContent` component can be put anywhere inside the component you pass to `render`, you should make sure to only have one. If `containsInlineContent` is set to false, `render` shouldn't contain any. +**`parse`** + +This is a function that allows you to define which HTML elements should be parsed into your block when importing HTML from outside BlockNote, namely when pasting it from the clipboard. If the element should be parsed into your custom block, you should return the props that the block should be given. Otherwise, return `undefined`. + +`element`: The HTML element that's being parsed. ### Adding Custom Blocks to the Editor -Now, we need to tell BlockNote to use our custom image block by passing it to the editor in the `blockSchema` option. Let's again look at the image block from the demo as an example: +Now, we need to tell BlockNote to use our font paragraph block by passing it to the editor in the `blockSpecs` option. Let's again look at the image block from the demo as an example: ```typescript jsx +// Our block specs, which contain the configs and implementations for blocks +// that we want our editor to use. +const blockSpecs = { + // Adds all default blocks. + ...defaultBlockSpecs, + // Adds the font paragraph. + fontParagraph: FontParagraphBlock, +}; + +... + // Creates a new editor instance. const editor = useBlockNote({ // Tells BlockNote which blocks to use. - blockSchema: { - // Adds all default blocks. - ...defaultBlockSchema, - // Adds the custom image block. - image: ImageBlock, - }, + blockSpecs: blockSpecs, }); ``` -Since we still want the editor to use the [Built-In Block Types](/docs/block-types#built-in-block-types), we add `defaultBlockSchema` to our custom block schema. The key which we use for the custom image block is the same string we use for its type. Make sure that this is always the case for your own custom blocks. +Since we still want the editor to use the [Built-In Block Types](/docs/block-types#built-in-block-types), we add `defaultBlockSpecs` too. The key which we use for the font paragraph block should also be the same string we use for its type. Make sure that this is always the case for your own custom blocks. And we're done! You now know how to create custom blocks and add them to the editor. Head to [Manipulating Blocks](/docs/manipulating-blocks) to see what you can do with them in the editor. diff --git a/packages/website/docs/docs/blocks.md b/packages/website/docs/docs/blocks.md index f22d6d69eb..81e7c99069 100644 --- a/packages/website/docs/docs/blocks.md +++ b/packages/website/docs/docs/blocks.md @@ -65,7 +65,7 @@ type Block = { `props:` The block's properties, which are stored in a set of key/value pairs and specify how the block looks and behaves. Different block types have different props - see [Block Types & Properties](/docs/block-types) for more. -`content:` The block's rich text content, represented as an array of `InlineContent` objects. This does not include content from any nested blocks. For more information on `InlineContent` objects, visit [Inline Content](/docs/inline-content). +`content:` The block's rich text content, represented as an array of `InlineContent` objects. This does not include content from any nested blocks. [Table](/docs/block-types#table) blocks are slightly different, as they contain `TableContent`, where each table cell is represented as an array of `InlineContent` objects. For more information on `InlineContent` and `TableContent` objects, visit [Inline Content](/docs/inline-content). `children:` Any blocks nested inside the block. The nested blocks are also represented using `Block` objects. diff --git a/packages/website/docs/docs/converting-blocks.md b/packages/website/docs/docs/converting-blocks.md index 8ffed0314c..e83ba1186b 100644 --- a/packages/website/docs/docs/converting-blocks.md +++ b/packages/website/docs/docs/converting-blocks.md @@ -34,12 +34,12 @@ BlockNote can import / export Blocks to and from Markdown. Note that this is als // Definition class BlockNoteEditor { ... - public blocksToMarkdown(blocks: Block[]): string; + public blocksToMarkdownLossy(blocks: Block[]): string; ... } // Usage -const markdownFromBlocks = editor.blocksToMarkdown(blocks); +const markdownFromBlocks = editor.blocksToMarkdownLossy(blocks); ``` `returns:` The blocks, serialized as a Markdown string. @@ -64,17 +64,17 @@ export default function App() { const editor: BlockNoteEditor = useBlockNote({ // Listens for when the editor's contents change. onEditorContentChange: (editor) => { - // Converts the editor's contents from Block objects to Markdown and + // Converts the editor's contents from Block objects to Markdown and // saves them. const saveBlocksAsMarkdown = async () => { - const markdown: string = - await editor.blocksToMarkdown(editor.topLevelBlocks); + const markdown: string = + await editor.blocksToMarkdownLossy(editor.topLevelBlocks); setMarkdown(markdown); }; saveBlocksAsMarkdown(); } }); - + // Renders the editor instance, and its contents as Markdown below. return (

            @@ -96,7 +96,7 @@ pre { ::: -### Converting Markdown to Blocks +### Parsing Markdown to Blocks `Block` objects can be parsed from a Markdown string using the following function: @@ -104,12 +104,12 @@ pre { // Definition class BlockNoteEditor { ... - public markdownToBlocks(markdown: string): Blocks[]; + public tryParseMarkdownToBlocks(markdown: string): Blocks[]; ... } // Usage -const blocksFromMarkdown = editor.markdownToBlocks(markdown); +const blocksFromMarkdown = editor.tryParseMarkdownToBlocks(markdown); ``` `returns:` The blocks parsed from the Markdown string. @@ -129,7 +129,7 @@ import "@blocknote/core/style.css"; export default function App() { // Stores the current Markdown content. const [markdown, setMarkdown] = useState(""); - + // Creates a new editor instance. const editor: BlockNoteEditor = useBlockNote({ // Makes the editor non-editable. @@ -141,7 +141,7 @@ export default function App() { // Whenever the current Markdown content changes, converts it to an array // of Block objects and replaces the editor's content with them. const getBlocks = async () => { - const blocks: Block[] = await editor.markdownToBlocks(markdown); + const blocks: Block[] = await editor.tryParseMarkdownToBlocks(markdown); editor.replaceBlocks(editor.topLevelBlocks, blocks); }; getBlocks(); @@ -181,21 +181,21 @@ We expose functions to convert Blocks to and from HTML. Converting Blocks to HTM ### Converting Blocks to HTML -`Block` objects can be serialized to an HTML string using the following function: +`Block` objects can be exported to an HTML string using the following function: ```typescript // Definition class BlockNoteEditor { ... - public blocksToHTML(blocks: Block[]): string; + public blocksToHTMLLossy(blocks: Block[]): string; ... } // Usage -const HTMLFromBlocks = editor.blocksToHTML(blocks); +const HTMLFromBlocks = editor.blocksToHTMLLossy(blocks); ``` -`returns:` The blocks, serialized as an HTML string. +`returns:` The blocks, exported to an HTML string. To better conform to HTML standards, children of blocks which aren't list items are un-nested in the output HTML. @@ -217,10 +217,10 @@ export default function App() { const editor: BlockNoteEditor = useBlockNote({ // Listens for when the editor's contents change. onEditorContentChange: (editor) => { - // Converts the editor's contents from Block objects to HTML and saves + // Converts the editor's contents from Block objects to HTML and saves // them. const saveBlocksAsHTML = async () => { - const html: string = await editor.blocksToHTML(editor.topLevelBlocks); + const html: string = await editor.blocksToHTMLLossy(editor.topLevelBlocks); setHTML(html); }; saveBlocksAsHTML(); @@ -248,7 +248,7 @@ pre { ::: -### Converting HTML to Blocks +### Parsing HTML to Blocks `Block` objects can be parsed from an HTML string using the following function: @@ -256,12 +256,12 @@ pre { // Definition class BlockNoteEditor { ... - public HTMLToBlocks(html: string): Blocks[]; + public tryParseHTMLToBlocks(html: string): Blocks[]; ... } // Usage -const blocksFromHTML = editor.HTMLToBlocks(html); +const blocksFromHTML = editor.tryParseHTMLToBlocks(html); ``` `returns:` The blocks parsed from the HTML string. @@ -281,7 +281,7 @@ import "@blocknote/core/style.css"; export default function App() { // Stores the current HTML content. const [html, setHTML] = useState(""); - + // Creates a new editor instance. const editor: BlockNoteEditor = useBlockNote({ // Makes the editor non-editable. @@ -290,10 +290,10 @@ export default function App() { useEffect(() => { if (editor) { - // Whenever the current HTML content changes, converts it to an array of + // Whenever the current HTML content changes, converts it to an array of // Block objects and replaces the editor's content with them. const getBlocks = async () => { - const blocks: Block[] = await editor.HTMLToBlocks(html); + const blocks: Block[] = await editor.tryParseHTMLToBlocks(html); editor.replaceBlocks(editor.topLevelBlocks, blocks); }; getBlocks(); @@ -325,4 +325,4 @@ textarea { } ``` -::: \ No newline at end of file +::: diff --git a/packages/website/docs/docs/inline-content.md b/packages/website/docs/docs/inline-content.md index 76948bee3a..212aba48f5 100644 --- a/packages/website/docs/docs/inline-content.md +++ b/packages/website/docs/docs/inline-content.md @@ -7,9 +7,11 @@ path: /docs/inline-content # Inline Content +## Inline Content Types + An array of `InlineContent` objects is used to describe the rich text content inside a block. Inline content can either be styled text or a link, and we'll go over both these in the upcoming sections. -## Styled Text +### Styled Text Styled text is a type of `InlineContent` used to display pieces of text with styles, and is defined using a `StyledText` object: @@ -25,7 +27,7 @@ type StyledText = { `styles:` The styles that are applied to the text. -### Styles Object +**Styles Object** `StyledText` supports a variety of styles, including bold, underline, and text color, which are represented using a `Styles` object: @@ -40,7 +42,7 @@ type Styles = Partial<{ }>; ``` -## Links +### Links Links are a type of `InlineContent` used to create clickable hyperlinks that go to some URL, and are made up of `StyledText`. They're defined using `Link` objects: @@ -56,6 +58,20 @@ type Link = { `href:` The URL that opens when clicking the link. +## Table Content + +While most blocks use an array of `InlineContent` objects to describe their content, tables are slightly different. They use a single `TableContent` object, which allows each table cell to be represented as an array of `InlineContent` objects instead: + + +```typescript +type TableContent = { + type: "tableContent"; + rows: { + cells: InlineContent[][]; + }[]; +}; +``` + ## Editor Functions While `InlineContent` objects are used to describe a block's content, they can be cumbersome to work with directly. Therefore, BlockNote exposes functions which make it easier to edit block contents. diff --git a/packages/website/docs/docs/vanilla-js.md b/packages/website/docs/docs/vanilla-js.md index 6d2b9ce4f4..e290034274 100644 --- a/packages/website/docs/docs/vanilla-js.md +++ b/packages/website/docs/docs/vanilla-js.md @@ -25,7 +25,7 @@ This is how to create a new BlockNote editor: ``` import { BlockNoteEditor } from "@blocknote/core"; -const editor = new BlockNoteEditor({ +const editor = BlockNoteEditor.create({ element: document.getElementById("root")!, // element to append the editor to onUpdate: ({ editor }) => { console.log(editor.getJSON()); @@ -47,7 +47,7 @@ Because we can't use the built-in React elements, you'll need to create and regi You can do this by passing custom component factories as `uiFactories`, e.g.: ``` -const editor = new BlockNoteEditor({ +const editor = BlockNoteEditor.create({ element: document.getElementById("root")!, uiFactories: { formattingToolbarFactory: customFormattingToolbarFactory, diff --git a/packages/website/docs/examples/alert-block.md b/packages/website/docs/examples/alert-block.md index cac334379c..666bb13b5a 100644 --- a/packages/website/docs/examples/alert-block.md +++ b/packages/website/docs/examples/alert-block.md @@ -159,14 +159,14 @@ export const alertPropSchema = { export const Alert = (props: { block: SpecificBlock< DefaultBlockSchema & { - alert: BlockSpec<"alert", typeof alertPropSchema, true>; - }, + alert: BlockSpec<"alert", typeof alertPropSchema, true>; + }, "alert" >; editor: BlockNoteEditor< DefaultBlockSchema & { - alert: BlockSpec<"alert", typeof alertPropSchema, true>; - } + alert: BlockSpec<"alert", typeof alertPropSchema, true>; + } >; theme: "light" | "dark"; }) => { @@ -210,7 +210,7 @@ export const Alert = (props: { {Object.entries(alertTypes).map(([key, value]) => { const ItemIcon = value.icon; - + return ( values: ["warning", "error", "info", "success"], }, } as const, - containsInlineContent: true, + content: "inline", render: (props) => , }); @@ -331,4 +331,4 @@ const inlineContentStyles = { {{ getStyles(isDark) }} ``` -::: \ No newline at end of file +::: diff --git a/packages/website/docs/public/img/screenshots/table_type.png b/packages/website/docs/public/img/screenshots/table_type.png new file mode 100644 index 0000000000..11921f6a82 Binary files /dev/null and b/packages/website/docs/public/img/screenshots/table_type.png differ diff --git a/packages/website/docs/public/img/screenshots/table_type_dark.png b/packages/website/docs/public/img/screenshots/table_type_dark.png new file mode 100644 index 0000000000..88b1c952f7 Binary files /dev/null and b/packages/website/docs/public/img/screenshots/table_type_dark.png differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/embed-image-chromium-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/embed-image-chromium-linux.png deleted file mode 100644 index 37d7509161..0000000000 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/embed-image-chromium-linux.png and /dev/null differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/embed-image-firefox-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/embed-image-firefox-linux.png deleted file mode 100644 index 02ef127097..0000000000 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/embed-image-firefox-linux.png and /dev/null differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/resize-image-chromium-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/resize-image-chromium-linux.png deleted file mode 100644 index 0f1c17d87a..0000000000 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/resize-image-chromium-linux.png and /dev/null differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/resize-image-firefox-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/resize-image-firefox-linux.png deleted file mode 100644 index 86c38f7231..0000000000 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/resize-image-firefox-linux.png and /dev/null differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/resize-image-webkit-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/resize-image-webkit-linux.png deleted file mode 100644 index 16915dada8..0000000000 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/resize-image-webkit-linux.png and /dev/null differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/upload-image-chromium-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/upload-image-chromium-linux.png deleted file mode 100644 index 68d47fba75..0000000000 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/upload-image-chromium-linux.png and /dev/null differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/upload-image-firefox-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/upload-image-firefox-linux.png deleted file mode 100644 index f24f9a043c..0000000000 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/upload-image-firefox-linux.png and /dev/null differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/upload-image-webkit-linux.png b/tests/end-to-end/images/images.test.ts-snapshots/upload-image-webkit-linux.png deleted file mode 100644 index 09de9b0b87..0000000000 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/upload-image-webkit-linux.png and /dev/null differ diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-chromium-linux.png b/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-chromium-linux.png deleted file mode 100644 index b7f35913d5..0000000000 Binary files a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-chromium-linux.png and /dev/null differ diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-firefox-linux.png b/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-firefox-linux.png deleted file mode 100644 index a92a186668..0000000000 Binary files a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-firefox-linux.png and /dev/null differ diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-webkit-linux.png b/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-webkit-linux.png deleted file mode 100644 index 9cbdd307db..0000000000 Binary files a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-webkit-linux.png and /dev/null differ diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000000..9141c59938 --- /dev/null +++ b/tests/package.json @@ -0,0 +1,30 @@ +{ + "name": "@blocknote/tests", + "private": true, + "version": "0.9.6", + "scripts": { + "build": "tsc", + "lint": "eslint src --max-warnings 0", + "playwright": "npx playwright test", + "test:updateSnaps": "docker run --rm -e RUN_IN_DOCKER=true --network host -v $(pwd)/..:/work/ -w /work/tests -it mcr.microsoft.com/playwright:v1.38.1-focal npx playwright test -u", + "test-ct": "playwright test -c playwright-ct.config.ts --headed", + "test-ct:updateSnaps": "docker run --rm -e RUN_IN_DOCKER=true --network host -v $(pwd)/..:/work/ -w /work/tests -it mcr.microsoft.com/playwright:v1.35.1-focal npm install && playwright test -c playwright-ct.config.ts -u" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "eslint": "^8.10.0", + "@blocknote/core": "^0.9.6", + "@blocknote/react": "^0.9.6", + "@playwright/experimental-ct-react": "^1.38.1", + "@playwright/test": "^1.38.1", + "react-icons": "^4.3.1" + }, + "eslintConfig": { + "extends": [ + "../.eslintrc.js" + ] + } +} diff --git a/playwright-ct.config.ts b/tests/playwright-ct.config.ts similarity index 94% rename from playwright-ct.config.ts rename to tests/playwright-ct.config.ts index 9eb80ef591..d21dc064b1 100644 --- a/playwright-ct.config.ts +++ b/tests/playwright-ct.config.ts @@ -4,9 +4,9 @@ import { defineConfig, devices } from "@playwright/experimental-ct-react"; * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: "./tests/component/", + testDir: "./src/component/", /* The base directory, relative to the config file, for snapshot files created with toMatchSnapshot and toHaveScreenshot. */ - snapshotDir: "./tests/component/snapshots", + snapshotDir: "./src/component/snapshots", /* Maximum time one test can run for. */ timeout: 10 * 1000, /* Run tests in files in parallel */ diff --git a/playwright.config.ts b/tests/playwright.config.ts similarity index 98% rename from playwright.config.ts rename to tests/playwright.config.ts index f3dddcdeb9..d9c8966d82 100644 --- a/playwright.config.ts +++ b/tests/playwright.config.ts @@ -11,7 +11,7 @@ import { devices } from "@playwright/test"; * See https://playwright.dev/docs/test-configuration. */ const config: PlaywrightTestConfig = { - testDir: "./tests/end-to-end", + testDir: "./src/end-to-end", /* Maximum time one test can run for. */ timeout: 30 * 1000, expect: { diff --git a/playwright/index.html b/tests/playwright/index.html similarity index 100% rename from playwright/index.html rename to tests/playwright/index.html diff --git a/playwright/index.tsx b/tests/playwright/index.tsx similarity index 100% rename from playwright/index.tsx rename to tests/playwright/index.tsx diff --git a/tests/component/copypaste-external.spec.tsx b/tests/src/component/copypaste-external.spec.tsx similarity index 99% rename from tests/component/copypaste-external.spec.tsx rename to tests/src/component/copypaste-external.spec.tsx index 8eee66d176..120c4ed557 100644 --- a/tests/component/copypaste-external.spec.tsx +++ b/tests/src/component/copypaste-external.spec.tsx @@ -1,15 +1,16 @@ import { expect, test } from "../setup/setupScriptComponent"; -import { focusOnEditor } from "../utils/editor"; -import { executeSlashCommand } from "../utils/slashmenu"; +import EditorWithTextArea from "../utils/components/EditorWithTextArea"; import { copyPasteAllExternal, removeClassesFromHTML, removeMetaFromHTML, } from "../utils/copypaste"; -import EditorWithTextArea from "../utils/components/EditorWithTextArea"; +import { focusOnEditor } from "../utils/editor"; +import { executeSlashCommand } from "../utils/slashmenu"; test.describe.configure({ mode: "serial" }); +// eslint-disable-next-line no-empty-pattern test.beforeEach(async ({}, testInfo) => { testInfo.snapshotSuffix = ""; }); diff --git a/tests/component/copypaste-internal.spec.tsx b/tests/src/component/copypaste-internal.spec.tsx similarity index 99% rename from tests/component/copypaste-internal.spec.tsx rename to tests/src/component/copypaste-internal.spec.tsx index 1f03addb52..89e36d93a9 100644 --- a/tests/component/copypaste-internal.spec.tsx +++ b/tests/src/component/copypaste-internal.spec.tsx @@ -1,11 +1,12 @@ import { expect, test } from "../setup/setupScriptComponent"; import Editor from "../utils/components/Editor"; +import { copyPasteAll } from "../utils/copypaste"; import { compareDocToSnapshot, focusOnEditor } from "../utils/editor"; import { executeSlashCommand } from "../utils/slashmenu"; -import { copyPasteAll } from "../utils/copypaste"; test.describe.configure({ mode: "serial" }); +// eslint-disable-next-line no-empty-pattern test.beforeEach(async ({}, testInfo) => { testInfo.snapshotSuffix = ""; }); diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/alert-external-chromium.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/alert-external-chromium.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/alert-external-chromium.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/alert-external-chromium.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/alert-external-firefox.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/alert-external-firefox.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/alert-external-firefox.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/alert-external-firefox.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/alert-external-webkit.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/alert-external-webkit.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/alert-external-webkit.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/alert-external-webkit.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/button-external-chromium.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/button-external-chromium.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/button-external-chromium.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/button-external-chromium.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/button-external-firefox.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/button-external-firefox.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/button-external-firefox.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/button-external-firefox.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/button-external-webkit.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/button-external-webkit.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/button-external-webkit.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/button-external-webkit.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/embed-external-chromium.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/embed-external-chromium.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/embed-external-chromium.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/embed-external-chromium.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/embed-external-firefox.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/embed-external-firefox.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/embed-external-firefox.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/embed-external-firefox.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/embed-external-webkit.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/embed-external-webkit.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/embed-external-webkit.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/embed-external-webkit.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/image-external-chromium.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/image-external-chromium.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/image-external-chromium.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/image-external-chromium.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/image-external-firefox.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/image-external-firefox.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/image-external-firefox.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/image-external-firefox.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/image-external-webkit.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/image-external-webkit.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/image-external-webkit.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/image-external-webkit.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/separator-external-chromium.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/separator-external-chromium.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/separator-external-chromium.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/separator-external-chromium.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/separator-external-firefox.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/separator-external-firefox.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/separator-external-firefox.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/separator-external-firefox.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/separator-external-webkit.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/separator-external-webkit.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/separator-external-webkit.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/separator-external-webkit.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/toc-external-chromium.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/toc-external-chromium.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/toc-external-chromium.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/toc-external-chromium.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/toc-external-firefox.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/toc-external-firefox.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/toc-external-firefox.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/toc-external-firefox.html diff --git a/tests/component/snapshots/copypaste-external.spec.tsx-snapshots/toc-external-webkit.html b/tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/toc-external-webkit.html similarity index 100% rename from tests/component/snapshots/copypaste-external.spec.tsx-snapshots/toc-external-webkit.html rename to tests/src/component/snapshots/copypaste-external.spec.tsx-snapshots/toc-external-webkit.html diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/alert-internal-chromium.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/alert-internal-chromium.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/alert-internal-chromium.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/alert-internal-chromium.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/alert-internal-firefox.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/alert-internal-firefox.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/alert-internal-firefox.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/alert-internal-firefox.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/alert-internal-webkit.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/alert-internal-webkit.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/alert-internal-webkit.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/alert-internal-webkit.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/button-internal-chromium.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/button-internal-chromium.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/button-internal-chromium.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/button-internal-chromium.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/button-internal-firefox.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/button-internal-firefox.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/button-internal-firefox.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/button-internal-firefox.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/button-internal-webkit.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/button-internal-webkit.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/button-internal-webkit.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/button-internal-webkit.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/embed-internal-chromium.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/embed-internal-chromium.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/embed-internal-chromium.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/embed-internal-chromium.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/embed-internal-firefox.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/embed-internal-firefox.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/embed-internal-firefox.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/embed-internal-firefox.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/embed-internal-webkit.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/embed-internal-webkit.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/embed-internal-webkit.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/embed-internal-webkit.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/image-internal-chromium.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/image-internal-chromium.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/image-internal-chromium.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/image-internal-chromium.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/image-internal-firefox.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/image-internal-firefox.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/image-internal-firefox.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/image-internal-firefox.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/image-internal-webkit.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/image-internal-webkit.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/image-internal-webkit.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/image-internal-webkit.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/separator-internal-chromium.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/separator-internal-chromium.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/separator-internal-chromium.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/separator-internal-chromium.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/separator-internal-firefox.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/separator-internal-firefox.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/separator-internal-firefox.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/separator-internal-firefox.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/separator-internal-webkit.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/separator-internal-webkit.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/separator-internal-webkit.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/separator-internal-webkit.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/toc-internal-chromium.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/toc-internal-chromium.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/toc-internal-chromium.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/toc-internal-chromium.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/toc-internal-firefox.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/toc-internal-firefox.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/toc-internal-firefox.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/toc-internal-firefox.json diff --git a/tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/toc-internal-webkit.json b/tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/toc-internal-webkit.json similarity index 100% rename from tests/component/snapshots/copypaste-internal.spec.tsx-snapshots/toc-internal-webkit.json rename to tests/src/component/snapshots/copypaste-internal.spec.tsx-snapshots/toc-internal-webkit.json diff --git a/tests/end-to-end/basics/basics.test.ts b/tests/src/end-to-end/basics/basics.test.ts similarity index 100% rename from tests/end-to-end/basics/basics.test.ts rename to tests/src/end-to-end/basics/basics.test.ts diff --git a/tests/end-to-end/colors/colors.test.ts b/tests/src/end-to-end/colors/colors.test.ts similarity index 97% rename from tests/end-to-end/colors/colors.test.ts rename to tests/src/end-to-end/colors/colors.test.ts index 101beb79b6..59f2998942 100644 --- a/tests/end-to-end/colors/colors.test.ts +++ b/tests/src/end-to-end/colors/colors.test.ts @@ -1,3 +1,4 @@ +import { expect } from "@playwright/test"; import { test } from "../../setup/setupScript"; import { BACKGROUND_COLOR_SELECTOR, @@ -8,9 +9,8 @@ import { H_TWO_BLOCK_SELECTOR, TEXT_COLOR_SELECTOR, } from "../../utils/const"; -import { focusOnEditor } from "../../utils/editor"; import { insertHeading, insertParagraph } from "../../utils/copypaste"; -import { expect } from "@playwright/test"; +import { focusOnEditor } from "../../utils/editor"; test.beforeEach(async ({ page }) => { await page.goto(BASE_URL, { waitUntil: "networkidle" }); @@ -76,7 +76,7 @@ test.describe("Check Background & Text Color Functionality", () => { await page.hover("text=Colors"); const element = await page.locator(TEXT_COLOR_SELECTOR("red")); - const boundingBox = await element.boundingBox(); + const boundingBox = (await element.boundingBox())!; const { x, y } = boundingBox; await page.mouse.click(x + 10, y + 10); @@ -100,7 +100,7 @@ test.describe("Check Background & Text Color Functionality", () => { await page.hover("text=Colors"); const element = await page.locator(BACKGROUND_COLOR_SELECTOR("red")); - const boundingBox = await element.boundingBox(); + const boundingBox = (await element.boundingBox())!; const { x, y } = boundingBox; await page.mouse.click(x + 10, y + 10); diff --git a/tests/end-to-end/colors/colors.test.ts-snapshots/backgroundColorMark-chromium-linux.png b/tests/src/end-to-end/colors/colors.test.ts-snapshots/backgroundColorMark-chromium-linux.png similarity index 100% rename from tests/end-to-end/colors/colors.test.ts-snapshots/backgroundColorMark-chromium-linux.png rename to tests/src/end-to-end/colors/colors.test.ts-snapshots/backgroundColorMark-chromium-linux.png diff --git a/tests/end-to-end/colors/colors.test.ts-snapshots/backgroundColorMark-firefox-linux.png b/tests/src/end-to-end/colors/colors.test.ts-snapshots/backgroundColorMark-firefox-linux.png similarity index 100% rename from tests/end-to-end/colors/colors.test.ts-snapshots/backgroundColorMark-firefox-linux.png rename to tests/src/end-to-end/colors/colors.test.ts-snapshots/backgroundColorMark-firefox-linux.png diff --git a/tests/end-to-end/colors/colors.test.ts-snapshots/backgroundColorMark-webkit-linux.png b/tests/src/end-to-end/colors/colors.test.ts-snapshots/backgroundColorMark-webkit-linux.png similarity index 100% rename from tests/end-to-end/colors/colors.test.ts-snapshots/backgroundColorMark-webkit-linux.png rename to tests/src/end-to-end/colors/colors.test.ts-snapshots/backgroundColorMark-webkit-linux.png diff --git a/tests/end-to-end/colors/colors.test.ts-snapshots/blockBackgroundColor-chromium-linux.png b/tests/src/end-to-end/colors/colors.test.ts-snapshots/blockBackgroundColor-chromium-linux.png similarity index 100% rename from tests/end-to-end/colors/colors.test.ts-snapshots/blockBackgroundColor-chromium-linux.png rename to tests/src/end-to-end/colors/colors.test.ts-snapshots/blockBackgroundColor-chromium-linux.png diff --git a/tests/end-to-end/colors/colors.test.ts-snapshots/blockBackgroundColor-firefox-linux.png b/tests/src/end-to-end/colors/colors.test.ts-snapshots/blockBackgroundColor-firefox-linux.png similarity index 100% rename from tests/end-to-end/colors/colors.test.ts-snapshots/blockBackgroundColor-firefox-linux.png rename to tests/src/end-to-end/colors/colors.test.ts-snapshots/blockBackgroundColor-firefox-linux.png diff --git a/tests/end-to-end/colors/colors.test.ts-snapshots/blockBackgroundColor-webkit-linux.png b/tests/src/end-to-end/colors/colors.test.ts-snapshots/blockBackgroundColor-webkit-linux.png similarity index 100% rename from tests/end-to-end/colors/colors.test.ts-snapshots/blockBackgroundColor-webkit-linux.png rename to tests/src/end-to-end/colors/colors.test.ts-snapshots/blockBackgroundColor-webkit-linux.png diff --git a/tests/end-to-end/colors/colors.test.ts-snapshots/blockTextColor-chromium-linux.png b/tests/src/end-to-end/colors/colors.test.ts-snapshots/blockTextColor-chromium-linux.png similarity index 100% rename from tests/end-to-end/colors/colors.test.ts-snapshots/blockTextColor-chromium-linux.png rename to tests/src/end-to-end/colors/colors.test.ts-snapshots/blockTextColor-chromium-linux.png diff --git a/tests/end-to-end/colors/colors.test.ts-snapshots/blockTextColor-firefox-linux.png b/tests/src/end-to-end/colors/colors.test.ts-snapshots/blockTextColor-firefox-linux.png similarity index 100% rename from tests/end-to-end/colors/colors.test.ts-snapshots/blockTextColor-firefox-linux.png rename to tests/src/end-to-end/colors/colors.test.ts-snapshots/blockTextColor-firefox-linux.png diff --git a/tests/end-to-end/colors/colors.test.ts-snapshots/blockTextColor-webkit-linux.png b/tests/src/end-to-end/colors/colors.test.ts-snapshots/blockTextColor-webkit-linux.png similarity index 100% rename from tests/end-to-end/colors/colors.test.ts-snapshots/blockTextColor-webkit-linux.png rename to tests/src/end-to-end/colors/colors.test.ts-snapshots/blockTextColor-webkit-linux.png diff --git a/tests/end-to-end/colors/colors.test.ts-snapshots/textColorMark-chromium-linux.png b/tests/src/end-to-end/colors/colors.test.ts-snapshots/textColorMark-chromium-linux.png similarity index 100% rename from tests/end-to-end/colors/colors.test.ts-snapshots/textColorMark-chromium-linux.png rename to tests/src/end-to-end/colors/colors.test.ts-snapshots/textColorMark-chromium-linux.png diff --git a/tests/end-to-end/colors/colors.test.ts-snapshots/textColorMark-firefox-linux.png b/tests/src/end-to-end/colors/colors.test.ts-snapshots/textColorMark-firefox-linux.png similarity index 100% rename from tests/end-to-end/colors/colors.test.ts-snapshots/textColorMark-firefox-linux.png rename to tests/src/end-to-end/colors/colors.test.ts-snapshots/textColorMark-firefox-linux.png diff --git a/tests/end-to-end/colors/colors.test.ts-snapshots/textColorMark-webkit-linux.png b/tests/src/end-to-end/colors/colors.test.ts-snapshots/textColorMark-webkit-linux.png similarity index 100% rename from tests/end-to-end/colors/colors.test.ts-snapshots/textColorMark-webkit-linux.png rename to tests/src/end-to-end/colors/colors.test.ts-snapshots/textColorMark-webkit-linux.png diff --git a/tests/end-to-end/copypaste/copypaste.test.ts b/tests/src/end-to-end/copypaste/copypaste.test.ts similarity index 95% rename from tests/end-to-end/copypaste/copypaste.test.ts rename to tests/src/end-to-end/copypaste/copypaste.test.ts index ff7ced83c6..d39c357210 100644 --- a/tests/end-to-end/copypaste/copypaste.test.ts +++ b/tests/src/end-to-end/copypaste/copypaste.test.ts @@ -1,14 +1,15 @@ +/* eslint-disable jest/valid-title */ import { test } from "../../setup/setupScript"; import { BASE_URL } from "../../utils/const"; -import { compareDocToSnapshot, focusOnEditor } from "../../utils/editor"; import { copyPasteAll, insertHeading, - startList, insertListItems, insertNestedListItems, insertParagraph, + startList, } from "../../utils/copypaste"; +import { compareDocToSnapshot, focusOnEditor } from "../../utils/editor"; import { executeSlashCommand } from "../../utils/slashmenu"; test.describe.configure({ mode: "serial" }); @@ -158,11 +159,13 @@ test.describe("Check Copy/Paste Functionality", () => { await page.click(`img`); - await page.waitForSelector(`[class*="resizeHandle"][style*="right"]`); + await page.waitForSelector( + `[class*="bn-image-resize-handle"][style*="right"]` + ); const resizeHandle = page.locator( - `[class*="resizeHandle"][style*="right"]` + `[class*="bn-image-resize-handle"][style*="right"]` ); - const resizeHandleBoundingBox = await resizeHandle.boundingBox(); + const resizeHandleBoundingBox = (await resizeHandle.boundingBox())!; await page.mouse.move( resizeHandleBoundingBox.x + resizeHandleBoundingBox.width / 2, resizeHandleBoundingBox.y + resizeHandleBoundingBox.height / 2, diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-chromium-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-chromium-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-chromium-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-chromium-linux.json diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-webkit-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-webkit-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-webkit-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-webkit-linux.json diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/images-json-chromium-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/images-json-chromium-linux.json similarity index 96% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/images-json-chromium-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/images-json-chromium-linux.json index 8ddb0e9f23..04c7045205 100644 --- a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/images-json-chromium-linux.json +++ b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/images-json-chromium-linux.json @@ -38,7 +38,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 462 @@ -96,7 +95,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 462 diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedOrderedLists-json-chromium-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedOrderedLists-json-chromium-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedOrderedLists-json-chromium-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedOrderedLists-json-chromium-linux.json diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedOrderedLists-json-webkit-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedOrderedLists-json-webkit-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedOrderedLists-json-webkit-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedOrderedLists-json-webkit-linux.json diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedParagraphs-json-chromium-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedParagraphs-json-chromium-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedParagraphs-json-chromium-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedParagraphs-json-chromium-linux.json diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedParagraphs-json-webkit-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedParagraphs-json-webkit-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedParagraphs-json-webkit-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedParagraphs-json-webkit-linux.json diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedUnorderedLists-json-chromium-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedUnorderedLists-json-chromium-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedUnorderedLists-json-chromium-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedUnorderedLists-json-chromium-linux.json diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedUnorderedLists-json-webkit-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedUnorderedLists-json-webkit-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedUnorderedLists-json-webkit-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/nestedUnorderedLists-json-webkit-linux.json diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/orderedLists-json-chromium-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/orderedLists-json-chromium-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/orderedLists-json-chromium-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/orderedLists-json-chromium-linux.json diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/orderedLists-json-webkit-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/orderedLists-json-webkit-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/orderedLists-json-webkit-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/orderedLists-json-webkit-linux.json diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/paragraphs-json-chromium-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/paragraphs-json-chromium-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/paragraphs-json-chromium-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/paragraphs-json-chromium-linux.json diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/paragraphs-json-webkit-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/paragraphs-json-webkit-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/paragraphs-json-webkit-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/paragraphs-json-webkit-linux.json diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/unorderedLists-json-chromium-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/unorderedLists-json-chromium-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/unorderedLists-json-chromium-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/unorderedLists-json-chromium-linux.json diff --git a/tests/end-to-end/copypaste/copypaste.test.ts-snapshots/unorderedLists-json-webkit-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/unorderedLists-json-webkit-linux.json similarity index 100% rename from tests/end-to-end/copypaste/copypaste.test.ts-snapshots/unorderedLists-json-webkit-linux.json rename to tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/unorderedLists-json-webkit-linux.json diff --git a/tests/end-to-end/dragdrop/dragdrop.test.ts b/tests/src/end-to-end/dragdrop/dragdrop.test.ts similarity index 100% rename from tests/end-to-end/dragdrop/dragdrop.test.ts rename to tests/src/end-to-end/dragdrop/dragdrop.test.ts diff --git a/tests/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropnested-chromium-linux.json b/tests/src/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropnested-chromium-linux.json similarity index 100% rename from tests/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropnested-chromium-linux.json rename to tests/src/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropnested-chromium-linux.json diff --git a/tests/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropnested-webkit-linux.json b/tests/src/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropnested-webkit-linux.json similarity index 100% rename from tests/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropnested-webkit-linux.json rename to tests/src/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropnested-webkit-linux.json diff --git a/tests/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropsingle-chromium-linux.json b/tests/src/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropsingle-chromium-linux.json similarity index 100% rename from tests/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropsingle-chromium-linux.json rename to tests/src/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropsingle-chromium-linux.json diff --git a/tests/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropsingle-webkit-linux.json b/tests/src/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropsingle-webkit-linux.json similarity index 100% rename from tests/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropsingle-webkit-linux.json rename to tests/src/end-to-end/dragdrop/dragdrop.test.ts-snapshots/dragdropsingle-webkit-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts b/tests/src/end-to-end/draghandle/draghandle.test.ts similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts rename to tests/src/end-to-end/draghandle/draghandle.test.ts diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/addnonselectedemptyblock-chromium-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/addnonselectedemptyblock-chromium-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/addnonselectedemptyblock-chromium-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/addnonselectedemptyblock-chromium-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/addnonselectedemptyblock-firefox-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/addnonselectedemptyblock-firefox-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/addnonselectedemptyblock-firefox-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/addnonselectedemptyblock-firefox-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/addnonselectedemptyblock-webkit-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/addnonselectedemptyblock-webkit-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/addnonselectedemptyblock-webkit-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/addnonselectedemptyblock-webkit-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/dragHandleDocStructure-chromium-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/dragHandleDocStructure-chromium-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/dragHandleDocStructure-chromium-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/dragHandleDocStructure-chromium-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/dragHandleDocStructure-firefox-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/dragHandleDocStructure-firefox-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/dragHandleDocStructure-firefox-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/dragHandleDocStructure-firefox-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/dragHandleDocStructure-webkit-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/dragHandleDocStructure-webkit-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/dragHandleDocStructure-webkit-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/dragHandleDocStructure-webkit-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandleadd-chromium-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandleadd-chromium-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandleadd-chromium-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandleadd-chromium-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandleadd-firefox-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandleadd-firefox-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandleadd-firefox-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandleadd-firefox-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandleadd-webkit-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandleadd-webkit-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandleadd-webkit-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandleadd-webkit-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandledelete-chromium-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandledelete-chromium-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandledelete-chromium-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandledelete-chromium-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandledelete-firefox-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandledelete-firefox-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandledelete-firefox-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandledelete-firefox-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandledelete-webkit-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandledelete-webkit-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandledelete-webkit-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandledelete-webkit-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-chromium-linux.png b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-chromium-linux.png similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-chromium-linux.png rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-chromium-linux.png diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-firefox-linux.png b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-firefox-linux.png similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-firefox-linux.png rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-firefox-linux.png diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-webkit-linux.png b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-webkit-linux.png similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-webkit-linux.png rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlemenu-webkit-linux.png diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlenesteddelete-chromium-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlenesteddelete-chromium-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlenesteddelete-chromium-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlenesteddelete-chromium-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlenesteddelete-firefox-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlenesteddelete-firefox-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlenesteddelete-firefox-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlenesteddelete-firefox-linux.json diff --git a/tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlenesteddelete-webkit-linux.json b/tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlenesteddelete-webkit-linux.json similarity index 100% rename from tests/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlenesteddelete-webkit-linux.json rename to tests/src/end-to-end/draghandle/draghandle.test.ts-snapshots/draghandlenesteddelete-webkit-linux.json diff --git a/tests/end-to-end/images/images.test.ts b/tests/src/end-to-end/images/images.test.ts similarity index 92% rename from tests/end-to-end/images/images.test.ts rename to tests/src/end-to-end/images/images.test.ts index 8ab2b1cc4b..e4df321a94 100644 --- a/tests/end-to-end/images/images.test.ts +++ b/tests/src/end-to-end/images/images.test.ts @@ -1,16 +1,16 @@ +import { FileChooser, expect } from "@playwright/test"; import { test } from "../../setup/setupScript"; import { BASE_URL, H_ONE_BLOCK_SELECTOR, IMAGE_SELECTOR, } from "../../utils/const"; +import { insertHeading } from "../../utils/copypaste"; import { compareDocToSnapshot, focusOnEditor } from "../../utils/editor"; -import { expect, FileChooser } from "@playwright/test"; import { dragAndDropBlock } from "../../utils/mouse"; import { executeSlashCommand } from "../../utils/slashmenu"; -import { insertHeading } from "../../utils/copypaste"; -const IMAGE_UPLOAD_PATH = "tests/end-to-end/images/placeholder.png"; +const IMAGE_UPLOAD_PATH = "src/end-to-end/images/placeholder.png"; const IMAGE_EMBED_URL = "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg"; @@ -70,11 +70,13 @@ test.describe("Check Image Block and Toolbar functionality", () => { await page.click(`img`); - await page.waitForSelector(`[class*="resizeHandle"][style*="right"]`); + await page.waitForSelector( + `[class*="bn-image-resize-handle"][style*="right"]` + ); const resizeHandle = page.locator( - `[class*="resizeHandle"][style*="right"]` + `[class*="bn-image-resize-handle"][style*="right"]` ); - const resizeHandleBoundingBox = await resizeHandle.boundingBox(); + const resizeHandleBoundingBox = (await resizeHandle.boundingBox())!; await page.mouse.move( resizeHandleBoundingBox.x + resizeHandleBoundingBox.width / 2, resizeHandleBoundingBox.y + resizeHandleBoundingBox.height / 2, diff --git a/tests/end-to-end/images/images.test.ts-snapshots/create-image-chromium-linux.png b/tests/src/end-to-end/images/images.test.ts-snapshots/create-image-chromium-linux.png similarity index 100% rename from tests/end-to-end/images/images.test.ts-snapshots/create-image-chromium-linux.png rename to tests/src/end-to-end/images/images.test.ts-snapshots/create-image-chromium-linux.png diff --git a/tests/end-to-end/images/images.test.ts-snapshots/create-image-firefox-linux.png b/tests/src/end-to-end/images/images.test.ts-snapshots/create-image-firefox-linux.png similarity index 100% rename from tests/end-to-end/images/images.test.ts-snapshots/create-image-firefox-linux.png rename to tests/src/end-to-end/images/images.test.ts-snapshots/create-image-firefox-linux.png diff --git a/tests/end-to-end/images/images.test.ts-snapshots/create-image-webkit-linux.png b/tests/src/end-to-end/images/images.test.ts-snapshots/create-image-webkit-linux.png similarity index 100% rename from tests/end-to-end/images/images.test.ts-snapshots/create-image-webkit-linux.png rename to tests/src/end-to-end/images/images.test.ts-snapshots/create-image-webkit-linux.png diff --git a/tests/end-to-end/images/images.test.ts-snapshots/createImage-chromium-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/createImage-chromium-linux.json similarity index 95% rename from tests/end-to-end/images/images.test.ts-snapshots/createImage-chromium-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/createImage-chromium-linux.json index 3d3e6a4be4..0148fe1624 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/createImage-chromium-linux.json +++ b/tests/src/end-to-end/images/images.test.ts-snapshots/createImage-chromium-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/createImage-firefox-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/createImage-firefox-linux.json similarity index 95% rename from tests/end-to-end/images/images.test.ts-snapshots/createImage-firefox-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/createImage-firefox-linux.json index 3d3e6a4be4..0148fe1624 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/createImage-firefox-linux.json +++ b/tests/src/end-to-end/images/images.test.ts-snapshots/createImage-firefox-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/createImage-webkit-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/createImage-webkit-linux.json similarity index 95% rename from tests/end-to-end/images/images.test.ts-snapshots/createImage-webkit-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/createImage-webkit-linux.json index 3d3e6a4be4..0148fe1624 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/createImage-webkit-linux.json +++ b/tests/src/end-to-end/images/images.test.ts-snapshots/createImage-webkit-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/deleteImage-chromium-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/deleteImage-chromium-linux.json similarity index 100% rename from tests/end-to-end/images/images.test.ts-snapshots/deleteImage-chromium-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/deleteImage-chromium-linux.json diff --git a/tests/end-to-end/images/images.test.ts-snapshots/deleteImage-firefox-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/deleteImage-firefox-linux.json similarity index 100% rename from tests/end-to-end/images/images.test.ts-snapshots/deleteImage-firefox-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/deleteImage-firefox-linux.json diff --git a/tests/end-to-end/images/images.test.ts-snapshots/deleteImage-webkit-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/deleteImage-webkit-linux.json similarity index 100% rename from tests/end-to-end/images/images.test.ts-snapshots/deleteImage-webkit-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/deleteImage-webkit-linux.json diff --git a/tests/end-to-end/images/images.test.ts-snapshots/dragImage-chromium-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/dragImage-chromium-linux.json similarity index 96% rename from tests/end-to-end/images/images.test.ts-snapshots/dragImage-chromium-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/dragImage-chromium-linux.json index 3463182de8..3be5ef59cd 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/dragImage-chromium-linux.json +++ b/tests/src/end-to-end/images/images.test.ts-snapshots/dragImage-chromium-linux.json @@ -39,7 +39,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/dragImage-firefox-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/dragImage-firefox-linux.json similarity index 96% rename from tests/end-to-end/images/images.test.ts-snapshots/dragImage-firefox-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/dragImage-firefox-linux.json index 3463182de8..3be5ef59cd 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/dragImage-firefox-linux.json +++ b/tests/src/end-to-end/images/images.test.ts-snapshots/dragImage-firefox-linux.json @@ -39,7 +39,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/dragImage-webkit-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/dragImage-webkit-linux.json similarity index 96% rename from tests/end-to-end/images/images.test.ts-snapshots/dragImage-webkit-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/dragImage-webkit-linux.json index 3463182de8..3be5ef59cd 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/dragImage-webkit-linux.json +++ b/tests/src/end-to-end/images/images.test.ts-snapshots/dragImage-webkit-linux.json @@ -39,7 +39,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "", "caption": "", "width": 512 diff --git a/tests/src/end-to-end/images/images.test.ts-snapshots/embed-image-chromium-linux.png b/tests/src/end-to-end/images/images.test.ts-snapshots/embed-image-chromium-linux.png new file mode 100644 index 0000000000..421ebe7a6b Binary files /dev/null and b/tests/src/end-to-end/images/images.test.ts-snapshots/embed-image-chromium-linux.png differ diff --git a/tests/src/end-to-end/images/images.test.ts-snapshots/embed-image-firefox-linux.png b/tests/src/end-to-end/images/images.test.ts-snapshots/embed-image-firefox-linux.png new file mode 100644 index 0000000000..6b79a453a9 Binary files /dev/null and b/tests/src/end-to-end/images/images.test.ts-snapshots/embed-image-firefox-linux.png differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/embed-image-webkit-linux.png b/tests/src/end-to-end/images/images.test.ts-snapshots/embed-image-webkit-linux.png similarity index 53% rename from tests/end-to-end/images/images.test.ts-snapshots/embed-image-webkit-linux.png rename to tests/src/end-to-end/images/images.test.ts-snapshots/embed-image-webkit-linux.png index b963f66e9e..aa9796c9d6 100644 Binary files a/tests/end-to-end/images/images.test.ts-snapshots/embed-image-webkit-linux.png and b/tests/src/end-to-end/images/images.test.ts-snapshots/embed-image-webkit-linux.png differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/embedImage-chromium-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/embedImage-chromium-linux.json similarity index 95% rename from tests/end-to-end/images/images.test.ts-snapshots/embedImage-chromium-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/embedImage-chromium-linux.json index 8d66de1c8d..50458ba61e 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/embedImage-chromium-linux.json +++ b/tests/src/end-to-end/images/images.test.ts-snapshots/embedImage-chromium-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/embedImage-firefox-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/embedImage-firefox-linux.json similarity index 95% rename from tests/end-to-end/images/images.test.ts-snapshots/embedImage-firefox-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/embedImage-firefox-linux.json index 8d66de1c8d..50458ba61e 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/embedImage-firefox-linux.json +++ b/tests/src/end-to-end/images/images.test.ts-snapshots/embedImage-firefox-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 512 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/embedImage-webkit-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/embedImage-webkit-linux.json similarity index 95% rename from tests/end-to-end/images/images.test.ts-snapshots/embedImage-webkit-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/embedImage-webkit-linux.json index 8d66de1c8d..50458ba61e 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/embedImage-webkit-linux.json +++ b/tests/src/end-to-end/images/images.test.ts-snapshots/embedImage-webkit-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 512 diff --git a/tests/src/end-to-end/images/images.test.ts-snapshots/resize-image-chromium-linux.png b/tests/src/end-to-end/images/images.test.ts-snapshots/resize-image-chromium-linux.png new file mode 100644 index 0000000000..f90690e393 Binary files /dev/null and b/tests/src/end-to-end/images/images.test.ts-snapshots/resize-image-chromium-linux.png differ diff --git a/tests/src/end-to-end/images/images.test.ts-snapshots/resize-image-firefox-linux.png b/tests/src/end-to-end/images/images.test.ts-snapshots/resize-image-firefox-linux.png new file mode 100644 index 0000000000..bf2ce7bf13 Binary files /dev/null and b/tests/src/end-to-end/images/images.test.ts-snapshots/resize-image-firefox-linux.png differ diff --git a/tests/src/end-to-end/images/images.test.ts-snapshots/resize-image-webkit-linux.png b/tests/src/end-to-end/images/images.test.ts-snapshots/resize-image-webkit-linux.png new file mode 100644 index 0000000000..7f8d760889 Binary files /dev/null and b/tests/src/end-to-end/images/images.test.ts-snapshots/resize-image-webkit-linux.png differ diff --git a/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-chromium-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/resizeImage-chromium-linux.json similarity index 95% rename from tests/end-to-end/images/images.test.ts-snapshots/resizeImage-chromium-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/resizeImage-chromium-linux.json index 318cb5cca9..58d8752142 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-chromium-linux.json +++ b/tests/src/end-to-end/images/images.test.ts-snapshots/resizeImage-chromium-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 462 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-firefox-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/resizeImage-firefox-linux.json similarity index 95% rename from tests/end-to-end/images/images.test.ts-snapshots/resizeImage-firefox-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/resizeImage-firefox-linux.json index 318cb5cca9..58d8752142 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-firefox-linux.json +++ b/tests/src/end-to-end/images/images.test.ts-snapshots/resizeImage-firefox-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 462 diff --git a/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-webkit-linux.json b/tests/src/end-to-end/images/images.test.ts-snapshots/resizeImage-webkit-linux.json similarity index 95% rename from tests/end-to-end/images/images.test.ts-snapshots/resizeImage-webkit-linux.json rename to tests/src/end-to-end/images/images.test.ts-snapshots/resizeImage-webkit-linux.json index 318cb5cca9..58d8752142 100644 --- a/tests/end-to-end/images/images.test.ts-snapshots/resizeImage-webkit-linux.json +++ b/tests/src/end-to-end/images/images.test.ts-snapshots/resizeImage-webkit-linux.json @@ -16,7 +16,6 @@ "type": "image", "attrs": { "textAlignment": "left", - "backgroundColor": "default", "url": "https://www.pulsecarshalton.co.uk/wp-content/uploads/2016/08/jk-placeholder-image.jpg", "caption": "", "width": 462 diff --git a/tests/src/end-to-end/images/images.test.ts-snapshots/upload-image-chromium-linux.png b/tests/src/end-to-end/images/images.test.ts-snapshots/upload-image-chromium-linux.png new file mode 100644 index 0000000000..acdb3a6d88 Binary files /dev/null and b/tests/src/end-to-end/images/images.test.ts-snapshots/upload-image-chromium-linux.png differ diff --git a/tests/src/end-to-end/images/images.test.ts-snapshots/upload-image-firefox-linux.png b/tests/src/end-to-end/images/images.test.ts-snapshots/upload-image-firefox-linux.png new file mode 100644 index 0000000000..3e4a497c7c Binary files /dev/null and b/tests/src/end-to-end/images/images.test.ts-snapshots/upload-image-firefox-linux.png differ diff --git a/tests/src/end-to-end/images/images.test.ts-snapshots/upload-image-webkit-linux.png b/tests/src/end-to-end/images/images.test.ts-snapshots/upload-image-webkit-linux.png new file mode 100644 index 0000000000..baa871c0bb Binary files /dev/null and b/tests/src/end-to-end/images/images.test.ts-snapshots/upload-image-webkit-linux.png differ diff --git a/tests/end-to-end/images/placeholder.png b/tests/src/end-to-end/images/placeholder.png similarity index 100% rename from tests/end-to-end/images/placeholder.png rename to tests/src/end-to-end/images/placeholder.png diff --git a/tests/end-to-end/indentation/indentation.test.ts b/tests/src/end-to-end/indentation/indentation.test.ts similarity index 92% rename from tests/end-to-end/indentation/indentation.test.ts rename to tests/src/end-to-end/indentation/indentation.test.ts index 46916c415b..bee2e72d1a 100644 --- a/tests/end-to-end/indentation/indentation.test.ts +++ b/tests/src/end-to-end/indentation/indentation.test.ts @@ -1,13 +1,13 @@ import { test } from "../../setup/setupScript"; import { BASE_URL, - UNNEST_BLOCK_BUTTON_SELECTOR, H_THREE_BLOCK_SELECTOR, H_TWO_BLOCK_SELECTOR, NEST_BLOCK_BUTTON_SELECTOR, + UNNEST_BLOCK_BUTTON_SELECTOR, } from "../../utils/const"; -import { compareDocToSnapshot, focusOnEditor } from "../../utils/editor"; import { insertHeading, insertParagraph } from "../../utils/copypaste"; +import { compareDocToSnapshot, focusOnEditor } from "../../utils/editor"; test.beforeEach(async ({ page }) => { await page.goto(BASE_URL, { waitUntil: "networkidle" }); @@ -28,7 +28,7 @@ test.describe("Check Block Indentation Functionality", () => { await insertHeading(page, 3); const element = await page.locator(H_TWO_BLOCK_SELECTOR); - const boundingBox = await element.boundingBox(); + const boundingBox = (await element.boundingBox())!; const { x, y, height } = boundingBox; await page.mouse.click(x + 10, y + height / 2, { clickCount: 2 }); @@ -57,7 +57,7 @@ test.describe("Check Block Indentation Functionality", () => { await insertHeading(page, 3); const element = await page.locator(H_TWO_BLOCK_SELECTOR); - const boundingBox = await element.boundingBox(); + const boundingBox = (await element.boundingBox())!; const { x, y, height } = boundingBox; await page.mouse.click(x + 10, y + height / 2, { clickCount: 2 }); @@ -86,14 +86,14 @@ test.describe("Check Block Indentation Functionality", () => { await insertHeading(page, 3); const firstElement = await page.locator(H_TWO_BLOCK_SELECTOR); - const firstElementBoundingBox = await firstElement.boundingBox(); + const firstElementBoundingBox = (await firstElement.boundingBox())!; await page.mouse.click( firstElementBoundingBox.x + 20, firstElementBoundingBox.y + firstElementBoundingBox.height / 2 ); const secondElement = await page.locator(H_THREE_BLOCK_SELECTOR); - const secondElementBoundingBox = await secondElement.boundingBox(); + const secondElementBoundingBox = (await secondElement.boundingBox())!; await page.keyboard.down("Shift"); await page.mouse.click( secondElementBoundingBox.x + 20, @@ -128,14 +128,14 @@ test.describe("Check Block Indentation Functionality", () => { await insertHeading(page, 3); const firstElement = await page.locator(H_TWO_BLOCK_SELECTOR); - const firstElementBoundingBox = await firstElement.boundingBox(); + const firstElementBoundingBox = (await firstElement.boundingBox())!; await page.mouse.click( firstElementBoundingBox.x + 20, firstElementBoundingBox.y + firstElementBoundingBox.height / 2 ); const secondElement = await page.locator(H_THREE_BLOCK_SELECTOR); - const secondElementBoundingBox = await secondElement.boundingBox(); + const secondElementBoundingBox = (await secondElement.boundingBox())!; await page.keyboard.down("Shift"); await page.mouse.click( secondElementBoundingBox.x + 20, diff --git a/tests/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentMultipleBlocks-chromium-linux.json b/tests/src/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentMultipleBlocks-chromium-linux.json similarity index 100% rename from tests/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentMultipleBlocks-chromium-linux.json rename to tests/src/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentMultipleBlocks-chromium-linux.json diff --git a/tests/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentMultipleBlocks-firefox-linux.json b/tests/src/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentMultipleBlocks-firefox-linux.json similarity index 100% rename from tests/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentMultipleBlocks-firefox-linux.json rename to tests/src/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentMultipleBlocks-firefox-linux.json diff --git a/tests/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentMultipleBlocks-webkit-linux.json b/tests/src/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentMultipleBlocks-webkit-linux.json similarity index 100% rename from tests/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentMultipleBlocks-webkit-linux.json rename to tests/src/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentMultipleBlocks-webkit-linux.json diff --git a/tests/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentSingleBlock-chromium-linux.json b/tests/src/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentSingleBlock-chromium-linux.json similarity index 100% rename from tests/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentSingleBlock-chromium-linux.json rename to tests/src/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentSingleBlock-chromium-linux.json diff --git a/tests/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentSingleBlock-firefox-linux.json b/tests/src/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentSingleBlock-firefox-linux.json similarity index 100% rename from tests/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentSingleBlock-firefox-linux.json rename to tests/src/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentSingleBlock-firefox-linux.json diff --git a/tests/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentSingleBlock-webkit-linux.json b/tests/src/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentSingleBlock-webkit-linux.json similarity index 100% rename from tests/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentSingleBlock-webkit-linux.json rename to tests/src/end-to-end/indentation/indentation.test.ts-snapshots/decreaseIndentSingleBlock-webkit-linux.json diff --git a/tests/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentMultipleBlocks-chromium-linux.json b/tests/src/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentMultipleBlocks-chromium-linux.json similarity index 100% rename from tests/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentMultipleBlocks-chromium-linux.json rename to tests/src/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentMultipleBlocks-chromium-linux.json diff --git a/tests/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentMultipleBlocks-firefox-linux.json b/tests/src/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentMultipleBlocks-firefox-linux.json similarity index 100% rename from tests/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentMultipleBlocks-firefox-linux.json rename to tests/src/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentMultipleBlocks-firefox-linux.json diff --git a/tests/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentMultipleBlocks-webkit-linux.json b/tests/src/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentMultipleBlocks-webkit-linux.json similarity index 100% rename from tests/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentMultipleBlocks-webkit-linux.json rename to tests/src/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentMultipleBlocks-webkit-linux.json diff --git a/tests/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentSingleBlock-chromium-linux.json b/tests/src/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentSingleBlock-chromium-linux.json similarity index 100% rename from tests/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentSingleBlock-chromium-linux.json rename to tests/src/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentSingleBlock-chromium-linux.json diff --git a/tests/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentSingleBlock-firefox-linux.json b/tests/src/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentSingleBlock-firefox-linux.json similarity index 100% rename from tests/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentSingleBlock-firefox-linux.json rename to tests/src/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentSingleBlock-firefox-linux.json diff --git a/tests/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentSingleBlock-webkit-linux.json b/tests/src/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentSingleBlock-webkit-linux.json similarity index 100% rename from tests/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentSingleBlock-webkit-linux.json rename to tests/src/end-to-end/indentation/indentation.test.ts-snapshots/increaseIndentSingleBlock-webkit-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts similarity index 92% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts index 0a03efd59e..15500f99e9 100644 --- a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts +++ b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts @@ -1,12 +1,12 @@ import { test } from "../../setup/setupScript"; import { BASE_URL, - ITALIC_BUTTON_SELECTOR, H_ONE_BLOCK_SELECTOR, H_TWO_BLOCK_SELECTOR, + ITALIC_BUTTON_SELECTOR, } from "../../utils/const"; -import { compareDocToSnapshot, focusOnEditor } from "../../utils/editor"; import { insertHeading, insertParagraph } from "../../utils/copypaste"; +import { compareDocToSnapshot, focusOnEditor } from "../../utils/editor"; test.describe.configure({ mode: "serial" }); @@ -22,13 +22,13 @@ test.describe("Check Keyboard Handlers' Behaviour", () => { await page.waitForTimeout(500); const startElement = await page.locator(H_ONE_BLOCK_SELECTOR); - let boundingBox = await startElement.boundingBox(); + let boundingBox = (await startElement.boundingBox())!; let { x, y, height } = boundingBox; await page.mouse.move(x + 35, y + height / 2, { steps: 5 }); await page.mouse.down(); const endElement = await page.locator(H_TWO_BLOCK_SELECTOR); - boundingBox = await endElement.boundingBox(); + boundingBox = (await endElement.boundingBox())!; ({ x, y, height } = boundingBox); await page.mouse.move(x + 105, y + height / 2, { steps: 5 }); await page.mouse.up(); @@ -43,8 +43,8 @@ test.describe("Check Keyboard Handlers' Behaviour", () => { await page.waitForTimeout(500); const element = await page.locator(H_ONE_BLOCK_SELECTOR); - let boundingBox = await element.boundingBox(); - let { x, y, height } = boundingBox; + const boundingBox = (await element.boundingBox())!; + const { x, y, height } = boundingBox; await page.mouse.click(x + 35, y + height / 2, { clickCount: 2 }); await page.locator(ITALIC_BUTTON_SELECTOR).click(); @@ -64,8 +64,8 @@ test.describe("Check Keyboard Handlers' Behaviour", () => { await page.waitForTimeout(500); const element = await page.locator(H_ONE_BLOCK_SELECTOR); - let boundingBox = await element.boundingBox(); - let { x, y, height } = boundingBox; + const boundingBox = (await element.boundingBox())!; + const { x, y, height } = boundingBox; await page.mouse.click(x + 35, y + height / 2); await page.keyboard.press("Enter"); diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesMarks-json-chromium-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesMarks-json-chromium-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesMarks-json-chromium-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesMarks-json-chromium-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesMarks-json-firefox-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesMarks-json-firefox-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesMarks-json-firefox-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesMarks-json-firefox-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesMarks-json-webkit-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesMarks-json-webkit-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesMarks-json-webkit-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesMarks-json-webkit-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesNestedBlocks-json-chromium-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesNestedBlocks-json-chromium-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesNestedBlocks-json-chromium-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesNestedBlocks-json-chromium-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesNestedBlocks-json-firefox-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesNestedBlocks-json-firefox-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesNestedBlocks-json-firefox-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesNestedBlocks-json-firefox-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesNestedBlocks-json-webkit-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesNestedBlocks-json-webkit-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesNestedBlocks-json-webkit-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspacePreservesNestedBlocks-json-webkit-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspaceStartOfBlock-json-chromium-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspaceStartOfBlock-json-chromium-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspaceStartOfBlock-json-chromium-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspaceStartOfBlock-json-chromium-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspaceStartOfBlock-json-firefox-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspaceStartOfBlock-json-firefox-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspaceStartOfBlock-json-firefox-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspaceStartOfBlock-json-firefox-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspaceStartOfBlock-json-webkit-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspaceStartOfBlock-json-webkit-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspaceStartOfBlock-json-webkit-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/backspaceStartOfBlock-json-webkit-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesMarks-json-chromium-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesMarks-json-chromium-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesMarks-json-chromium-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesMarks-json-chromium-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesMarks-json-firefox-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesMarks-json-firefox-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesMarks-json-firefox-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesMarks-json-firefox-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesMarks-json-webkit-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesMarks-json-webkit-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesMarks-json-webkit-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesMarks-json-webkit-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesNestedBlocks-json-chromium-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesNestedBlocks-json-chromium-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesNestedBlocks-json-chromium-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesNestedBlocks-json-chromium-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesNestedBlocks-json-firefox-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesNestedBlocks-json-firefox-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesNestedBlocks-json-firefox-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesNestedBlocks-json-firefox-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesNestedBlocks-json-webkit-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesNestedBlocks-json-webkit-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesNestedBlocks-json-webkit-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterPreservesNestedBlocks-json-webkit-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterSelectionNotEmpty-json-chromium-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterSelectionNotEmpty-json-chromium-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterSelectionNotEmpty-json-chromium-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterSelectionNotEmpty-json-chromium-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterSelectionNotEmpty-json-firefox-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterSelectionNotEmpty-json-firefox-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterSelectionNotEmpty-json-firefox-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterSelectionNotEmpty-json-firefox-linux.json diff --git a/tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterSelectionNotEmpty-json-webkit-linux.json b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterSelectionNotEmpty-json-webkit-linux.json similarity index 100% rename from tests/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterSelectionNotEmpty-json-webkit-linux.json rename to tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.ts-snapshots/enterSelectionNotEmpty-json-webkit-linux.json diff --git a/tests/end-to-end/placeholder/placeholder.test.ts b/tests/src/end-to-end/placeholder/placeholder.test.ts similarity index 100% rename from tests/end-to-end/placeholder/placeholder.test.ts rename to tests/src/end-to-end/placeholder/placeholder.test.ts diff --git a/tests/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-chromium-darwin.png b/tests/src/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-chromium-darwin.png similarity index 100% rename from tests/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-chromium-darwin.png rename to tests/src/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-chromium-darwin.png diff --git a/tests/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-chromium-linux.png b/tests/src/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-chromium-linux.png similarity index 100% rename from tests/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-chromium-linux.png rename to tests/src/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-chromium-linux.png diff --git a/tests/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-firefox-darwin.png b/tests/src/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-firefox-darwin.png similarity index 100% rename from tests/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-firefox-darwin.png rename to tests/src/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-firefox-darwin.png diff --git a/tests/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-firefox-linux.png b/tests/src/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-firefox-linux.png similarity index 100% rename from tests/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-firefox-linux.png rename to tests/src/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-firefox-linux.png diff --git a/tests/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-webkit-darwin.png b/tests/src/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-webkit-darwin.png similarity index 100% rename from tests/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-webkit-darwin.png rename to tests/src/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-webkit-darwin.png diff --git a/tests/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-webkit-linux.png b/tests/src/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-webkit-linux.png similarity index 100% rename from tests/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-webkit-linux.png rename to tests/src/end-to-end/placeholder/placeholder.test.ts-snapshots/initial-placeholder-webkit-linux.png diff --git a/tests/end-to-end/slashmenu/slashmenu.test.ts b/tests/src/end-to-end/slashmenu/slashmenu.test.ts similarity index 100% rename from tests/end-to-end/slashmenu/slashmenu.test.ts rename to tests/src/end-to-end/slashmenu/slashmenu.test.ts diff --git a/tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/docStructureSnapshot-chromium-linux.json b/tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/docStructureSnapshot-chromium-linux.json similarity index 100% rename from tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/docStructureSnapshot-chromium-linux.json rename to tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/docStructureSnapshot-chromium-linux.json diff --git a/tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/docStructureSnapshot-firefox-linux.json b/tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/docStructureSnapshot-firefox-linux.json similarity index 100% rename from tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/docStructureSnapshot-firefox-linux.json rename to tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/docStructureSnapshot-firefox-linux.json diff --git a/tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/docStructureSnapshot-webkit-linux.json b/tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/docStructureSnapshot-webkit-linux.json similarity index 100% rename from tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/docStructureSnapshot-webkit-linux.json rename to tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/docStructureSnapshot-webkit-linux.json diff --git a/tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-chromium-darwin.png b/tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-chromium-darwin.png similarity index 100% rename from tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-chromium-darwin.png rename to tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-chromium-darwin.png diff --git a/tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-chromium-linux.png b/tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-chromium-linux.png similarity index 100% rename from tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-chromium-linux.png rename to tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-chromium-linux.png diff --git a/tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-firefox-darwin.png b/tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-firefox-darwin.png similarity index 100% rename from tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-firefox-darwin.png rename to tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-firefox-darwin.png diff --git a/tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-firefox-linux.png b/tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-firefox-linux.png similarity index 100% rename from tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-firefox-linux.png rename to tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-firefox-linux.png diff --git a/tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-webkit-darwin.png b/tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-webkit-darwin.png similarity index 100% rename from tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-webkit-darwin.png rename to tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-webkit-darwin.png diff --git a/tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-webkit-linux.png b/tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-webkit-linux.png similarity index 100% rename from tests/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-webkit-linux.png rename to tests/src/end-to-end/slashmenu/slashmenu.test.ts-snapshots/slash-menu-end-product-webkit-linux.png diff --git a/tests/end-to-end/textalignment/textAlignment.test.ts b/tests/src/end-to-end/textalignment/textAlignment.test.ts similarity index 93% rename from tests/end-to-end/textalignment/textAlignment.test.ts rename to tests/src/end-to-end/textalignment/textAlignment.test.ts index 700721b1c4..d948347141 100644 --- a/tests/end-to-end/textalignment/textAlignment.test.ts +++ b/tests/src/end-to-end/textalignment/textAlignment.test.ts @@ -1,13 +1,8 @@ +import { expect } from "@playwright/test"; import { test } from "../../setup/setupScript"; -import { - ALIGN_TEXT_RIGHT_BUTTON_SELECTOR, - BASE_URL, - H_ONE_BLOCK_SELECTOR, - H_TWO_BLOCK_SELECTOR, -} from "../../utils/const"; -import { focusOnEditor } from "../../utils/editor"; +import { ALIGN_TEXT_RIGHT_BUTTON_SELECTOR, BASE_URL } from "../../utils/const"; import { insertHeading } from "../../utils/copypaste"; -import { expect } from "@playwright/test"; +import { focusOnEditor } from "../../utils/editor"; test.beforeEach(async ({ page }) => { await page.goto(BASE_URL, { waitUntil: "networkidle" }); diff --git a/tests/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextMultipleBlocks-chromium-linux.png b/tests/src/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextMultipleBlocks-chromium-linux.png similarity index 100% rename from tests/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextMultipleBlocks-chromium-linux.png rename to tests/src/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextMultipleBlocks-chromium-linux.png diff --git a/tests/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextMultipleBlocks-firefox-linux.png b/tests/src/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextMultipleBlocks-firefox-linux.png similarity index 100% rename from tests/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextMultipleBlocks-firefox-linux.png rename to tests/src/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextMultipleBlocks-firefox-linux.png diff --git a/tests/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextMultipleBlocks-webkit-linux.png b/tests/src/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextMultipleBlocks-webkit-linux.png similarity index 100% rename from tests/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextMultipleBlocks-webkit-linux.png rename to tests/src/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextMultipleBlocks-webkit-linux.png diff --git a/tests/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextSingleBlock-chromium-linux.png b/tests/src/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextSingleBlock-chromium-linux.png similarity index 100% rename from tests/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextSingleBlock-chromium-linux.png rename to tests/src/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextSingleBlock-chromium-linux.png diff --git a/tests/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextSingleBlock-firefox-linux.png b/tests/src/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextSingleBlock-firefox-linux.png similarity index 100% rename from tests/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextSingleBlock-firefox-linux.png rename to tests/src/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextSingleBlock-firefox-linux.png diff --git a/tests/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextSingleBlock-webkit-linux.png b/tests/src/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextSingleBlock-webkit-linux.png similarity index 100% rename from tests/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextSingleBlock-webkit-linux.png rename to tests/src/end-to-end/textalignment/textAlignment.test.ts-snapshots/alignTextSingleBlock-webkit-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts b/tests/src/end-to-end/theming/theming.test.ts similarity index 100% rename from tests/end-to-end/theming/theming.test.ts rename to tests/src/end-to-end/theming/theming.test.ts diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-drag-handle-menu-chromium-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-drag-handle-menu-chromium-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-drag-handle-menu-chromium-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-drag-handle-menu-chromium-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-drag-handle-menu-firefox-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-drag-handle-menu-firefox-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-drag-handle-menu-firefox-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-drag-handle-menu-firefox-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-drag-handle-menu-webkit-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-drag-handle-menu-webkit-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-drag-handle-menu-webkit-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-drag-handle-menu-webkit-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-editor-chromium-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-editor-chromium-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-editor-chromium-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-editor-chromium-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-editor-firefox-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-editor-firefox-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-editor-firefox-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-editor-firefox-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-editor-webkit-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-editor-webkit-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-editor-webkit-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-editor-webkit-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-formatting-toolbar-chromium-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-formatting-toolbar-chromium-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-formatting-toolbar-chromium-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-formatting-toolbar-chromium-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-formatting-toolbar-firefox-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-formatting-toolbar-firefox-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-formatting-toolbar-firefox-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-formatting-toolbar-firefox-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-formatting-toolbar-webkit-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-formatting-toolbar-webkit-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-formatting-toolbar-webkit-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-formatting-toolbar-webkit-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-hyperlink-toolbar-chromium-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-hyperlink-toolbar-chromium-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-hyperlink-toolbar-chromium-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-hyperlink-toolbar-chromium-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-hyperlink-toolbar-firefox-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-hyperlink-toolbar-firefox-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-hyperlink-toolbar-firefox-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-hyperlink-toolbar-firefox-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-hyperlink-toolbar-webkit-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-hyperlink-toolbar-webkit-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-hyperlink-toolbar-webkit-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-hyperlink-toolbar-webkit-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-image-toolbar-chromium-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-image-toolbar-chromium-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-image-toolbar-chromium-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-image-toolbar-chromium-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-image-toolbar-firefox-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-image-toolbar-firefox-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-image-toolbar-firefox-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-image-toolbar-firefox-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-image-toolbar-webkit-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-image-toolbar-webkit-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-image-toolbar-webkit-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-image-toolbar-webkit-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-side-menu-chromium-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-side-menu-chromium-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-side-menu-chromium-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-side-menu-chromium-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-side-menu-firefox-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-side-menu-firefox-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-side-menu-firefox-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-side-menu-firefox-linux.png diff --git a/tests/end-to-end/theming/theming.test.ts-snapshots/dark-side-menu-webkit-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-side-menu-webkit-linux.png similarity index 100% rename from tests/end-to-end/theming/theming.test.ts-snapshots/dark-side-menu-webkit-linux.png rename to tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-side-menu-webkit-linux.png diff --git a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-chromium-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-chromium-linux.png new file mode 100644 index 0000000000..afb3383394 Binary files /dev/null and b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-chromium-linux.png differ diff --git a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-firefox-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-firefox-linux.png new file mode 100644 index 0000000000..2a62207d0a Binary files /dev/null and b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-firefox-linux.png differ diff --git a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-webkit-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-webkit-linux.png new file mode 100644 index 0000000000..28f1a28e03 Binary files /dev/null and b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-slash-menu-webkit-linux.png differ diff --git a/tests/setup/setupScript.ts b/tests/src/setup/setupScript.ts similarity index 98% rename from tests/setup/setupScript.ts rename to tests/src/setup/setupScript.ts index 3fa0a38de1..1eb448aa0a 100644 --- a/tests/setup/setupScript.ts +++ b/tests/src/setup/setupScript.ts @@ -2,7 +2,6 @@ import { BrowserContext, BrowserContextOptions, test as base, - expect, } from "@playwright/test"; export type TestOptions = { diff --git a/tests/setup/setupScriptComponent.ts b/tests/src/setup/setupScriptComponent.ts similarity index 90% rename from tests/setup/setupScriptComponent.ts rename to tests/src/setup/setupScriptComponent.ts index 6d92b67ee1..27c14bd192 100644 --- a/tests/setup/setupScriptComponent.ts +++ b/tests/src/setup/setupScriptComponent.ts @@ -1,4 +1,4 @@ -import { test as base, expect } from "@playwright/experimental-ct-react"; +import { test as base } from "@playwright/experimental-ct-react"; export type TestOptions = { mockID: number | undefined; diff --git a/tests/src/types/styles.d.ts b/tests/src/types/styles.d.ts new file mode 100644 index 0000000000..f57bdaee63 --- /dev/null +++ b/tests/src/types/styles.d.ts @@ -0,0 +1 @@ +declare module "*.module.css"; \ No newline at end of file diff --git a/tests/utils/components/Editor.module.css b/tests/src/utils/components/Editor.module.css similarity index 100% rename from tests/utils/components/Editor.module.css rename to tests/src/utils/components/Editor.module.css diff --git a/tests/utils/components/Editor.tsx b/tests/src/utils/components/Editor.tsx similarity index 70% rename from tests/utils/components/Editor.tsx rename to tests/src/utils/components/Editor.tsx index d719605e44..ffc47cc38f 100644 --- a/tests/utils/components/Editor.tsx +++ b/tests/src/utils/components/Editor.tsx @@ -1,4 +1,4 @@ -import { defaultBlockSchema } from "@blocknote/core"; +import { defaultBlockSpecs } from "@blocknote/core"; import "@blocknote/core/style.css"; import { BlockNoteView, @@ -9,25 +9,21 @@ import { Alert, insertAlert } from "../customblocks/Alert"; import { Button, insertButton } from "../customblocks/Button"; import { Embed, insertEmbed } from "../customblocks/Embed"; import { Image, insertImage } from "../customblocks/Image"; -import { insertSeparator, Separator } from "../customblocks/Separator"; -import { - insertTableOfContents, - TableOfContents, -} from "../customblocks/TableOfContents"; +import { Separator, insertSeparator } from "../customblocks/Separator"; import styles from "./Editor.module.css"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; export default function Editor() { - const blockSchema = { - ...defaultBlockSchema, + const blockSpecs = { + ...defaultBlockSpecs, alert: Alert, button: Button, embed: Embed, image: Image, separator: Separator, - toc: TableOfContents, - } as const; + // toc: TableOfContents, + }; const slashMenuItems = [ insertAlert, @@ -35,15 +31,14 @@ export default function Editor() { insertEmbed, insertImage, insertSeparator, - insertTableOfContents, + // insertTableOfContents, ]; const editor = useBlockNote({ - editorDOMAttributes: { - class: styles.editor, - "data-test": "editor", + domAttributes: { + editor: { class: styles.editor, "data-test": "editor" }, }, - blockSchema: blockSchema, + blockSpecs, slashMenuItems: [...getDefaultReactSlashMenuItems(), ...slashMenuItems], }); diff --git a/tests/utils/components/EditorWithTextArea.tsx b/tests/src/utils/components/EditorWithTextArea.tsx similarity index 100% rename from tests/utils/components/EditorWithTextArea.tsx rename to tests/src/utils/components/EditorWithTextArea.tsx diff --git a/tests/utils/components/TextArea.module.css b/tests/src/utils/components/TextArea.module.css similarity index 100% rename from tests/utils/components/TextArea.module.css rename to tests/src/utils/components/TextArea.module.css diff --git a/tests/utils/const.ts b/tests/src/utils/const.ts similarity index 94% rename from tests/utils/const.ts rename to tests/src/utils/const.ts index b16e32ca89..fdbdbf0c40 100644 --- a/tests/utils/const.ts +++ b/tests/src/utils/const.ts @@ -1,7 +1,7 @@ const PORT = 3000; export const BASE_URL = !process.env.RUN_IN_DOCKER - ? `http://localhost:${PORT}` - : `http://host.docker.internal:${PORT}`; + ? `http://localhost:${PORT}/simple?hideMenu` + : `http://host.docker.internal:${PORT}/simple?hideMenu`; export const PASTE_ZONE_SELECTOR = "#pasteZone"; diff --git a/tests/utils/copypaste.ts b/tests/src/utils/copypaste.ts similarity index 100% rename from tests/utils/copypaste.ts rename to tests/src/utils/copypaste.ts diff --git a/tests/src/utils/customblocks/Alert.tsx b/tests/src/utils/customblocks/Alert.tsx new file mode 100644 index 0000000000..1585f81de3 --- /dev/null +++ b/tests/src/utils/customblocks/Alert.tsx @@ -0,0 +1,149 @@ +import { BlockSchema, createBlockSpec, defaultProps } from "@blocknote/core"; +import { ReactSlashMenuItem } from "@blocknote/react"; +import { RiAlertFill } from "react-icons/ri"; +const values = { + warning: { + icon: "⚠️", + backgroundColor: "#fbf3db", + }, + error: { + icon: "❌", + backgroundColor: "#fbe4e4", + }, + info: { + icon: "ℹ️", + backgroundColor: "#ddebf1", + }, + success: { + icon: "✅", + backgroundColor: "#ddedea", + }, +} as const; + +export const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: { + textAlignment: defaultProps.textAlignment, + textColor: defaultProps.textColor, + type: { + default: "warning", + values: ["warning", "error", "info", "success"], + }, + }, + content: "inline", + }, + { + render: (block, editor) => { + // Tests to see if types are correct: + + const test: "alert" = block.type; + console.log(test); + + // @ts-expect-error + const test1: "othertype" = block.type; + console.log(test1); + + const parent = document.createElement("div"); + parent.setAttribute( + "style", + `display: flex; background-color: ${ + values[block.props.type as keyof typeof values].backgroundColor + }` + ); + + const icon = document.createElement("div"); + icon.innerText = values[block.props.type as keyof typeof values].icon; + icon.setAttribute("contenteditable", "false"); + icon.setAttribute( + "style", + "margin-right: 0.5rem; user-select: none; cursor: pointer;" + ); + icon.addEventListener("click", () => { + const type = editor.getBlock(block)!.props.type; + + if (type === "warning") { + parent.setAttribute( + "style", + `display: flex; background-color: ${values["error"].backgroundColor}` + ); + editor.updateBlock(editor.getBlock(block)!, { + props: { + type: "error", + }, + }); + } else if (type === "error") { + parent.setAttribute( + "style", + `display: flex; background-color: ${values["info"].backgroundColor}` + ); + editor.updateBlock(editor.getBlock(block)!, { + props: { + type: "info", + }, + }); + } else if (type === "info") { + parent.setAttribute( + "style", + `display: flex; background-color: ${values["success"].backgroundColor}` + ); + editor.updateBlock(editor.getBlock(block)!, { + props: { + type: "success", + }, + }); + } else if (type === "success") { + parent.setAttribute( + "style", + `display: flex; background-color: ${values["warning"].backgroundColor}` + ); + editor.updateBlock(editor.getBlock(block)!, { + props: { + type: "warning", + }, + }); + } else { + throw new Error("Unknown alert type"); + } + }); + + const text = document.createElement("div"); + + parent.appendChild(icon); + parent.appendChild(text); + + return { + dom: parent, + contentDOM: text, + }; + }, + } +); + +export const insertAlert: ReactSlashMenuItem = { + name: "Insert Alert", + execute: (editor) => { + // editor.topLevelBlocks[0] + editor.insertBlocks( + [ + { + type: "alert", + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + aliases: [ + "alert", + "notification", + "emphasize", + "warning", + "error", + "info", + "success", + ], + group: "Media", + icon: , + hint: "Insert an alert block to emphasize text", +}; diff --git a/tests/src/utils/customblocks/Button.tsx b/tests/src/utils/customblocks/Button.tsx new file mode 100644 index 0000000000..7e2a46d5ac --- /dev/null +++ b/tests/src/utils/customblocks/Button.tsx @@ -0,0 +1,62 @@ +import { + BlockSchemaWithBlock, + createBlockSpec, + defaultProps, +} from "@blocknote/core"; +import { ReactSlashMenuItem } from "@blocknote/react"; +import { RiRadioButtonFill } from "react-icons/ri"; + +export const Button = createBlockSpec( + { + type: "button" as const, + propSchema: { + backgroundColor: defaultProps.backgroundColor, + } as const, + content: "none", + }, + { + render: (block, editor) => { + const button = document.createElement("button"); + button.innerText = "Insert Block Below"; + button.addEventListener("click", () => { + editor.insertBlocks( + [ + { + type: "paragraph", + content: "Hello World", + } as any, // TODO + ], + editor.getBlock(block)!, + "after" + ); + }); + + return { + dom: button, + }; + }, + } +); + +export const insertButton: ReactSlashMenuItem< + BlockSchemaWithBlock<"button", typeof Button.config>, + any, + any +> = { + name: "Insert Button", + execute: (editor) => { + editor.insertBlocks( + [ + { + type: "button", + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + aliases: ["button", "click", "action"], + group: "Media", + icon: , + hint: "Insert a button which inserts a block below it", +}; diff --git a/tests/src/utils/customblocks/Embed.tsx b/tests/src/utils/customblocks/Embed.tsx new file mode 100644 index 0000000000..d63d022122 --- /dev/null +++ b/tests/src/utils/customblocks/Embed.tsx @@ -0,0 +1,56 @@ +import { BlockSchemaWithBlock, createBlockSpec } from "@blocknote/core"; +import { ReactSlashMenuItem } from "@blocknote/react"; +import { RiLayout5Fill } from "react-icons/ri"; + +export const Embed = createBlockSpec( + { + type: "embed" as const, + propSchema: { + src: { + default: "https://www.youtube.com/embed/wjfuB8Xjhc4", + }, + } as const, + content: "none", + }, + { + render: (block) => { + const embed = document.createElement("iframe"); + embed.setAttribute("src", block.props.src); + embed.setAttribute("contenteditable", "false"); + embed.setAttribute("border", "1px solid black"); + embed.setAttribute("width", "500px"); + embed.setAttribute("height", "300px"); + + return { + dom: embed, + }; + }, + } +); + +export const insertEmbed: ReactSlashMenuItem< + BlockSchemaWithBlock<"embed", typeof Embed.config>, + any, + any +> = { + name: "Insert Embedded Website", + execute: (editor) => { + const src = prompt("Enter website URL"); + editor.insertBlocks( + [ + { + type: "embed", + props: { + src: src || "https://www.youtube.com/embed/wjfuB8Xjhc4", + }, + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + aliases: ["embedded", "website", "site", "link", "url"], + group: "Media", + icon: , + hint: "Insert an embedded website", +}; diff --git a/tests/src/utils/customblocks/Image.tsx b/tests/src/utils/customblocks/Image.tsx new file mode 100644 index 0000000000..789a1900d5 --- /dev/null +++ b/tests/src/utils/customblocks/Image.tsx @@ -0,0 +1,77 @@ +import { + BlockSchemaWithBlock, + createBlockSpec, + defaultProps, +} from "@blocknote/core"; +import { ReactSlashMenuItem } from "@blocknote/react"; +import { RiImage2Fill } from "react-icons/ri"; +export const Image = createBlockSpec( + { + type: "image" as const, + propSchema: { + ...defaultProps, + src: { + default: "https://via.placeholder.com/1000", + }, + } as const, + content: "inline", + }, + { + render: (block) => { + const image = document.createElement("img"); + image.setAttribute("src", block.props.src); + image.setAttribute("contenteditable", "false"); + image.setAttribute("style", "width: 100%"); + image.setAttribute("alt", "Image"); + + const caption = document.createElement("div"); + caption.setAttribute("style", "flex-grow: 1"); + + const parent = document.createElement("div"); + parent.setAttribute("style", "display: flex; flex-direction: column;"); + parent.appendChild(image); + parent.appendChild(caption); + + return { + dom: parent, + contentDOM: caption, + }; + }, + parse: (element) => { + if (element.hasAttribute("src")) { + return { + src: element.getAttribute("src")!, + }; + } + + return; + }, + } +); + +export const insertImage: ReactSlashMenuItem< + BlockSchemaWithBlock<"image", typeof Image.config>, + any, + any +> = { + name: "Insert Image", + execute: (editor) => { + const src = prompt("Enter image URL") || "https://via.placeholder.com/1000"; + editor.insertBlocks( + [ + { + type: "image", + props: { + src, + }, + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + aliases: ["image", "img", "picture", "media"], + group: "Media", + icon: , + hint: "Insert an image", +}; diff --git a/tests/src/utils/customblocks/ReactAlert.tsx b/tests/src/utils/customblocks/ReactAlert.tsx new file mode 100644 index 0000000000..263281a32e --- /dev/null +++ b/tests/src/utils/customblocks/ReactAlert.tsx @@ -0,0 +1,146 @@ +import { BlockSchemaWithBlock, defaultProps } from "@blocknote/core"; +import { ReactSlashMenuItem, createReactBlockSpec } from "@blocknote/react"; +import { useEffect, useState } from "react"; +import { RiAlertFill } from "react-icons/ri"; + +const values = { + warning: { + icon: "⚠️", + backgroundColor: "#fbf3db", + }, + error: { + icon: "❌", + backgroundColor: "#fbe4e4", + }, + info: { + icon: "ℹ️", + backgroundColor: "#ddebf1", + }, + success: { + icon: "✅", + backgroundColor: "#ddedea", + }, +} as const; + +export const ReactAlert = createReactBlockSpec( + { + type: "reactAlert" as const, + propSchema: { + textAlignment: defaultProps.textAlignment, + textColor: defaultProps.textColor, + type: { + default: "warning", + values: ["warning", "error", "info", "success"], + }, + } as const, + content: "inline", + }, + { + render: function Render(props) { + const [type, setType] = useState(props.block.props.type); + + useEffect(() => { + console.log("ReactAlert initialize"); + return () => { + console.log(" ReactAlert cleanup"); + }; + }, []); + + console.log("ReactAlert render"); + + // Tests to see if types are correct: + + const test: "reactAlert" = props.block.type; + console.log(test); + + // @ts-expect-error + const test1: "othertype" = props.block.type; + console.log(test1); + + return ( +
            +
            { + if (type === "warning") { + props.editor.updateBlock(props.block, { + props: { + type: "error", + }, + }); + setType("error"); + } else if (type === "error") { + props.editor.updateBlock(props.block, { + props: { + type: "info", + }, + }); + setType("info"); + } else if (type === "info") { + props.editor.updateBlock(props.block, { + props: { + type: "success", + }, + }); + setType("success"); + } else if (type === "success") { + props.editor.updateBlock(props.block, { + props: { + type: "warning", + }, + }); + setType("warning"); + } else { + throw new Error("Unknown alert type"); + } + }}> + {values[type as keyof typeof values].icon} +
            + +
            + ); + }, + } +); +export const insertReactAler: ReactSlashMenuItem< + BlockSchemaWithBlock<"reactAlert", typeof ReactAlert.config> +> = { + name: "Insert React Alert", + execute: (editor) => { + editor.insertBlocks( + [ + { + type: "reactAlert", + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + aliases: [ + "react", + "reactAlert", + "react alert", + "alert", + "notification", + "emphasize", + "warning", + "error", + "info", + "success", + ], + group: "Media", + icon: , + hint: "Insert an alert block to emphasize text", +}; diff --git a/tests/src/utils/customblocks/ReactImage.tsx b/tests/src/utils/customblocks/ReactImage.tsx new file mode 100644 index 0000000000..5de8b4f56f --- /dev/null +++ b/tests/src/utils/customblocks/ReactImage.tsx @@ -0,0 +1,70 @@ +import { BlockSchemaWithBlock, defaultProps } from "@blocknote/core"; +import { ReactSlashMenuItem, createReactBlockSpec } from "@blocknote/react"; +import { RiImage2Fill } from "react-icons/ri"; + +export const ReactImage = createReactBlockSpec( + { + type: "reactImage" as const, + propSchema: { + ...defaultProps, + src: { + default: "https://via.placeholder.com/1000", + }, + } as const, + content: "inline", + }, + { + render: ({ block, contentRef }) => { + return ( +
            + {"test"} + +
            + ); + }, + } +); + +export const insertReactImage: ReactSlashMenuItem< + BlockSchemaWithBlock<"reactImage", typeof ReactImage.config> +> = { + name: "Insert React Image", + execute: (editor) => { + const src = prompt("Enter image URL") || "https://via.placeholder.com/1000"; + editor.insertBlocks( + [ + { + type: "reactImage", + props: { + src, + }, + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + aliases: [ + "react", + "reactImage", + "react image", + "image", + "img", + "picture", + "media", + ], + group: "Media", + icon: , + hint: "Insert an image", +}; diff --git a/tests/src/utils/customblocks/Separator.tsx b/tests/src/utils/customblocks/Separator.tsx new file mode 100644 index 0000000000..d01e6c5ca7 --- /dev/null +++ b/tests/src/utils/customblocks/Separator.tsx @@ -0,0 +1,53 @@ +import { BlockSchemaWithBlock, createBlockSpec } from "@blocknote/core"; +import { ReactSlashMenuItem } from "@blocknote/react"; +import { RiSeparator } from "react-icons/ri"; + +export const Separator = createBlockSpec( + { + type: "separator" as const, + propSchema: {} as const, + content: "none", + }, + { + render: () => { + const separator = document.createElement("div"); + separator.setAttribute( + "style", + "height: 1px; background-color: black; width: 100%;" + ); + + const parent = document.createElement("div"); + parent.setAttribute( + "style", + "height: 1rem; display: flex; justify-content: center; align-items: center;" + ); + parent.appendChild(separator); + + return { + dom: parent, + }; + }, + } +); + +export const insertSeparator: ReactSlashMenuItem< + BlockSchemaWithBlock<"separator", typeof Separator.config> +> = { + name: "Insert Separator", + execute: (editor) => { + editor.insertBlocks( + [ + { + type: "separator", + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + + aliases: ["separator", "horizontal", "line", "rule"], + group: "Media", + icon: , + hint: "Insert a button which inserts a block below it", +}; diff --git a/tests/utils/customblocks/TableOfContents.tsx b/tests/src/utils/customblocks/TableOfContents.tsx.bak similarity index 52% rename from tests/utils/customblocks/TableOfContents.tsx rename to tests/src/utils/customblocks/TableOfContents.tsx.bak index e29844d952..eb98686834 100644 --- a/tests/utils/customblocks/TableOfContents.tsx +++ b/tests/src/utils/customblocks/TableOfContents.tsx.bak @@ -1,13 +1,13 @@ import { Block, - BlockSchema, + BlockSchemaWithBlock, createBlockSpec, InlineContent, } from "@blocknote/core"; import { ReactSlashMenuItem } from "@blocknote/react"; import { RiLayout5Fill } from "react-icons/ri"; -function inlineContentToText(inlineContent: InlineContent[]) { +function inlineContentToText(inlineContent: InlineContent[]) { return inlineContent.reduce((string, content) => { if (content.type === "link") { return ( @@ -22,7 +22,7 @@ function inlineContentToText(inlineContent: InlineContent[]) { }, ""); } -function createHeadingElements(block: Block) { +function createHeadingElements(block: Block) { const heading: HTMLElement = document.createElement("li"); const text = document.createElement("p"); text.innerText = inlineContentToText(block.content); @@ -41,33 +41,37 @@ function createHeadingElements(block: Block) { return heading; } -export const TableOfContents = createBlockSpec({ - type: "toc" as const, - propSchema: {} as const, - containsInlineContent: false, - render: (_, editor) => { - const toc = document.createElement("ol"); +export const TableOfContents = createBlockSpec( + { + type: "toc" as const, + propSchema: {} as const, + content: "none", + }, + { + render: (_, editor) => { + const toc = document.createElement("ol"); - editor.onEditorContentChange(() => { - toc.innerHTML = ""; - for (const block of editor.topLevelBlocks) { - if (block.type === "heading") { - toc.appendChild(createHeadingElements(block)); + editor.onEditorContentChange(() => { + toc.innerHTML = ""; + for (const block of editor.topLevelBlocks) { + if (block.type === "heading") { + toc.appendChild(createHeadingElements(block)); + } } - } - }); + }); - return { - dom: toc, - }; - }, -}); + return { + dom: toc, + }; + }, + } +); -export const insertTableOfContents = new ReactSlashMenuItem<{ - toc: typeof TableOfContents; -}>( - "Insert Table of Contents", - (editor) => { +export const insertTableOfContents: ReactSlashMenuItem< + BlockSchemaWithBlock<"toc", typeof TableOfContents.config> +> = { + name: "Insert Separator", + execute: (editor) => { editor.insertBlocks( [ { @@ -78,8 +82,9 @@ export const insertTableOfContents = new ReactSlashMenuItem<{ "after" ); }, - ["toc", "table", "contents", "navigation", "headings"], - "Media", - , - "Insert a table of contents" -); + + aliases: ["toc", "table", "contents", "navigation", "headings"], + group: "Media", + icon: , + hint: "Insert a table of contents", +}; diff --git a/tests/utils/debug.ts b/tests/src/utils/debug.ts similarity index 92% rename from tests/utils/debug.ts rename to tests/src/utils/debug.ts index 42dfe5866f..687aa5313f 100644 --- a/tests/utils/debug.ts +++ b/tests/src/utils/debug.ts @@ -2,9 +2,9 @@ import { Page } from "@playwright/test"; export async function showMouseCursor(page: Page) { await page.evaluate(() => { - if (window !== window.parent) return; + if (window !== window.parent) {return;} - let cursorStyle = { + const cursorStyle = { position: "absolute", left: "0", top: "0", @@ -20,7 +20,7 @@ export async function showMouseCursor(page: Page) { }; let cssString = ""; - for (let [key, value] of Object.entries(cursorStyle)) { + for (const [key, value] of Object.entries(cursorStyle)) { cssString += key + ":" + value + ";"; } diff --git a/tests/utils/draghandle.ts b/tests/src/utils/draghandle.ts similarity index 85% rename from tests/utils/draghandle.ts rename to tests/src/utils/draghandle.ts index a576070b85..ea0c55d711 100644 --- a/tests/utils/draghandle.ts +++ b/tests/src/utils/draghandle.ts @@ -1,5 +1,5 @@ import { Page } from "@playwright/test"; -import { DRAG_HANDLE_SELECTOR, DRAG_HANDLE_ADD_SELECTOR } from "./const"; +import { DRAG_HANDLE_ADD_SELECTOR, DRAG_HANDLE_SELECTOR } from "./const"; import { moveMouseOverElement } from "./mouse"; export async function addBlockFromDragHandle(page: Page, blockQuery: string) { @@ -22,6 +22,6 @@ export async function getDragHandleYCoord(page: Page, selector: string) { const element = await page.locator(selector); await moveMouseOverElement(page, element); await page.waitForSelector(DRAG_HANDLE_SELECTOR); - const boundingBox = await page.locator(DRAG_HANDLE_SELECTOR).boundingBox(); + const boundingBox = (await page.locator(DRAG_HANDLE_SELECTOR).boundingBox())!; return boundingBox.y; } diff --git a/tests/utils/editor.ts b/tests/src/utils/editor.ts similarity index 89% rename from tests/utils/editor.ts rename to tests/src/utils/editor.ts index 4885df016a..168a229c39 100644 --- a/tests/utils/editor.ts +++ b/tests/src/utils/editor.ts @@ -22,8 +22,8 @@ export async function getDoc(page: Page) { return doc; } -export function removeAttFromDoc(doc: unknown, att: string) { - if (typeof doc !== "object" || doc === null) return; +export function removeAttFromDoc(doc: any, att: string) { + if (typeof doc !== "object" || doc === null) {return;} if (Object.keys(doc).includes(att)) { delete doc[att]; } diff --git a/tests/utils/mouse.ts b/tests/src/utils/mouse.ts similarity index 77% rename from tests/utils/mouse.ts rename to tests/src/utils/mouse.ts index 308b4f39b7..2f9037293c 100644 --- a/tests/utils/mouse.ts +++ b/tests/src/utils/mouse.ts @@ -1,22 +1,22 @@ import { Locator, Page } from "@playwright/test"; import { DRAG_HANDLE_SELECTOR } from "./const"; -async function getElementLeftCoords(page: Page, element: Locator) { - const boundingBox = await element.boundingBox(); +async function getElementLeftCoords(_page: Page, element: Locator) { + const boundingBox = (await element.boundingBox())!; const centerY = boundingBox.y + boundingBox.height / 2; return { x: boundingBox.x + 1, y: centerY }; } -async function getElementRightCoords(page: Page, element: Locator) { - const boundingBox = await element.boundingBox(); +async function getElementRightCoords(_page: Page, element: Locator) { + const boundingBox = (await element.boundingBox())!; const centerY = boundingBox.y + boundingBox.height / 2; return { x: boundingBox.x + boundingBox.width - 1, y: centerY }; } -async function getElementCenterCoords(page: Page, element: Locator) { - const boundingBox = await element.boundingBox(); +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; @@ -24,7 +24,7 @@ async function getElementCenterCoords(page: Page, element: Locator) { } export async function moveMouseOverElement(page: Page, element: Locator) { - const boundingBox = await element.boundingBox(); + const boundingBox = (await element.boundingBox())!; const coords = { x: boundingBox.x, y: boundingBox.y }; await page.mouse.move(coords.x, coords.y, { steps: 5 }); } diff --git a/tests/utils/slashmenu.ts b/tests/src/utils/slashmenu.ts similarity index 100% rename from tests/utils/slashmenu.ts rename to tests/src/utils/slashmenu.ts diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000000..3e9d8d8cf2 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "jsx": "react-jsx", + "strict": true, + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "outDir": "dist", + "declaration": true, + "declarationDir": "types", + "composite": true, + "skipLibCheck": true + }, + "include": ["src"], + "references": [ + { + "path": "../packages/core" + }, + { + "path": "../packages/react" + } + ] +} diff --git a/tests/utils/customblocks/Alert.tsx b/tests/utils/customblocks/Alert.tsx deleted file mode 100644 index 8df5df14f1..0000000000 --- a/tests/utils/customblocks/Alert.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { createBlockSpec, defaultProps } from "@blocknote/core"; -import { ReactSlashMenuItem } from "@blocknote/react"; -import { RiAlertFill } from "react-icons/ri"; - -const values = { - warning: { - icon: "⚠️", - backgroundColor: "#fbf3db", - }, - error: { - icon: "❌", - backgroundColor: "#fbe4e4", - }, - info: { - icon: "ℹ️", - backgroundColor: "#ddebf1", - }, - success: { - icon: "✅", - backgroundColor: "#ddedea", - }, -} as const; - -export const Alert = createBlockSpec({ - type: "alert" as const, - propSchema: { - textAlignment: defaultProps.textAlignment, - textColor: defaultProps.textColor, - type: { - default: "warning", - values: ["warning", "error", "info", "success"], - }, - } as const, - containsInlineContent: true, - render: (block, editor) => { - // Tests to see if types are correct: - - let test: "alert" = block.type; - console.log(test); - - // @ts-expect-error - let test1: "othertype" = block.type; - console.log(test1); - - const parent = document.createElement("div"); - parent.setAttribute( - "style", - `display: flex; background-color: ${ - values[block.props.type as keyof typeof values].backgroundColor - }` - ); - - const icon = document.createElement("div"); - icon.innerText = values[block.props.type as keyof typeof values].icon; - icon.setAttribute("contenteditable", "false"); - icon.setAttribute( - "style", - "margin-right: 0.5rem; user-select: none; cursor: pointer;" - ); - icon.addEventListener("click", () => { - const type = editor.getBlock(block)!.props.type; - - if (type === "warning") { - parent.setAttribute( - "style", - `display: flex; background-color: ${values["error"].backgroundColor}` - ); - editor.updateBlock(editor.getBlock(block)!, { - props: { - type: "error", - }, - }); - } else if (type === "error") { - parent.setAttribute( - "style", - `display: flex; background-color: ${values["info"].backgroundColor}` - ); - editor.updateBlock(editor.getBlock(block)!, { - props: { - type: "info", - }, - }); - } else if (type === "info") { - parent.setAttribute( - "style", - `display: flex; background-color: ${values["success"].backgroundColor}` - ); - editor.updateBlock(editor.getBlock(block)!, { - props: { - type: "success", - }, - }); - } else if (type === "success") { - parent.setAttribute( - "style", - `display: flex; background-color: ${values["warning"].backgroundColor}` - ); - editor.updateBlock(editor.getBlock(block)!, { - props: { - type: "warning", - }, - }); - } else { - throw new Error("Unknown alert type"); - } - }); - - const text = document.createElement("div"); - - parent.appendChild(icon); - parent.appendChild(text); - - return { - dom: parent, - contentDOM: text, - }; - }, -}); - -export const insertAlert = new ReactSlashMenuItem<{ - alert: typeof Alert; -}>( - "Insert Alert", - (editor) => { - editor.insertBlocks( - [ - { - type: "alert", - }, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - ["alert", "notification", "emphasize", "warning", "error", "info", "success"], - "Media", - , - "Insert an alert block to emphasize text" -); diff --git a/tests/utils/customblocks/Button.tsx b/tests/utils/customblocks/Button.tsx deleted file mode 100644 index 5e861e63ae..0000000000 --- a/tests/utils/customblocks/Button.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { createBlockSpec, defaultProps } from "@blocknote/core"; -import { ReactSlashMenuItem } from "@blocknote/react"; -import { RiRadioButtonFill } from "react-icons/ri"; - -export const Button = createBlockSpec({ - type: "button" as const, - propSchema: { - backgroundColor: defaultProps.backgroundColor, - } as const, - containsInlineContent: false, - render: (block, editor) => { - const button = document.createElement("button"); - button.innerText = "Insert Block Below"; - button.addEventListener("click", () => { - editor.insertBlocks( - [ - { - type: "paragraph", - content: "Hello World", - }, - ], - editor.getBlock(block)!, - "after" - ); - }); - - return { - dom: button, - }; - }, -}); - -export const insertButton = new ReactSlashMenuItem<{ - button: typeof Button; -}>( - "Insert Button", - (editor) => { - editor.insertBlocks( - [ - { - type: "button", - }, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - ["button", "click", "action"], - "Media", - , - "Insert a button which inserts a block below it" -); diff --git a/tests/utils/customblocks/Embed.tsx b/tests/utils/customblocks/Embed.tsx deleted file mode 100644 index 9fbd822012..0000000000 --- a/tests/utils/customblocks/Embed.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { createBlockSpec } from "@blocknote/core"; -import { ReactSlashMenuItem } from "@blocknote/react"; -import { RiLayout5Fill } from "react-icons/ri"; - -export const Embed = createBlockSpec({ - type: "embed" as const, - propSchema: { - src: { - default: "https://www.youtube.com/embed/wjfuB8Xjhc4", - }, - } as const, - containsInlineContent: false, - render: (block) => { - const embed = document.createElement("iframe"); - embed.setAttribute("src", block.props.src); - embed.setAttribute("contenteditable", "false"); - embed.setAttribute("border", "1px solid black"); - embed.setAttribute("width", "500px"); - embed.setAttribute("height", "300px"); - - return { - dom: embed, - }; - }, -}); - -export const insertEmbed = new ReactSlashMenuItem<{ - embed: typeof Embed; -}>( - "Insert Embedded Website", - (editor) => { - const src = prompt("Enter website URL"); - editor.insertBlocks( - [ - { - type: "embed", - props: { - src: src || "https://www.youtube.com/embed/wjfuB8Xjhc4", - }, - }, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - ["embedded", "website", "site", "link", "url"], - "Media", - , - "Insert an embedded website" -); diff --git a/tests/utils/customblocks/Image.tsx b/tests/utils/customblocks/Image.tsx deleted file mode 100644 index 68fd3193b3..0000000000 --- a/tests/utils/customblocks/Image.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { BlockSchema, createBlockSpec, defaultProps } from "@blocknote/core"; -import { ReactSlashMenuItem } from "@blocknote/react"; -import { RiImage2Fill } from "react-icons/ri"; - -export const Image = createBlockSpec({ - type: "image" as const, - propSchema: { - ...defaultProps, - src: { - default: "https://via.placeholder.com/1000", - }, - } as const, - containsInlineContent: true, - render: (block) => { - const image = document.createElement("img"); - image.setAttribute("src", block.props.src); - image.setAttribute("contenteditable", "false"); - image.setAttribute("style", "width: 100%"); - - const caption = document.createElement("div"); - caption.setAttribute("style", "flex-grow: 1"); - - const parent = document.createElement("div"); - parent.setAttribute("style", "display: flex; flex-direction: column;"); - parent.appendChild(image); - parent.appendChild(caption); - - return { - dom: parent, - contentDOM: caption, - }; - }, -}); - -export const insertImage = { - name: "Insert Image", - execute: (editor) => { - const src = prompt("Enter image URL"); - editor.insertBlocks( - [ - { - type: "image", - props: { - src: src || "https://via.placeholder.com/1000", - }, - }, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - aliases: ["image", "img", "picture", "media"], - group: "Media", - icon: , - hint: "Insert an image", -} satisfies ReactSlashMenuItem; diff --git a/tests/utils/customblocks/ReactAlert.tsx b/tests/utils/customblocks/ReactAlert.tsx deleted file mode 100644 index e89b8586c4..0000000000 --- a/tests/utils/customblocks/ReactAlert.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { defaultProps } from "@blocknote/core"; -import { - createReactBlockSpec, - InlineContent, - ReactSlashMenuItem, -} from "@blocknote/react"; -import { useEffect, useState } from "react"; -import { RiAlertFill } from "react-icons/ri"; - -const values = { - warning: { - icon: "⚠️", - backgroundColor: "#fbf3db", - }, - error: { - icon: "❌", - backgroundColor: "#fbe4e4", - }, - info: { - icon: "ℹ️", - backgroundColor: "#ddebf1", - }, - success: { - icon: "✅", - backgroundColor: "#ddedea", - }, -} as const; - -export const ReactAlert = createReactBlockSpec({ - type: "reactAlert" as const, - propSchema: { - textAlignment: defaultProps.textAlignment, - textColor: defaultProps.textColor, - type: { - default: "warning", - values: ["warning", "error", "info", "success"], - }, - } as const, - containsInlineContent: true, - render: (props) => { - const [type, setType] = useState(props.block.props.type); - - useEffect(() => { - console.log("ReactAlert initialize"); - return () => { - console.log(" ReactAlert cleanup"); - }; - }, []); - - console.log("ReactAlert render"); - - // Tests to see if types are correct: - - let test: "reactAlert" = props.block.type; - console.log(test); - - // @ts-expect-error - let test1: "othertype" = props.block.type; - console.log(test1); - - return ( -
            -
            { - if (type === "warning") { - props.editor.updateBlock(props.block, { - props: { - type: "error", - }, - }); - setType("error"); - } else if (type === "error") { - props.editor.updateBlock(props.block, { - props: { - type: "info", - }, - }); - setType("info"); - } else if (type === "info") { - props.editor.updateBlock(props.block, { - props: { - type: "success", - }, - }); - setType("success"); - } else if (type === "success") { - props.editor.updateBlock(props.block, { - props: { - type: "warning", - }, - }); - setType("warning"); - } else { - throw new Error("Unknown alert type"); - } - }}> - {values[type as keyof typeof values].icon} -
            - -
            - ); - }, -}); -export const insertReactAlert = new ReactSlashMenuItem<{ - reactAlert: typeof ReactAlert; -}>( - "Insert React Alert", - (editor) => { - editor.insertBlocks( - [ - { - type: "reactAlert", - }, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - [ - "react", - "reactAlert", - "react alert", - "alert", - "notification", - "emphasize", - "warning", - "error", - "info", - "success", - ], - "Media", - , - "Insert an alert block to emphasize text" -); diff --git a/tests/utils/customblocks/ReactImage.tsx b/tests/utils/customblocks/ReactImage.tsx deleted file mode 100644 index 6d18f6e97f..0000000000 --- a/tests/utils/customblocks/ReactImage.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { - InlineContent, - createReactBlockSpec, - ReactSlashMenuItem, -} from "@blocknote/react"; -import { BlockSchema, defaultProps } from "@blocknote/core"; -import { RiImage2Fill } from "react-icons/ri"; - -export const ReactImage = createReactBlockSpec({ - type: "reactImage" as const, - propSchema: { - ...defaultProps, - src: { - default: "https://via.placeholder.com/1000", - }, - } as const, - containsInlineContent: true, - render: ({ block }) => { - return ( -
            - {"Image"} - -
            - ); - }, -}); - -export const insertReactImage = { - name: "Insert React Image", - execute: (editor) => { - const src = prompt("Enter image URL"); - editor.insertBlocks( - [ - { - type: "reactImage", - props: { - src: src || "https://via.placeholder.com/1000", - }, - }, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - aliases: [ - "react", - "reactImage", - "react image", - "image", - "img", - "picture", - "media", - ], - group: "Media", - icon: , - hint: "Insert an image", -} satisfies ReactSlashMenuItem; diff --git a/tests/utils/customblocks/Separator.tsx b/tests/utils/customblocks/Separator.tsx deleted file mode 100644 index 39e84067d7..0000000000 --- a/tests/utils/customblocks/Separator.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { createBlockSpec } from "@blocknote/core"; -import { ReactSlashMenuItem } from "@blocknote/react"; -import { RiSeparator } from "react-icons/ri"; - -export const Separator = createBlockSpec({ - type: "separator" as const, - propSchema: {} as const, - containsInlineContent: false, - render: () => { - const separator = document.createElement("div"); - separator.setAttribute( - "style", - "height: 1px; background-color: black; width: 100%;" - ); - - const parent = document.createElement("div"); - parent.setAttribute( - "style", - "height: 1rem; display: flex; justify-content: center; align-items: center;" - ); - parent.appendChild(separator); - - return { - dom: parent, - }; - }, -}); - -export const insertSeparator = new ReactSlashMenuItem<{ - separator: typeof Separator; -}>( - "Insert Separator", - (editor) => { - editor.insertBlocks( - [ - { - type: "separator", - }, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - ["separator", "horizontal", "line", "rule"], - "Media", - , - "Insert a button which inserts a block below it" -);