diff --git a/examples/editor/examples/Basic.tsx b/examples/editor/examples/basic/App.tsx similarity index 100% rename from examples/editor/examples/Basic.tsx rename to examples/editor/examples/basic/App.tsx diff --git a/examples/editor/examples/Collaboration.tsx b/examples/editor/examples/collaboration/App.tsx similarity index 100% rename from examples/editor/examples/Collaboration.tsx rename to examples/editor/examples/collaboration/App.tsx 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..43750998a0 --- /dev/null +++ b/examples/editor/examples/react-custom-blocks/App.tsx @@ -0,0 +1,143 @@ +import { defaultBlockSpecs, defaultProps } from "@blocknote/core"; +import "@blocknote/core/style.css"; +import { + BlockNoteView, + createReactBlockSpec, + useBlockNote, +} from "@blocknote/react"; + +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) => ( +
+ +
+
+ ), + } +); + +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, + bracketsParagraph: bracketsParagraphBlock, + }, + initialContent: [ + { + type: "alert", + props: { + type: "success", + }, + content: "Alert", + }, + { + 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/ReactInlineContent.tsx b/examples/editor/examples/react-custom-inline-content/App.tsx similarity index 98% rename from examples/editor/examples/ReactInlineContent.tsx rename to examples/editor/examples/react-custom-inline-content/App.tsx index 07ec3deb13..2c2bc05add 100644 --- a/examples/editor/examples/ReactInlineContent.tsx +++ b/examples/editor/examples/react-custom-inline-content/App.tsx @@ -75,7 +75,6 @@ export function ReactInlineContent() { "I love ", { type: "tag", - // props: {}, content: "BlockNote", } as any, ], diff --git a/examples/editor/examples/ReactStyles.tsx b/examples/editor/examples/react-custom-styles/App.tsx similarity index 100% rename from examples/editor/examples/ReactStyles.tsx rename to examples/editor/examples/react-custom-styles/App.tsx 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..2bdfde7ce9 --- /dev/null +++ b/examples/editor/examples/vanilla-custom-blocks/App.tsx @@ -0,0 +1,213 @@ +import { + createBlockSpec, + defaultBlockSpecs, + defaultProps, +} from "@blocknote/core"; +import "@blocknote/core/style.css"; +import { BlockNoteView, useBlockNote } from "@blocknote/react"; + +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"); + Object.entries(alertStyles).forEach(([key, value]) => { + alert.style[key as any] = value; + }); + 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, + }; + }, + } +); + +// TODO: use CSS? +const alertStyles = { + display: "flex", + justifyContent: "center", + alignItems: "center", + flexGrow: "1", + height: "48px", + padding: "4px", + maxWidth: "100%", +}; + +const bracketsParagraphBlock = createBlockSpec( + { + type: "bracketsParagraph", + content: "inline", + propSchema: { + ...defaultProps, + }, + }, + { + render: () => { + const bracketsParagraph = document.createElement("div"); + Object.entries(bracketsParagraphStyles).forEach(([key, value]) => { + bracketsParagraph.style[key as any] = value; + }); + + 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.style.flexGrow = "1"; + + 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, + }; + }, + } +); + +// TODO: use CSS +const bracketsParagraphStyles = { + display: "flex", + justifyContent: "center", + alignItems: "center", + flexGrow: "1", + height: "48px", + padding: "4px", + maxWidth: "100%", +}; + +export function CustomBlocks() { + const editor = useBlockNote({ + domAttributes: { + editor: { + class: "editor", + "data-test": "editor", + }, + }, + blockSpecs: { + ...defaultBlockSpecs, + alert: alertBlock, + bracketsParagraph: bracketsParagraphBlock, + }, + initialContent: [ + { + type: "alert", + props: { + type: "success", + }, + content: ["Alert"], + }, + { + 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-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/src/main.tsx b/examples/editor/src/main.tsx index 0d2f29eefe..3d13ac7755 100644 --- a/examples/editor/src/main.tsx +++ b/examples/editor/src/main.tsx @@ -8,10 +8,14 @@ import { createBrowserRouter, } from "react-router-dom"; -import { App } from "../examples/Basic"; -import { ReactInlineContent } from "../examples/ReactInlineContent"; -import { ReactStyles } from "../examples/ReactStyles"; +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 = [ @@ -25,11 +29,26 @@ const editors = [ 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() { diff --git a/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts b/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts index 1ae2006ff7..de292f228a 100644 --- a/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts +++ b/packages/core/src/extensions/Blocks/api/blocks/createSpec.ts @@ -135,6 +135,17 @@ export function createBlockSpec< 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 diff --git a/packages/react/src/ReactBlockSpec.tsx b/packages/react/src/ReactBlockSpec.tsx index 81d1f72d79..634c707eb7 100644 --- a/packages/react/src/ReactBlockSpec.tsx +++ b/packages/react/src/ReactBlockSpec.tsx @@ -122,6 +122,17 @@ export function createReactBlockSpec< 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(