diff --git a/examples/01-basic/01-minimal/package.json b/examples/01-basic/01-minimal/package.json
index 7deed32f95..79bc3beed8 100644
--- a/examples/01-basic/01-minimal/package.json
+++ b/examples/01-basic/01-minimal/package.json
@@ -15,6 +15,7 @@
"@blocknote/ariakit": "latest",
"@blocknote/mantine": "latest",
"@blocknote/shadcn": "latest",
+ "@blocknote/code-block": "latest",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
@@ -24,4 +25,4 @@
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.3.4"
}
-}
\ No newline at end of file
+}
diff --git a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx
index 48e32a2b01..dc718437b1 100644
--- a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx
+++ b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx
@@ -1,6 +1,6 @@
import {
BlockSchema,
- checkBlockIsFileBlock,
+ blockHasType,
InlineContentSchema,
StyleSchema,
} from "@blocknote/core";
@@ -41,7 +41,7 @@ export const FileReplaceButton = () => {
if (
block === undefined ||
- !checkBlockIsFileBlock(block, editor) ||
+ !blockHasType(block, editor, { url: "string" }) ||
!editor.isEditable
) {
return null;
diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
index 6a08ae4254..1e73471d23 100644
--- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
@@ -1,9 +1,10 @@
-import { EditorState } from "prosemirror-state";
+import { EditorState, Transaction } from "prosemirror-state";
import {
getBlockInfo,
getNearestBlockPos,
} from "../../../getBlockInfoFromPos.js";
+import { getPmSchema } from "../../../pmUtil.js";
export const splitBlockCommand = (
posInBlock: number,
@@ -17,33 +18,41 @@ export const splitBlockCommand = (
state: EditorState;
dispatch: ((args?: any) => any) | undefined;
}) => {
- const nearestBlockContainerPos = getNearestBlockPos(state.doc, posInBlock);
-
- const info = getBlockInfo(nearestBlockContainerPos);
-
- if (!info.isBlockContainer) {
- throw new Error(
- `BlockContainer expected when calling splitBlock, position ${posInBlock}`,
- );
- }
-
- const types = [
- {
- type: info.bnBlock.node.type, // always keep blockcontainer type
- attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {},
- },
- {
- type: keepType
- ? info.blockContent.node.type
- : state.schema.nodes["paragraph"],
- attrs: keepProps ? { ...info.blockContent.node.attrs } : {},
- },
- ];
-
if (dispatch) {
- state.tr.split(posInBlock, 2, types);
+ return splitBlockTr(state.tr, posInBlock, keepType, keepProps);
}
return true;
};
};
+
+export const splitBlockTr = (
+ tr: Transaction,
+ posInBlock: number,
+ keepType?: boolean,
+ keepProps?: boolean,
+): boolean => {
+ const nearestBlockContainerPos = getNearestBlockPos(tr.doc, posInBlock);
+
+ const info = getBlockInfo(nearestBlockContainerPos);
+
+ if (!info.isBlockContainer) {
+ return false;
+ }
+ const schema = getPmSchema(tr);
+
+ const types = [
+ {
+ type: info.bnBlock.node.type, // always keep blockcontainer type
+ attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {},
+ },
+ {
+ type: keepType ? info.blockContent.node.type : schema.nodes["paragraph"],
+ attrs: keepProps ? { ...info.blockContent.node.attrs } : {},
+ },
+ ];
+
+ tr.split(posInBlock, 2, types);
+
+ return true;
+};
diff --git a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts
index d164dcb762..ce49383b86 100644
--- a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts
+++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts
@@ -2,7 +2,6 @@ import { Block, PartialBlock } from "../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor";
import {
BlockSchema,
- FileBlockConfig,
InlineContentSchema,
StyleSchema,
} from "../../../schema/index.js";
@@ -106,15 +105,11 @@ export async function handleFileInsertion<
event.preventDefault();
- const fileBlockConfigs = Object.values(editor.schema.blockSchema).filter(
- (blockConfig) => blockConfig.isFileBlock,
- ) as FileBlockConfig[];
-
for (let i = 0; i < items.length; i++) {
// Gets file block corresponding to MIME type.
let fileBlockType = "file";
- for (const fileBlockConfig of fileBlockConfigs) {
- for (const mimeType of fileBlockConfig.fileBlockAccept || []) {
+ for (const fileBlockConfig of Object.values(editor.schema.blockSchema)) {
+ for (const mimeType of fileBlockConfig.meta?.fileBlockAccept || []) {
const isFileExtension = mimeType.startsWith(".");
const file = items[i].getAsFile();
diff --git a/packages/core/src/blks/Audio/definition.ts b/packages/core/src/blks/Audio/definition.ts
new file mode 100644
index 0000000000..c25b30b090
--- /dev/null
+++ b/packages/core/src/blks/Audio/definition.ts
@@ -0,0 +1,135 @@
+import { parseAudioElement } from "../../blocks/AudioBlockContent/parseAudioElement.js";
+import { defaultProps } from "../../blocks/defaultProps.js";
+import { parseFigureElement } from "../../blocks/FileBlockContent/helpers/parse/parseFigureElement.js";
+import { createFileBlockWrapper } from "../../blocks/FileBlockContent/helpers/render/createFileBlockWrapper.js";
+import { createFigureWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js";
+import { createLinkWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js";
+import {
+ createBlockConfig,
+ createBlockDefinition,
+} from "../../schema/index.js";
+
+export const FILE_AUDIO_ICON_SVG =
+ '';
+
+export interface AudioOptions {
+ icon?: string;
+}
+const config = createBlockConfig(
+ (_ctx: AudioOptions) =>
+ ({
+ type: "audio" as const,
+ propSchema: {
+ backgroundColor: defaultProps.backgroundColor,
+ // File name.
+ name: {
+ default: "" as const,
+ },
+ // File url.
+ url: {
+ default: "" as const,
+ },
+ // File caption.
+ caption: {
+ default: "" as const,
+ },
+
+ showPreview: {
+ default: true,
+ },
+ },
+ content: "none",
+ meta: {
+ fileBlockAccept: ["audio/*"],
+ },
+ }) as const,
+);
+
+export const definition = createBlockDefinition(config).implementation(
+ (config = {}) => ({
+ parse: (element) => {
+ if (element.tagName === "AUDIO") {
+ // Ignore if parent figure has already been parsed.
+ if (element.closest("figure")) {
+ return undefined;
+ }
+
+ return parseAudioElement(element as HTMLAudioElement);
+ }
+
+ if (element.tagName === "FIGURE") {
+ const parsedFigure = parseFigureElement(element, "audio");
+ if (!parsedFigure) {
+ return undefined;
+ }
+
+ const { targetElement, caption } = parsedFigure;
+
+ return {
+ ...parseAudioElement(targetElement as HTMLAudioElement),
+ caption,
+ };
+ }
+
+ return undefined;
+ },
+ render: (block, editor) => {
+ const icon = document.createElement("div");
+ icon.innerHTML = config.icon ?? FILE_AUDIO_ICON_SVG;
+
+ const audio = document.createElement("audio");
+ audio.className = "bn-audio";
+ if (editor.resolveFileUrl) {
+ editor.resolveFileUrl(block.props.url).then((downloadUrl) => {
+ audio.src = downloadUrl;
+ });
+ } else {
+ audio.src = block.props.url;
+ }
+ audio.controls = true;
+ audio.contentEditable = "false";
+ audio.draggable = false;
+
+ return createFileBlockWrapper(
+ block,
+ editor,
+ { dom: audio },
+ editor.dictionary.file_blocks.audio.add_button_text,
+ icon.firstElementChild as HTMLElement,
+ );
+ },
+ toExternalHTML(block) {
+ if (!block.props.url) {
+ const div = document.createElement("p");
+ div.textContent = "Add audio";
+
+ return {
+ dom: div,
+ };
+ }
+
+ let audio;
+ if (block.props.showPreview) {
+ audio = document.createElement("audio");
+ audio.src = block.props.url;
+ } else {
+ audio = document.createElement("a");
+ audio.href = block.props.url;
+ audio.textContent = block.props.name || block.props.url;
+ }
+
+ if (block.props.caption) {
+ if (block.props.showPreview) {
+ return createFigureWithCaption(audio, block.props.caption);
+ } else {
+ return createLinkWithCaption(audio, block.props.caption);
+ }
+ }
+
+ return {
+ dom: audio,
+ };
+ },
+ runsBefore: ["file"],
+ }),
+);
diff --git a/packages/core/src/blks/BulletListItem/definition.ts b/packages/core/src/blks/BulletListItem/definition.ts
new file mode 100644
index 0000000000..ba6e378f93
--- /dev/null
+++ b/packages/core/src/blks/BulletListItem/definition.ts
@@ -0,0 +1,103 @@
+import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
+import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js";
+import { defaultProps } from "../../blocks/defaultProps.js";
+import { getListItemContent } from "../../blocks/ListItemBlockContent/getListItemContent.js";
+import {
+ createBlockConfig,
+ createBlockDefinition,
+ createBlockNoteExtension,
+} from "../../schema/index.js";
+import { handleEnter } from "../utils/listItemEnterHandler.js";
+
+const config = createBlockConfig(
+ () =>
+ ({
+ type: "bulletListItem" as const,
+ propSchema: {
+ ...defaultProps,
+ },
+ content: "inline",
+ }) as const,
+);
+
+export const definition = createBlockDefinition(config).implementation(
+ () => ({
+ parse(element) {
+ if (element.tagName !== "LI") {
+ return false;
+ }
+
+ const parent = element.parentElement;
+
+ if (parent === null) {
+ return false;
+ }
+
+ if (
+ parent.tagName === "UL" ||
+ (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL")
+ ) {
+ return {};
+ }
+
+ return false;
+ },
+ // As `li` elements can contain multiple paragraphs, we need to merge their contents
+ // into a single one so that ProseMirror can parse everything correctly.
+ parseContent: ({ el, schema }) =>
+ getListItemContent(el, schema, "bulletListItem"),
+ render() {
+ const div = document.createElement("div");
+ // 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.
+ const el = document.createElement("p");
+
+ div.appendChild(el);
+
+ return {
+ dom: div,
+ contentDOM: el,
+ };
+ },
+ }),
+ () => [
+ createBlockNoteExtension({
+ key: "bullet-list-item-shortcuts",
+ keyboardShortcuts: {
+ Enter: ({ editor }) => {
+ return handleEnter(editor, "bulletListItem");
+ },
+ "Mod-Shift-8": ({ editor }) =>
+ editor.transact((tr) => {
+ const blockInfo = getBlockInfoFromTransaction(tr);
+
+ if (
+ !blockInfo.isBlockContainer ||
+ blockInfo.blockContent.node.type.spec.content !== "inline*"
+ ) {
+ return true;
+ }
+
+ updateBlockTr(tr, blockInfo.bnBlock.beforePos, {
+ type: "bulletListItem",
+ props: {},
+ });
+ return true;
+ }),
+ },
+ inputRules: [
+ {
+ find: new RegExp(`^[-+*]\\s$`),
+ replace() {
+ return {
+ type: "bulletListItem",
+ props: {},
+ content: [],
+ };
+ },
+ },
+ ],
+ }),
+ ],
+);
diff --git a/packages/core/src/blks/CheckListItem/definition.ts b/packages/core/src/blks/CheckListItem/definition.ts
new file mode 100644
index 0000000000..b88b167611
--- /dev/null
+++ b/packages/core/src/blks/CheckListItem/definition.ts
@@ -0,0 +1,145 @@
+import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
+import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js";
+import { defaultProps } from "../../blocks/defaultProps.js";
+import { getListItemContent } from "../../blocks/ListItemBlockContent/getListItemContent.js";
+import {
+ createBlockConfig,
+ createBlockDefinition,
+ createBlockNoteExtension,
+} from "../../schema/index.js";
+import { handleEnter } from "../utils/listItemEnterHandler.js";
+
+const config = createBlockConfig(
+ () =>
+ ({
+ type: "checkListItem" as const,
+ propSchema: {
+ ...defaultProps,
+ checked: { default: false, type: "boolean" },
+ },
+ content: "inline",
+ }) as const,
+);
+
+export const definition = createBlockDefinition(config).implementation(
+ () => ({
+ parse(element) {
+ if (element.tagName === "input") {
+ // Ignore if we already parsed an ancestor list item to avoid double-parsing.
+ if (element.closest("[data-content-type]") || element.closest("li")) {
+ return;
+ }
+
+ if ((element as HTMLInputElement).type === "checkbox") {
+ return { checked: (element as HTMLInputElement).checked };
+ }
+ return;
+ }
+ if (element.tagName !== "LI") {
+ return;
+ }
+
+ const parent = element.parentElement;
+
+ if (parent === null) {
+ return;
+ }
+
+ if (
+ parent.tagName === "UL" ||
+ (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL")
+ ) {
+ const checkbox =
+ (element.querySelector("input[type=checkbox]") as HTMLInputElement) ||
+ null;
+
+ if (checkbox === null) {
+ return;
+ }
+
+ return { checked: checkbox.checked };
+ }
+
+ return;
+ },
+ // As `li` elements can contain multiple paragraphs, we need to merge their contents
+ // into a single one so that ProseMirror can parse everything correctly.
+ parseContent: ({ el, schema }) =>
+ getListItemContent(el, schema, "checkListItem"),
+ render(block) {
+ const div = document.createElement("div");
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.checked = block.props.checked;
+ if (block.props.checked) {
+ checkbox.setAttribute("checked", "");
+ }
+ // 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.
+ const paragraphEl = document.createElement("p");
+
+ div.appendChild(checkbox);
+ div.appendChild(paragraphEl);
+
+ return {
+ dom: div,
+ contentDOM: paragraphEl,
+ };
+ },
+ runsBefore: ["bulletListItem"],
+ }),
+ () => [
+ createBlockNoteExtension({
+ key: "check-list-item-shortcuts",
+ keyboardShortcuts: {
+ Enter: ({ editor }) => {
+ return handleEnter(editor, "checkListItem");
+ },
+ "Mod-Shift-9": ({ editor }) =>
+ editor.transact((tr) => {
+ const blockInfo = getBlockInfoFromTransaction(tr);
+
+ if (
+ !blockInfo.isBlockContainer ||
+ blockInfo.blockContent.node.type.spec.content !== "inline*"
+ ) {
+ return true;
+ }
+
+ updateBlockTr(tr, blockInfo.bnBlock.beforePos, {
+ type: "checkListItem",
+ props: {},
+ });
+ return true;
+ }),
+ },
+ inputRules: [
+ {
+ find: new RegExp(`\\[\\s*\\]\\s$`),
+ replace() {
+ return {
+ type: "checkListItem",
+ props: {
+ checked: false,
+ },
+ content: [],
+ };
+ },
+ },
+ {
+ find: new RegExp(`\\[[Xx]\\]\\s$`),
+ replace() {
+ return {
+ type: "checkListItem",
+ props: {
+ checked: true,
+ },
+ content: [],
+ };
+ },
+ },
+ ],
+ }),
+ ],
+);
diff --git a/packages/core/src/blks/Code/definition.ts b/packages/core/src/blks/Code/definition.ts
new file mode 100644
index 0000000000..4e1234b2d8
--- /dev/null
+++ b/packages/core/src/blks/Code/definition.ts
@@ -0,0 +1,268 @@
+import type { HighlighterGeneric } from "@shikijs/types";
+import {
+ createBlockConfig,
+ createBlockDefinition,
+ createBlockNoteExtension,
+} from "../../schema/index.js";
+import { lazyShikiPlugin } from "./shiki.js";
+
+export type CodeBlockOptions = {
+ /**
+ * Whether to indent lines with a tab when the user presses `Tab` in a code block.
+ *
+ * @default true
+ */
+ indentLineWithTab?: boolean;
+ /**
+ * The default language to use for code blocks.
+ *
+ * @default "text"
+ */
+ defaultLanguage?: string;
+ /**
+ * The languages that are supported in the editor.
+ *
+ * @example
+ * {
+ * javascript: {
+ * name: "JavaScript",
+ * aliases: ["js"],
+ * },
+ * typescript: {
+ * name: "TypeScript",
+ * aliases: ["ts"],
+ * },
+ * }
+ */
+ supportedLanguages?: Record<
+ string,
+ {
+ /**
+ * The display name of the language.
+ */
+ name: string;
+ /**
+ * Aliases for this language.
+ */
+ aliases?: string[];
+ }
+ >;
+ /**
+ * The highlighter to use for code blocks.
+ */
+ createHighlighter?: () => Promise>;
+};
+
+const config = createBlockConfig(
+ ({ defaultLanguage = "text" }: CodeBlockOptions = {}) =>
+ ({
+ type: "codeBlock" as const,
+ propSchema: {
+ language: {
+ default: defaultLanguage,
+ },
+ },
+ content: "inline",
+ meta: {
+ code: true,
+ defining: true,
+ },
+ }) as const,
+);
+
+export const definition = createBlockDefinition(config).implementation(
+ (options = {}) => ({
+ parse: (e) => {
+ const pre = e.querySelector("pre");
+ if (!pre) {
+ return undefined;
+ }
+
+ return {};
+ },
+
+ render(block, editor) {
+ const wrapper = document.createDocumentFragment();
+ const pre = document.createElement("pre");
+ const code = document.createElement("code");
+ code.textContent = block.content as unknown as string;
+ pre.appendChild(code);
+ const select = document.createElement("select");
+ const selectWrapper = document.createElement("div");
+ const handleLanguageChange = (event: Event) => {
+ const language = (event.target as HTMLSelectElement).value;
+
+ editor.updateBlock(block.id, { props: { language } });
+ };
+
+ Object.entries(options.supportedLanguages ?? {}).forEach(
+ ([id, { name }]) => {
+ const option = document.createElement("option");
+
+ option.value = id;
+ option.text = name;
+ select.appendChild(option);
+ },
+ );
+
+ selectWrapper.contentEditable = "false";
+ select.value = block.props.language || options.defaultLanguage || "text";
+ wrapper.appendChild(selectWrapper);
+ wrapper.appendChild(pre);
+ selectWrapper.appendChild(select);
+ select.addEventListener("change", handleLanguageChange);
+ return {
+ dom: wrapper,
+ contentDOM: code,
+ destroy: () => {
+ select.removeEventListener("change", handleLanguageChange);
+ },
+ };
+ },
+ toExternalHTML(block) {
+ const pre = document.createElement("pre");
+ pre.className = `language-${block.props.language}`;
+ pre.dataset.language = block.props.language;
+ const code = document.createElement("code");
+ code.textContent = block.content as unknown as string;
+ pre.appendChild(code);
+ return {
+ dom: pre,
+ };
+ },
+ }),
+ (options = {}) => {
+ return [
+ createBlockNoteExtension({
+ key: "code-block-highlighter",
+ plugins: [lazyShikiPlugin(options)],
+ }),
+ createBlockNoteExtension({
+ key: "code-block-keyboard-shortcuts",
+ keyboardShortcuts: {
+ Delete: ({ editor }) => {
+ return editor.transact((tr) => {
+ const { block } = editor.getTextCursorPosition();
+ if (block.type !== "codeBlock") {
+ return false;
+ }
+ const { $from } = tr.selection;
+
+ // When inside empty codeblock, on `DELETE` key press, delete the codeblock
+ if (!$from.parent.textContent) {
+ editor.removeBlocks([block]);
+
+ return true;
+ }
+
+ return false;
+ });
+ },
+ Tab: ({ editor }) => {
+ if (options.indentLineWithTab === false) {
+ return false;
+ }
+
+ return editor.transact((tr) => {
+ const { block } = editor.getTextCursorPosition();
+ if (block.type === "codeBlock") {
+ // TODO should probably only tab when at a line start or already tabbed in
+ tr.insertText(" ");
+ return true;
+ }
+
+ return false;
+ });
+ },
+ Enter: ({ editor }) => {
+ return editor.transact((tr) => {
+ const { block, nextBlock } = editor.getTextCursorPosition();
+ if (block.type !== "codeBlock") {
+ return false;
+ }
+ const { $from } = tr.selection;
+
+ const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
+ const endsWithDoubleNewline =
+ $from.parent.textContent.endsWith("\n\n");
+
+ // The user is trying to exit the code block by pressing enter at the end of the code block
+ if (isAtEnd && endsWithDoubleNewline) {
+ // Remove the double newline
+ tr.delete($from.pos - 2, $from.pos);
+
+ // If there is a next block, move the cursor to it
+ if (nextBlock) {
+ editor.setTextCursorPosition(nextBlock, "start");
+ return true;
+ }
+
+ // If there is no next block, insert a new paragraph
+ const [newBlock] = editor.insertBlocks(
+ [{ type: "paragraph" }],
+ block,
+ "after",
+ );
+ // Move the cursor to the new block
+ editor.setTextCursorPosition(newBlock, "start");
+
+ return true;
+ }
+
+ tr.insertText("\n");
+ return true;
+ });
+ },
+ "Shift-Enter": ({ editor }) => {
+ return editor.transact(() => {
+ const { block } = editor.getTextCursorPosition();
+ if (block.type !== "codeBlock") {
+ return false;
+ }
+
+ const [newBlock] = editor.insertBlocks(
+ // insert a new paragraph
+ [{ type: "paragraph" }],
+ block,
+ "after",
+ );
+ // move the cursor to the new block
+ editor.setTextCursorPosition(newBlock, "start");
+ return true;
+ });
+ },
+ },
+ inputRules: [
+ {
+ find: /^```(.*?)\s$/,
+ replace: ({ match }) => {
+ const languageName = match[1].trim();
+ const attributes = {
+ language: getLanguageId(options, languageName) ?? languageName,
+ };
+
+ return {
+ type: "codeBlock",
+ props: {
+ language: attributes.language,
+ },
+ content: [],
+ };
+ },
+ },
+ ],
+ }),
+ ];
+ },
+);
+
+export function getLanguageId(
+ options: CodeBlockOptions,
+ languageName: string,
+): string | undefined {
+ return Object.entries(options.supportedLanguages ?? {}).find(
+ ([id, { aliases }]) => {
+ return aliases?.includes(languageName) || id === languageName;
+ },
+ )?.[0];
+}
diff --git a/packages/core/src/blks/Code/shiki.ts b/packages/core/src/blks/Code/shiki.ts
new file mode 100644
index 0000000000..8849e49438
--- /dev/null
+++ b/packages/core/src/blks/Code/shiki.ts
@@ -0,0 +1,73 @@
+import type { HighlighterGeneric } from "@shikijs/types";
+import { Parser, createHighlightPlugin } from "prosemirror-highlight";
+import { createParser } from "prosemirror-highlight/shiki";
+import { CodeBlockOptions, getLanguageId } from "./definition.js";
+
+export const shikiParserSymbol = Symbol.for("blocknote.shikiParser");
+export const shikiHighlighterPromiseSymbol = Symbol.for(
+ "blocknote.shikiHighlighterPromise",
+);
+
+export function lazyShikiPlugin(options: CodeBlockOptions) {
+ const globalThisForShiki = globalThis as {
+ [shikiHighlighterPromiseSymbol]?: Promise>;
+ [shikiParserSymbol]?: Parser;
+ };
+
+ let highlighter: HighlighterGeneric | undefined;
+ let parser: Parser | undefined;
+ let hasWarned = false;
+ const lazyParser: Parser = (parserOptions) => {
+ if (!options.createHighlighter) {
+ if (process.env.NODE_ENV === "development" && !hasWarned) {
+ // eslint-disable-next-line no-console
+ console.log(
+ "For syntax highlighting of code blocks, you must provide a `codeBlock.createHighlighter` function",
+ );
+ hasWarned = true;
+ }
+ return [];
+ }
+ if (!highlighter) {
+ globalThisForShiki[shikiHighlighterPromiseSymbol] =
+ globalThisForShiki[shikiHighlighterPromiseSymbol] ||
+ options.createHighlighter();
+
+ return globalThisForShiki[shikiHighlighterPromiseSymbol].then(
+ (createdHighlighter) => {
+ highlighter = createdHighlighter;
+ },
+ );
+ }
+ const language = getLanguageId(options, parserOptions.language!);
+
+ if (
+ !language ||
+ language === "text" ||
+ language === "none" ||
+ language === "plaintext" ||
+ language === "txt"
+ ) {
+ return [];
+ }
+
+ if (!highlighter.getLoadedLanguages().includes(language)) {
+ return highlighter.loadLanguage(language);
+ }
+
+ if (!parser) {
+ parser =
+ globalThisForShiki[shikiParserSymbol] ||
+ createParser(highlighter as any);
+ globalThisForShiki[shikiParserSymbol] = parser;
+ }
+
+ return parser(parserOptions);
+ };
+
+ return createHighlightPlugin({
+ parser: lazyParser,
+ languageExtractor: (node) => node.attrs.language,
+ nodeTypes: ["codeBlock"],
+ });
+}
diff --git a/packages/core/src/blks/File/definition.ts b/packages/core/src/blks/File/definition.ts
new file mode 100644
index 0000000000..35f6d1f46a
--- /dev/null
+++ b/packages/core/src/blks/File/definition.ts
@@ -0,0 +1,89 @@
+import { defaultProps } from "../../blocks/defaultProps.js";
+import { parseEmbedElement } from "../../blocks/FileBlockContent/helpers/parse/parseEmbedElement.js";
+import { parseFigureElement } from "../../blocks/FileBlockContent/helpers/parse/parseFigureElement.js";
+import { createFileBlockWrapper } from "../../blocks/FileBlockContent/helpers/render/createFileBlockWrapper.js";
+import { createLinkWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js";
+import {
+ createBlockConfig,
+ createBlockDefinition,
+} from "../../schema/index.js";
+
+const config = createBlockConfig(
+ () =>
+ ({
+ type: "file" as const,
+ propSchema: {
+ backgroundColor: defaultProps.backgroundColor,
+ // File name.
+ name: {
+ default: "" as const,
+ },
+ // File url.
+ url: {
+ default: "" as const,
+ },
+ // File caption.
+ caption: {
+ default: "" as const,
+ },
+ },
+ content: "none" as const,
+ meta: {
+ fileBlockAccept: ["*/*"],
+ },
+ }) as const,
+);
+
+export const definition = createBlockDefinition(config).implementation(() => ({
+ parse: (element) => {
+ if (element.tagName === "EMBED") {
+ // Ignore if parent figure has already been parsed.
+ if (element.closest("figure")) {
+ return undefined;
+ }
+
+ return parseEmbedElement(element as HTMLEmbedElement);
+ }
+
+ if (element.tagName === "FIGURE") {
+ const parsedFigure = parseFigureElement(element, "embed");
+ if (!parsedFigure) {
+ return undefined;
+ }
+
+ const { targetElement, caption } = parsedFigure;
+
+ return {
+ ...parseEmbedElement(targetElement as HTMLEmbedElement),
+ caption,
+ };
+ }
+
+ return undefined;
+ },
+ render: (block, editor) => {
+ return createFileBlockWrapper(block, editor);
+ },
+ toExternalHTML(block) {
+ if (!block.props.url) {
+ const div = document.createElement("p");
+ div.textContent = "Add file";
+
+ return {
+ dom: div,
+ };
+ }
+
+ const fileSrcLink = document.createElement("a");
+ fileSrcLink.href = block.props.url;
+ fileSrcLink.textContent = block.props.name || block.props.url;
+
+ if (block.props.caption) {
+ return createLinkWithCaption(fileSrcLink, block.props.caption);
+ }
+
+ return {
+ dom: fileSrcLink,
+ };
+ },
+}));
diff --git a/packages/core/src/blks/Heading/definition.ts b/packages/core/src/blks/Heading/definition.ts
new file mode 100644
index 0000000000..e0cc5a1602
--- /dev/null
+++ b/packages/core/src/blks/Heading/definition.ts
@@ -0,0 +1,126 @@
+import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
+import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js";
+import { defaultProps } from "../../blocks/defaultProps.js";
+import { createToggleWrapper } from "../../blocks/ToggleWrapper/createToggleWrapper.js";
+import {
+ createBlockConfig,
+ createBlockNoteExtension,
+ createBlockDefinition,
+} from "../../schema/index.js";
+
+const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const;
+
+export interface HeadingOptions {
+ defaultLevel?: (typeof HEADING_LEVELS)[number];
+ levels?: readonly number[];
+ // TODO should probably use composition instead of this
+ allowToggleHeadings?: boolean;
+}
+
+const config = createBlockConfig(
+ ({
+ defaultLevel = 1,
+ levels = HEADING_LEVELS,
+ allowToggleHeadings = true,
+ }: HeadingOptions = {}) =>
+ ({
+ type: "heading" as const,
+ propSchema: {
+ ...defaultProps,
+ level: { default: defaultLevel, values: levels },
+ ...(allowToggleHeadings
+ ? { isToggleable: { default: false, optional: true } as const }
+ : {}),
+ },
+ content: "inline",
+ }) as const,
+);
+
+export const definition = createBlockDefinition(config).implementation(
+ ({ allowToggleHeadings = true }: HeadingOptions = {}) => ({
+ parse(e) {
+ let level: number;
+ switch (e.tagName) {
+ case "H1":
+ level = 1;
+ break;
+ case "H2":
+ level = 2;
+ break;
+ case "H3":
+ level = 3;
+ break;
+ case "H4":
+ level = 4;
+ break;
+ case "H5":
+ level = 5;
+ break;
+ case "H6":
+ level = 6;
+ break;
+ default:
+ return undefined;
+ }
+
+ return {
+ level,
+ };
+ },
+ render(block, editor) {
+ const dom = document.createElement(`h${block.props.level}`);
+
+ if (allowToggleHeadings) {
+ const toggleWrapper = createToggleWrapper(block, editor, dom);
+ return { ...toggleWrapper, contentDOM: dom };
+ }
+
+ return {
+ dom,
+ contentDOM: dom,
+ };
+ },
+ }),
+ ({ levels = HEADING_LEVELS }: HeadingOptions = {}) => [
+ createBlockNoteExtension({
+ key: "heading-shortcuts",
+ keyboardShortcuts: Object.fromEntries(
+ levels.map((level) => [
+ `Mod-Alt-${level}`,
+ ({ editor }) =>
+ editor.transact((tr) => {
+ // TODO this is weird, why do we need it?
+ // https://github.com/TypeCellOS/BlockNote/pull/561
+ const blockInfo = getBlockInfoFromTransaction(tr);
+
+ if (
+ !blockInfo.isBlockContainer ||
+ blockInfo.blockContent.node.type.spec.content !== "inline*"
+ ) {
+ return true;
+ }
+
+ updateBlockTr(tr, blockInfo.bnBlock.beforePos, {
+ type: "heading",
+ props: {
+ level: level as any,
+ },
+ });
+ return true;
+ }),
+ ]) ?? [],
+ ),
+ inputRules: levels.map((level) => ({
+ find: new RegExp(`^(#{${level}})\\s$`),
+ replace({ match }: { match: RegExpMatchArray }) {
+ return {
+ type: "heading",
+ props: {
+ level: match[1].length,
+ },
+ };
+ },
+ })),
+ }),
+ ],
+);
diff --git a/packages/core/src/blks/Image/definition.ts b/packages/core/src/blks/Image/definition.ts
new file mode 100644
index 0000000000..aa918dce3f
--- /dev/null
+++ b/packages/core/src/blks/Image/definition.ts
@@ -0,0 +1,152 @@
+import { defaultProps } from "../../blocks/defaultProps.js";
+import { parseFigureElement } from "../../blocks/FileBlockContent/helpers/parse/parseFigureElement.js";
+import { createResizableFileBlockWrapper } from "../../blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.js";
+import { createFigureWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js";
+import { createLinkWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js";
+import { parseImageElement } from "../../blocks/ImageBlockContent/parseImageElement.js";
+import {
+ createBlockConfig,
+ createBlockDefinition,
+} from "../../schema/index.js";
+
+export const FILE_IMAGE_ICON_SVG =
+ '';
+
+export interface ImageOptions {
+ icon?: string;
+}
+const config = createBlockConfig(
+ (_ctx: ImageOptions = {}) =>
+ ({
+ type: "image" as const,
+ propSchema: {
+ textAlignment: defaultProps.textAlignment,
+ backgroundColor: defaultProps.backgroundColor,
+ // File name.
+ name: {
+ default: "" as const,
+ },
+ // File url.
+ url: {
+ default: "" as const,
+ },
+ // File caption.
+ caption: {
+ default: "" as const,
+ },
+
+ showPreview: {
+ default: true,
+ },
+ // File preview width in px.
+ previewWidth: {
+ default: undefined,
+ type: "number" as const,
+ },
+ },
+ content: "none" as const,
+ meta: {
+ fileBlockAccept: ["image/*"],
+ },
+ }) as const,
+);
+
+export const definition = createBlockDefinition(config).implementation(
+ (config = {}) => ({
+ parse: (element) => {
+ if (element.tagName === "IMG") {
+ // Ignore if parent figure has already been parsed.
+ if (element.closest("figure")) {
+ return undefined;
+ }
+
+ return parseImageElement(element as HTMLImageElement);
+ }
+
+ if (element.tagName === "FIGURE") {
+ const parsedFigure = parseFigureElement(element, "img");
+ if (!parsedFigure) {
+ return undefined;
+ }
+
+ const { targetElement, caption } = parsedFigure;
+
+ return {
+ ...parseImageElement(targetElement as HTMLImageElement),
+ caption,
+ };
+ }
+
+ return undefined;
+ },
+ render: (block, editor) => {
+ const icon = document.createElement("div");
+ icon.innerHTML = config.icon ?? FILE_IMAGE_ICON_SVG;
+
+ const imageWrapper = document.createElement("div");
+ imageWrapper.className = "bn-visual-media-wrapper";
+
+ const image = document.createElement("img");
+ image.className = "bn-visual-media";
+ if (editor.resolveFileUrl) {
+ editor.resolveFileUrl(block.props.url).then((downloadUrl) => {
+ image.src = downloadUrl;
+ });
+ } else {
+ image.src = block.props.url;
+ }
+
+ image.alt = block.props.name || block.props.caption || "BlockNote image";
+ image.contentEditable = "false";
+ image.draggable = false;
+ imageWrapper.appendChild(image);
+
+ return createResizableFileBlockWrapper(
+ block,
+ editor,
+ { dom: imageWrapper },
+ imageWrapper,
+ editor.dictionary.file_blocks.image.add_button_text,
+ icon.firstElementChild as HTMLElement,
+ );
+ },
+ toExternalHTML(block) {
+ if (!block.props.url) {
+ const div = document.createElement("p");
+ div.textContent = "Add image";
+
+ return {
+ dom: div,
+ };
+ }
+
+ let image;
+ if (block.props.showPreview) {
+ image = document.createElement("img");
+ image.src = block.props.url;
+ image.alt =
+ block.props.name || block.props.caption || "BlockNote image";
+ if (block.props.previewWidth) {
+ image.width = block.props.previewWidth;
+ }
+ } else {
+ image = document.createElement("a");
+ image.href = block.props.url;
+ image.textContent = block.props.name || block.props.url;
+ }
+
+ if (block.props.caption) {
+ if (block.props.showPreview) {
+ return createFigureWithCaption(image, block.props.caption);
+ } else {
+ return createLinkWithCaption(image, block.props.caption);
+ }
+ }
+
+ return {
+ dom: image,
+ };
+ },
+ runsBefore: ["file"],
+ }),
+);
diff --git a/packages/core/src/blks/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blks/NumberedListItem/IndexingPlugin.ts
new file mode 100644
index 0000000000..370cddbefd
--- /dev/null
+++ b/packages/core/src/blks/NumberedListItem/IndexingPlugin.ts
@@ -0,0 +1,175 @@
+import type { Node } from "@tiptap/pm/model";
+import type { Transaction } from "@tiptap/pm/state";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { Decoration, DecorationSet } from "@tiptap/pm/view";
+
+import { getBlockInfo } from "../../api/getBlockInfoFromPos.js";
+
+// Loosely based on https://github.com/ueberdosis/tiptap/blob/7ac01ef0b816a535e903b5ca92492bff110a71ae/packages/extension-mathematics/src/MathematicsPlugin.ts (MIT)
+
+type DecoSpec = {
+ index: number;
+ isFirst: boolean;
+ hasStart: boolean;
+ side: number;
+};
+
+type Deco = Omit & { spec: DecoSpec };
+
+/**
+ * Calculate the index for a numbered list item based on its position and previous siblings
+ */
+function calculateListItemIndex(
+ node: Node,
+ pos: number,
+ tr: Transaction,
+ map: Map,
+): { index: number; isFirst: boolean; hasStart: boolean } {
+ let index: number = node.firstChild!.attrs["start"] || 1;
+ let isFirst = true;
+ const hasStart = !!node.firstChild!.attrs["start"];
+
+ const blockInfo = getBlockInfo({
+ posBeforeNode: pos,
+ node,
+ });
+
+ if (!blockInfo.isBlockContainer) {
+ throw new Error("impossible");
+ }
+
+ // Check if this block is the start of a new ordered list
+ const prevBlock = tr.doc.resolve(blockInfo.bnBlock.beforePos).nodeBefore;
+ const prevBlockIndex = prevBlock ? map.get(prevBlock) : undefined;
+
+ if (prevBlockIndex !== undefined) {
+ index = prevBlockIndex + 1;
+ isFirst = false;
+ } else if (prevBlock) {
+ // Because we only check the affected ranges, we may need to walk backwards to find the previous block's index
+ // We can't just rely on the map, because the map is reset every `apply` call
+ const prevBlockInfo = getBlockInfo({
+ posBeforeNode: blockInfo.bnBlock.beforePos - prevBlock.nodeSize,
+ node: prevBlock,
+ });
+
+ const isPrevBlockOrderedListItem =
+ prevBlockInfo.blockNoteType === "numberedListItem";
+ if (isPrevBlockOrderedListItem) {
+ // recurse to get the index of the previous block
+ const itemIndex = calculateListItemIndex(
+ prevBlock,
+ blockInfo.bnBlock.beforePos - prevBlock.nodeSize,
+ tr,
+ map,
+ );
+ index = itemIndex.index + 1;
+ isFirst = false;
+ }
+ }
+ // Note: we set the map late, so that when we recurse, we can rely on the map to get the previous block's index in one lookup
+ map.set(node, index);
+
+ return { index, isFirst, hasStart };
+}
+
+/**
+ * Get the decorations for the current state based on the previous state,
+ * and the transaction that was applied to get to the current state
+ */
+function getDecorations(
+ tr: Transaction,
+ previousPluginState: { decorations: DecorationSet },
+) {
+ const map = new Map();
+
+ const nextDecorationSet = previousPluginState.decorations.map(
+ tr.mapping,
+ tr.doc,
+ );
+ const decorationsToAdd = [] as Deco[];
+
+ tr.doc.nodesBetween(0, tr.doc.nodeSize - 2, (node, pos) => {
+ if (
+ node.type.name === "blockContainer" &&
+ node.firstChild!.type.name === "numberedListItem"
+ ) {
+ const { index, isFirst, hasStart } = calculateListItemIndex(
+ node,
+ pos,
+ tr,
+ map,
+ );
+
+ // Check if decoration already exists with the same properties (for perf reasons)
+ const existingDecorations = nextDecorationSet.find(
+ pos,
+ pos + node.nodeSize,
+ (deco: DecoSpec) =>
+ deco.index === index &&
+ deco.isFirst === isFirst &&
+ deco.hasStart === hasStart,
+ );
+
+ if (existingDecorations.length === 0) {
+ // Create a widget decoration to display the index
+ decorationsToAdd.push(
+ // move in by 1 to account for the block container
+ Decoration.node(pos + 1, pos + node.nodeSize - 1, {
+ "data-index": index.toString(),
+ // TODO figure out start? is this needed?
+ // "data-start": hasStart ? index.toString() : undefined,
+ }),
+ );
+ }
+ }
+ });
+
+ // Remove any decorations that exist at the same position, they will be replaced by the new decorations
+ const decorationsToRemove = decorationsToAdd.flatMap((deco) =>
+ nextDecorationSet.find(deco.from, deco.to),
+ );
+
+ return {
+ decorations: nextDecorationSet
+ // Remove existing decorations that are going to be replaced
+ .remove(decorationsToRemove)
+ // Add any new decorations
+ .add(tr.doc, decorationsToAdd),
+ };
+}
+
+/**
+ * This plugin adds decorations to numbered list items to display their index.
+ */
+export const NumberedListIndexingDecorationPlugin = () => {
+ return new Plugin<{ decorations: DecorationSet }>({
+ key: new PluginKey("numbered-list-indexing-decorations"),
+
+ state: {
+ init(_config, state) {
+ // We create an empty transaction to get the decorations for the initial state based on the initial content
+ return getDecorations(state.tr, {
+ decorations: DecorationSet.empty,
+ });
+ },
+ apply(tr, previousPluginState) {
+ if (
+ !tr.docChanged &&
+ !tr.selectionSet &&
+ previousPluginState.decorations
+ ) {
+ // Just reuse the existing decorations, since nothing should have changed
+ return previousPluginState;
+ }
+ return getDecorations(tr, previousPluginState);
+ },
+ },
+
+ props: {
+ decorations(state) {
+ return this.getState(state)?.decorations ?? DecorationSet.empty;
+ },
+ },
+ });
+};
diff --git a/packages/core/src/blks/NumberedListItem/definition.ts b/packages/core/src/blks/NumberedListItem/definition.ts
new file mode 100644
index 0000000000..088f57bffd
--- /dev/null
+++ b/packages/core/src/blks/NumberedListItem/definition.ts
@@ -0,0 +1,108 @@
+import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
+import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js";
+import { defaultProps } from "../../blocks/defaultProps.js";
+import { getListItemContent } from "../../blocks/ListItemBlockContent/getListItemContent.js";
+import {
+ createBlockConfig,
+ createBlockNoteExtension,
+ createBlockDefinition,
+} from "../../schema/index.js";
+import { handleEnter } from "../utils/listItemEnterHandler.js";
+import { NumberedListIndexingDecorationPlugin } from "./IndexingPlugin.js";
+
+const config = createBlockConfig(
+ () =>
+ ({
+ type: "numberedListItem" as const,
+ propSchema: {
+ ...defaultProps,
+ start: { default: undefined, type: "number" } as const,
+ },
+ content: "inline",
+ }) as const,
+);
+
+export const definition = createBlockDefinition(config).implementation(
+ () => ({
+ parse(element) {
+ if (element.tagName !== "LI") {
+ return false;
+ }
+
+ const parent = element.parentElement;
+
+ if (parent === null) {
+ return false;
+ }
+
+ if (
+ parent.tagName === "UL" ||
+ (parent.tagName === "DIV" && parent.parentElement?.tagName === "UL")
+ ) {
+ return {};
+ }
+
+ return false;
+ },
+ // As `li` elements can contain multiple paragraphs, we need to merge their contents
+ // into a single one so that ProseMirror can parse everything correctly.
+ parseContent: ({ el, schema }) =>
+ getListItemContent(el, schema, "numberedListItem"),
+ render() {
+ const div = document.createElement("div");
+ // 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.
+ const el = document.createElement("p");
+
+ div.appendChild(el);
+
+ return {
+ dom: div,
+ contentDOM: el,
+ };
+ },
+ }),
+ () => [
+ createBlockNoteExtension({
+ key: "numbered-list-item-shortcuts",
+ inputRules: [
+ {
+ find: new RegExp(`^(\\d+)\\.\\s$`),
+ replace({ match }) {
+ return {
+ type: "numberedListItem",
+ props: {
+ start: parseInt(match[1]),
+ },
+ content: [],
+ };
+ },
+ },
+ ],
+ keyboardShortcuts: {
+ Enter: ({ editor }) => {
+ return handleEnter(editor, "numberedListItem");
+ },
+ "Mod-Shift-7": ({ editor }) =>
+ editor.transact((tr) => {
+ const blockInfo = getBlockInfoFromTransaction(tr);
+
+ if (
+ !blockInfo.isBlockContainer ||
+ blockInfo.blockContent.node.type.spec.content !== "inline*"
+ ) {
+ return true;
+ }
+
+ updateBlockTr(tr, blockInfo.bnBlock.beforePos, {
+ type: "numberedListItem",
+ props: {},
+ });
+ return true;
+ }),
+ },
+ plugins: [NumberedListIndexingDecorationPlugin()],
+ }),
+ ],
+);
diff --git a/packages/core/src/blks/PageBreak/definition.ts b/packages/core/src/blks/PageBreak/definition.ts
new file mode 100644
index 0000000000..90a2ad9c43
--- /dev/null
+++ b/packages/core/src/blks/PageBreak/definition.ts
@@ -0,0 +1,42 @@
+import {
+ createBlockConfig,
+ createBlockDefinition,
+} from "../../schema/index.js";
+
+const config = createBlockConfig(
+ () =>
+ ({
+ type: "pageBreak" as const,
+ propSchema: {},
+ content: "none",
+ }) as const,
+);
+
+export const definition = createBlockDefinition(config).implementation(() => ({
+ parse(element) {
+ if (element.tagName === "DIV" && element.hasAttribute("data-page-break")) {
+ return {};
+ }
+
+ return undefined;
+ },
+ render() {
+ const pageBreak = document.createElement("div");
+
+ pageBreak.className = "bn-page-break";
+ pageBreak.setAttribute("data-page-break", "");
+
+ return {
+ dom: pageBreak,
+ };
+ },
+ toExternalHTML() {
+ const pageBreak = document.createElement("div");
+
+ pageBreak.setAttribute("data-page-break", "");
+
+ return {
+ dom: pageBreak,
+ };
+ },
+}));
diff --git a/packages/core/src/blks/Paragraph/definition.ts b/packages/core/src/blks/Paragraph/definition.ts
new file mode 100644
index 0000000000..6f103840b6
--- /dev/null
+++ b/packages/core/src/blks/Paragraph/definition.ts
@@ -0,0 +1,62 @@
+import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
+import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js";
+import { defaultProps } from "../../blocks/defaultProps.js";
+import {
+ createBlockConfig,
+ createBlockNoteExtension,
+ createBlockDefinition,
+} from "../../schema/index.js";
+
+const config = createBlockConfig(
+ () =>
+ ({
+ type: "paragraph" as const,
+ propSchema: defaultProps,
+ content: "inline" as const,
+ }) as const,
+);
+
+export const definition = createBlockDefinition(config).implementation(
+ () => ({
+ parse: (e) => {
+ const paragraph = e.querySelector("p");
+ if (!paragraph) {
+ return undefined;
+ }
+
+ return {};
+ },
+ render: () => {
+ const dom = document.createElement("p");
+ return {
+ dom,
+ contentDOM: dom,
+ };
+ },
+ runsBefore: ["default"],
+ }),
+ () => [
+ createBlockNoteExtension({
+ key: "paragraph-shortcuts",
+ keyboardShortcuts: {
+ "Mod-Alt-0": ({ editor }) =>
+ editor.transact((tr) => {
+ const blockInfo = getBlockInfoFromTransaction(tr);
+
+ if (
+ !blockInfo.isBlockContainer ||
+ blockInfo.blockContent.node.type.spec.content !== "inline*"
+ ) {
+ return true;
+ }
+
+ updateBlockTr(tr, blockInfo.bnBlock.beforePos, {
+ type: "paragraph",
+ props: {},
+ });
+ return true;
+ }),
+ },
+ }),
+ ],
+);
diff --git a/packages/core/src/blks/Quote/definition.ts b/packages/core/src/blks/Quote/definition.ts
new file mode 100644
index 0000000000..267d37d391
--- /dev/null
+++ b/packages/core/src/blks/Quote/definition.ts
@@ -0,0 +1,71 @@
+import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
+import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js";
+import { defaultProps } from "../../blocks/defaultProps.js";
+import {
+ createBlockConfig,
+ createBlockNoteExtension,
+ createBlockDefinition,
+} from "../../schema/index.js";
+
+const config = createBlockConfig(
+ () =>
+ ({
+ type: "quote" as const,
+ propSchema: { ...defaultProps },
+ content: "inline" as const,
+ }) as const,
+);
+
+export const definition = createBlockDefinition(config).implementation(
+ () => ({
+ parse(element) {
+ if (element.querySelector("blockquote")) {
+ return {};
+ }
+
+ return undefined;
+ },
+ render() {
+ const quote = document.createElement("blockquote");
+
+ return {
+ dom: quote,
+ contentDOM: quote,
+ };
+ },
+ }),
+ () => [
+ createBlockNoteExtension({
+ key: "quote-block-shortcuts",
+ keyboardShortcuts: {
+ "Mod-Alt-q": ({ editor }) =>
+ editor.transact((tr) => {
+ const blockInfo = getBlockInfoFromTransaction(tr);
+
+ if (
+ !blockInfo.isBlockContainer ||
+ blockInfo.blockContent.node.type.spec.content !== "inline*"
+ ) {
+ return true;
+ }
+
+ updateBlockTr(tr, blockInfo.bnBlock.beforePos, {
+ type: "quote",
+ });
+ return true;
+ }),
+ },
+ inputRules: [
+ {
+ find: new RegExp(`^>\\s$`),
+ replace() {
+ return {
+ type: "quote",
+ props: {},
+ };
+ },
+ },
+ ],
+ }),
+ ],
+);
diff --git a/packages/core/src/blks/ToggleListItem/definition.ts b/packages/core/src/blks/ToggleListItem/definition.ts
new file mode 100644
index 0000000000..59f304007c
--- /dev/null
+++ b/packages/core/src/blks/ToggleListItem/definition.ts
@@ -0,0 +1,62 @@
+import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
+import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js";
+import { defaultProps } from "../../blocks/defaultProps.js";
+import { createToggleWrapper } from "../../blocks/ToggleWrapper/createToggleWrapper.js";
+import {
+ createBlockConfig,
+ createBlockNoteExtension,
+ createBlockDefinition,
+} from "../../schema/index.js";
+import { handleEnter } from "../utils/listItemEnterHandler.js";
+
+const config = createBlockConfig(
+ () =>
+ ({
+ type: "toggleListItem" as const,
+ propSchema: {
+ ...defaultProps,
+ },
+ content: "inline" as const,
+ }) as const,
+);
+
+export const definition = createBlockDefinition(config).implementation(
+ () => ({
+ render(block, editor) {
+ const paragraphEl = document.createElement("p");
+ const toggleWrapper = createToggleWrapper(
+ block as any,
+ editor,
+ paragraphEl,
+ );
+ return { ...toggleWrapper, contentDOM: paragraphEl };
+ },
+ }),
+ () => [
+ createBlockNoteExtension({
+ key: "toggle-list-item-shortcuts",
+ keyboardShortcuts: {
+ Enter: ({ editor }) => {
+ return handleEnter(editor, "toggleListItem");
+ },
+ "Mod-Shift-6": ({ editor }) =>
+ editor.transact((tr) => {
+ const blockInfo = getBlockInfoFromTransaction(tr);
+
+ if (
+ !blockInfo.isBlockContainer ||
+ blockInfo.blockContent.node.type.spec.content !== "inline*"
+ ) {
+ return true;
+ }
+
+ updateBlockTr(tr, blockInfo.bnBlock.beforePos, {
+ type: "toggleListItem",
+ props: {},
+ });
+ return true;
+ }),
+ },
+ }),
+ ],
+);
diff --git a/packages/core/src/blks/Video/definition.ts b/packages/core/src/blks/Video/definition.ts
new file mode 100644
index 0000000000..7f4a101a60
--- /dev/null
+++ b/packages/core/src/blks/Video/definition.ts
@@ -0,0 +1,131 @@
+import { defaultProps } from "../../blocks/defaultProps.js";
+import { parseFigureElement } from "../../blocks/FileBlockContent/helpers/parse/parseFigureElement.js";
+import { createResizableFileBlockWrapper } from "../../blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.js";
+import { createFigureWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js";
+import { createLinkWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js";
+import { parseVideoElement } from "../../blocks/VideoBlockContent/parseVideoElement.js";
+import {
+ createBlockConfig,
+ createBlockDefinition,
+} from "../../schema/index.js";
+
+export const FILE_VIDEO_ICON_SVG =
+ '';
+
+export interface VideoOptions {
+ icon?: string;
+}
+const config = createBlockConfig((_ctx: VideoOptions) => ({
+ type: "video" as const,
+ propSchema: {
+ textAlignment: defaultProps.textAlignment,
+ backgroundColor: defaultProps.backgroundColor,
+ name: { default: "" as const },
+ url: { default: "" as const },
+ caption: { default: "" as const },
+ showPreview: { default: true },
+ previewWidth: { default: undefined, type: "number" as const },
+ },
+ content: "none" as const,
+ meta: {
+ fileBlockAccept: ["video/*"],
+ },
+}));
+
+export const definition = createBlockDefinition(config).implementation(
+ (config = {}) => ({
+ parse: (element) => {
+ if (element.tagName === "VIDEO") {
+ // Ignore if parent figure has already been parsed.
+ if (element.closest("figure")) {
+ return undefined;
+ }
+
+ return parseVideoElement(element as HTMLVideoElement);
+ }
+
+ if (element.tagName === "FIGURE") {
+ const parsedFigure = parseFigureElement(element, "video");
+ if (!parsedFigure) {
+ return undefined;
+ }
+
+ const { targetElement, caption } = parsedFigure;
+
+ return {
+ ...parseVideoElement(targetElement as HTMLVideoElement),
+ caption,
+ };
+ }
+
+ return undefined;
+ },
+ render: (block, editor) => {
+ const icon = document.createElement("div");
+ icon.innerHTML = config.icon ?? FILE_VIDEO_ICON_SVG;
+
+ const videoWrapper = document.createElement("div");
+ videoWrapper.className = "bn-visual-media-wrapper";
+
+ const video = document.createElement("video");
+ video.className = "bn-visual-media";
+ if (editor.resolveFileUrl) {
+ editor.resolveFileUrl(block.props.url).then((downloadUrl) => {
+ video.src = downloadUrl;
+ });
+ } else {
+ video.src = block.props.url;
+ }
+ video.controls = true;
+ video.contentEditable = "false";
+ video.draggable = false;
+ video.width = block.props.previewWidth;
+ videoWrapper.appendChild(video);
+
+ return createResizableFileBlockWrapper(
+ block,
+ editor,
+ { dom: videoWrapper },
+ videoWrapper,
+ editor.dictionary.file_blocks.video.add_button_text,
+ icon.firstElementChild as HTMLElement,
+ );
+ },
+ toExternalHTML(block) {
+ if (!block.props.url) {
+ const div = document.createElement("p");
+ div.textContent = "Add video";
+
+ return {
+ dom: div,
+ };
+ }
+
+ let video;
+ if (block.props.showPreview) {
+ video = document.createElement("video");
+ video.src = block.props.url;
+ if (block.props.previewWidth) {
+ video.width = block.props.previewWidth;
+ }
+ } else {
+ video = document.createElement("a");
+ video.href = block.props.url;
+ video.textContent = block.props.name || block.props.url;
+ }
+
+ if (block.props.caption) {
+ if (block.props.showPreview) {
+ return createFigureWithCaption(video, block.props.caption);
+ } else {
+ return createLinkWithCaption(video, block.props.caption);
+ }
+ }
+
+ return {
+ dom: video,
+ };
+ },
+ runsBefore: ["file"],
+ }),
+);
diff --git a/packages/core/src/blks/index.ts b/packages/core/src/blks/index.ts
new file mode 100644
index 0000000000..97473296ef
--- /dev/null
+++ b/packages/core/src/blks/index.ts
@@ -0,0 +1,14 @@
+export * as audio from "./Audio/definition.js";
+export * as bulletListItem from "./BulletListItem/definition.js";
+export * as checkListItem from "./CheckListItem/definition.js";
+export * as codeBlock from "./Code/definition.js";
+export * as heading from "./Heading/definition.js";
+export * as numberedListItem from "./NumberedListItem/definition.js";
+export * as pageBreak from "./PageBreak/definition.js";
+export * as paragraph from "./Paragraph/definition.js";
+export * as quote from "./Quote/definition.js";
+export * as toggleListItem from "./ToggleListItem/definition.js";
+
+export * as file from "./File/definition.js";
+export * as image from "./Image/definition.js";
+export * as video from "./Video/definition.js";
diff --git a/packages/core/src/blks/utils/listItemEnterHandler.ts b/packages/core/src/blks/utils/listItemEnterHandler.ts
new file mode 100644
index 0000000000..ceb383a611
--- /dev/null
+++ b/packages/core/src/blks/utils/listItemEnterHandler.ts
@@ -0,0 +1,42 @@
+import { splitBlockTr } from "../../api/blockManipulation/commands/splitBlock/splitBlock.js";
+import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
+import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+
+export const handleEnter = (
+ editor: BlockNoteEditor,
+ listItemType: string,
+) => {
+ const { blockInfo, selectionEmpty } = editor.transact((tr) => {
+ return {
+ blockInfo: getBlockInfoFromTransaction(tr),
+ selectionEmpty: tr.selection.anchor === tr.selection.head,
+ };
+ });
+
+ if (!blockInfo.isBlockContainer) {
+ return false;
+ }
+ const { bnBlock: blockContainer, blockContent } = blockInfo;
+
+ if (!(blockContent.node.type.name === listItemType) || !selectionEmpty) {
+ return false;
+ }
+
+ if (blockContent.node.childCount === 0) {
+ editor.transact((tr) => {
+ updateBlockTr(tr, blockContainer.beforePos, {
+ type: "paragraph",
+ props: {},
+ });
+ });
+ return true;
+ } else if (blockContent.node.childCount > 0) {
+ return editor.transact((tr) => {
+ tr.deleteSelection();
+ return splitBlockTr(tr, tr.selection.from, true);
+ });
+ }
+
+ return false;
+};
diff --git a/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts b/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts
index 7a3e0101fe..95fdb093a4 100644
--- a/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts
+++ b/packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts
@@ -2,7 +2,7 @@ import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import {
BlockFromConfig,
createBlockSpec,
- FileBlockConfig,
+ // FileBlockConfig,
Props,
PropSchema,
} from "../../schema/index.js";
@@ -43,7 +43,7 @@ export const audioBlockConfig = {
content: "none",
isFileBlock: true,
fileBlockAccept: ["audio/*"],
-} satisfies FileBlockConfig;
+} as any;
export const audioRender = (
block: BlockFromConfig,
diff --git a/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts b/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts
index 433487d8e0..ca21c1440b 100644
--- a/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts
+++ b/packages/core/src/blocks/FileBlockContent/FileBlockContent.ts
@@ -1,7 +1,7 @@
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import {
BlockFromConfig,
- FileBlockConfig,
+ // FileBlockConfig,
PropSchema,
createBlockSpec,
} from "../../schema/index.js";
@@ -32,7 +32,7 @@ export const fileBlockConfig = {
propSchema: filePropSchema,
content: "none",
isFileBlock: true,
-} satisfies FileBlockConfig;
+} as any;
export const fileRender = (
block: BlockFromConfig,
diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts b/packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts
index 0c026257ea..028d69a6a5 100644
--- a/packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts
+++ b/packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts
@@ -1,8 +1,11 @@
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
-import { BlockFromConfig, FileBlockConfig } from "../../../../schema/index.js";
+import {
+ BlockConfig,
+ BlockFromConfigNoChildren,
+} from "../../../../schema/index.js";
export const createAddFileButton = (
- block: BlockFromConfig,
+ block: BlockFromConfigNoChildren, any, any>,
editor: BlockNoteEditor,
buttonText?: string,
buttonIcon?: HTMLElement,
diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileBlockWrapper.ts b/packages/core/src/blocks/FileBlockContent/helpers/render/createFileBlockWrapper.ts
index 02af916f2d..5e9da7932d 100644
--- a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileBlockWrapper.ts
+++ b/packages/core/src/blocks/FileBlockContent/helpers/render/createFileBlockWrapper.ts
@@ -1,19 +1,28 @@
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import {
- BlockFromConfig,
- BlockSchemaWithBlock,
- FileBlockConfig,
+ BlockConfig,
+ BlockFromConfigNoChildren,
} from "../../../../schema/index.js";
import { createAddFileButton } from "./createAddFileButton.js";
import { createFileNameWithIcon } from "./createFileNameWithIcon.js";
export const createFileBlockWrapper = (
- block: BlockFromConfig,
- editor: BlockNoteEditor<
- BlockSchemaWithBlock,
+ block: BlockFromConfigNoChildren<
+ BlockConfig<
+ string,
+ {
+ backgroundColor: { default: "default" };
+ name: { default: "" };
+ url: { default: "" };
+ caption: { default: "" };
+ showPreview?: { default: true };
+ },
+ "none"
+ >,
any,
any
>,
+ editor: BlockNoteEditor,
element?: { dom: HTMLElement; destroy?: () => void },
buttonText?: string,
buttonIcon?: HTMLElement,
diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileNameWithIcon.ts b/packages/core/src/blocks/FileBlockContent/helpers/render/createFileNameWithIcon.ts
index dded8dbde6..05ba0d6281 100644
--- a/packages/core/src/blocks/FileBlockContent/helpers/render/createFileNameWithIcon.ts
+++ b/packages/core/src/blocks/FileBlockContent/helpers/render/createFileNameWithIcon.ts
@@ -1,9 +1,22 @@
-import { BlockFromConfig, FileBlockConfig } from "../../../../schema/index.js";
+import {
+ BlockConfig,
+ BlockFromConfigNoChildren,
+} from "../../../../schema/index.js";
export const FILE_ICON_SVG = ``;
export const createFileNameWithIcon = (
- block: BlockFromConfig,
+ block: BlockFromConfigNoChildren<
+ BlockConfig<
+ string,
+ {
+ name: { default: "" };
+ },
+ "none"
+ >,
+ any,
+ any
+ >,
): { dom: HTMLElement; destroy?: () => void } => {
const file = document.createElement("div");
file.className = "bn-file-name-with-icon";
diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.ts b/packages/core/src/blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.ts
index b4c4ae95d7..12ee16ff67 100644
--- a/packages/core/src/blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.ts
+++ b/packages/core/src/blocks/FileBlockContent/helpers/render/createResizableFileBlockWrapper.ts
@@ -1,9 +1,28 @@
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
-import { BlockFromConfig, FileBlockConfig } from "../../../../schema/index.js";
+import {
+ BlockConfig,
+ BlockFromConfigNoChildren,
+} from "../../../../schema/index.js";
import { createFileBlockWrapper } from "./createFileBlockWrapper.js";
export const createResizableFileBlockWrapper = (
- block: BlockFromConfig,
+ block: BlockFromConfigNoChildren<
+ BlockConfig<
+ string,
+ {
+ backgroundColor: { default: "default" };
+ name: { default: "" };
+ url: { default: "" };
+ caption: { default: "" };
+ showPreview?: { default: true };
+ previewWidth?: { default: number };
+ textAlignment?: { default: "left" };
+ },
+ "none"
+ >,
+ any,
+ any
+ >,
editor: BlockNoteEditor,
element: { dom: HTMLElement; destroy?: () => void },
resizeHandlesContainerElement: HTMLElement,
diff --git a/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts b/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts
index 32b7338640..a4113ac241 100644
--- a/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts
+++ b/packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts
@@ -2,7 +2,7 @@ import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import {
BlockFromConfig,
createBlockSpec,
- FileBlockConfig,
+ // FileBlockConfig,
Props,
PropSchema,
} from "../../schema/index.js";
@@ -48,7 +48,7 @@ export const imageBlockConfig = {
content: "none",
isFileBlock: true,
fileBlockAccept: ["image/*"],
-} satisfies FileBlockConfig;
+} as any;
export const imageRender = (
block: BlockFromConfig,
diff --git a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
index 03866d4cee..1195cdead6 100644
--- a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
+++ b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
@@ -29,6 +29,8 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
// the index attribute is only used internally (it's not part of the blocknote schema)
// that's why it's defined explicitly here, and not part of the prop schema
index: {
+ // TODO this is going to be a problem...
+ // How do we represent this? As decorations!
default: null,
parseHTML: (element) => element.getAttribute("data-index"),
renderHTML: (attributes) => {
diff --git a/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts b/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts
index c8343a30f3..6580e466f2 100644
--- a/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts
+++ b/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts
@@ -8,8 +8,6 @@ export const pageBreakConfig = {
type: "pageBreak" as const,
propSchema: {},
content: "none",
- isFileBlock: false,
- isSelectable: false,
} satisfies CustomBlockConfig;
export const pageBreakRender = () => {
const pageBreak = document.createElement("div");
diff --git a/packages/core/src/blocks/PageBreakBlockContent/schema.ts b/packages/core/src/blocks/PageBreakBlockContent/schema.ts
index 15ea54cbe5..4b111ae3fb 100644
--- a/packages/core/src/blocks/PageBreakBlockContent/schema.ts
+++ b/packages/core/src/blocks/PageBreakBlockContent/schema.ts
@@ -8,7 +8,7 @@ import { PageBreak } from "./PageBreakBlockContent.js";
export const pageBreakSchema = BlockNoteSchema.create({
blockSpecs: {
- pageBreak: PageBreak,
+ pageBreak: PageBreak as any,
},
});
diff --git a/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts b/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts
index af65e3d0df..9387ab85d7 100644
--- a/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts
+++ b/packages/core/src/blocks/VideoBlockContent/VideoBlockContent.ts
@@ -2,7 +2,7 @@ import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import {
BlockFromConfig,
createBlockSpec,
- FileBlockConfig,
+ // FileBlockConfig,
Props,
PropSchema,
} from "../../schema/index.js";
@@ -48,7 +48,7 @@ export const videoBlockConfig = {
content: "none",
isFileBlock: true,
fileBlockAccept: ["video/*"],
-} satisfies FileBlockConfig;
+} as any;
export const videoRender = (
block: BlockFromConfig,
diff --git a/packages/core/src/blocks/defaultBlockHelpers.ts b/packages/core/src/blocks/defaultBlockHelpers.ts
index 5f19cc052f..ccadf93e11 100644
--- a/packages/core/src/blocks/defaultBlockHelpers.ts
+++ b/packages/core/src/blocks/defaultBlockHelpers.ts
@@ -100,13 +100,13 @@ export const defaultBlockToHTML = <
// This is used when parsing blocks like list items and table cells, as they may
// contain multiple paragraphs that ProseMirror will not be able to handle
// properly.
-export function mergeParagraphs(element: HTMLElement) {
+export function mergeParagraphs(element: HTMLElement, separator = "
") {
const paragraphs = element.querySelectorAll("p");
if (paragraphs.length > 1) {
const firstParagraph = paragraphs[0];
for (let i = 1; i < paragraphs.length; i++) {
const paragraph = paragraphs[i];
- firstParagraph.innerHTML += "
" + paragraph.innerHTML;
+ firstParagraph.innerHTML += separator + paragraph.innerHTML;
paragraph.remove();
}
}
diff --git a/packages/core/src/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts
index 69ac5fdae9..c3db69fdaf 100644
--- a/packages/core/src/blocks/defaultBlockTypeGuards.ts
+++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts
@@ -1,201 +1,169 @@
import { CellSelection } from "prosemirror-tables";
import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
-import {
- BlockConfig,
- BlockFromConfig,
- BlockSchema,
- FileBlockConfig,
- InlineContentConfig,
- InlineContentSchema,
- StyleSchema,
-} from "../schema/index.js";
-import {
- Block,
- DefaultBlockSchema,
- DefaultInlineContentSchema,
- defaultBlockSchema,
- defaultInlineContentSchema,
-} from "./defaultBlocks.js";
-import { defaultProps } from "./defaultProps.js";
+import { BlockConfig, PropSchema, PropSpec } from "../schema/index.js";
+import { Block } from "./defaultBlocks.js";
import { Selection } from "prosemirror-state";
-export function checkDefaultBlockTypeInSchema<
- BlockType extends keyof DefaultBlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
+export function editorHasBlockWithType<
+ BType extends string,
+ Props extends
+ | PropSchema
+ | Record
+ | undefined = undefined,
>(
- blockType: BlockType,
- editor: BlockNoteEditor,
+ editor: BlockNoteEditor,
+ blockType: BType,
+ props?: Props,
): editor is BlockNoteEditor<
- { [K in BlockType]: DefaultBlockSchema[BlockType] },
- I,
- S
+ {
+ [BT in BType]: Props extends PropSchema
+ ? BlockConfig
+ : Props extends Record
+ ? BlockConfig<
+ BT,
+ {
+ [PN in keyof Props]: {
+ default: undefined;
+ type: Props[PN];
+ };
+ }
+ >
+ : BlockConfig;
+ },
+ any,
+ any
> {
- return (
- blockType in editor.schema.blockSchema &&
- editor.schema.blockSchema[blockType] === defaultBlockSchema[blockType]
- );
-}
+ if (!(blockType in editor.schema.blockSpecs)) {
+ return false;
+ }
-export function checkBlockTypeInSchema<
- BlockType extends string,
- Config extends BlockConfig,
->(
- blockType: BlockType,
- blockConfig: Config,
- editor: BlockNoteEditor,
-): editor is BlockNoteEditor<{ [T in BlockType]: Config }, any, any> {
- return (
- blockType in editor.schema.blockSchema &&
- editor.schema.blockSchema[blockType] === blockConfig
- );
-}
+ if (!props) {
+ return true;
+ }
-export function checkDefaultInlineContentTypeInSchema<
- InlineContentType extends keyof DefaultInlineContentSchema,
- B extends BlockSchema,
- S extends StyleSchema,
->(
- inlineContentType: InlineContentType,
- editor: BlockNoteEditor,
-): editor is BlockNoteEditor<
- B,
- { [K in InlineContentType]: DefaultInlineContentSchema[InlineContentType] },
- S
-> {
- return (
- inlineContentType in editor.schema.inlineContentSchema &&
- editor.schema.inlineContentSchema[inlineContentType] ===
- defaultInlineContentSchema[inlineContentType]
- );
-}
+ for (const [propName, propSpec] of Object.entries(props)) {
+ if (!(propName in editor.schema.blockSpecs[blockType].config.propSchema)) {
+ return false;
+ }
-export function checkInlineContentTypeInSchema<
- InlineContentType extends string,
- Config extends InlineContentConfig,
->(
- inlineContentType: InlineContentType,
- inlineContentConfig: Config,
- editor: BlockNoteEditor,
-): editor is BlockNoteEditor {
- return (
- inlineContentType in editor.schema.inlineContentSchema &&
- editor.schema.inlineContentSchema[inlineContentType] === inlineContentConfig
- );
-}
+ if (typeof propSpec === "string") {
+ if (
+ editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .default &&
+ typeof editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .default !== propSpec
+ ) {
+ return false;
+ }
-export function checkBlockIsDefaultType<
- BlockType extends keyof DefaultBlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
->(
- blockType: BlockType,
- block: Block,
- editor: BlockNoteEditor,
-): block is BlockFromConfig {
- return (
- block.type === blockType &&
- block.type in editor.schema.blockSchema &&
- checkDefaultBlockTypeInSchema(block.type, editor)
- );
-}
+ if (
+ editor.schema.blockSpecs[blockType].config.propSchema[propName].type &&
+ editor.schema.blockSpecs[blockType].config.propSchema[propName].type !==
+ propSpec
+ ) {
+ return false;
+ }
+ } else {
+ if (
+ editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .default !== propSpec.default
+ ) {
+ return false;
+ }
-export function checkBlockIsFileBlock<
- B extends BlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
->(
- block: Block,
- editor: BlockNoteEditor,
-): block is BlockFromConfig {
- return (
- (block.type in editor.schema.blockSchema &&
- editor.schema.blockSchema[block.type].isFileBlock) ||
- false
- );
-}
+ if (
+ editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .default === undefined &&
+ propSpec.default === undefined
+ ) {
+ if (
+ editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .type !== propSpec.type
+ ) {
+ return false;
+ }
+ }
-export function checkBlockIsFileBlockWithPreview<
- B extends BlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
->(
- block: Block,
- editor: BlockNoteEditor,
-): block is BlockFromConfig<
- FileBlockConfig & {
- propSchema: Required;
- },
- I,
- S
-> {
- return (
- (block.type in editor.schema.blockSchema &&
- editor.schema.blockSchema[block.type].isFileBlock &&
- "showPreview" in editor.schema.blockSchema[block.type].propSchema) ||
- false
- );
-}
+ if (
+ typeof editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .values !== typeof propSpec.values
+ ) {
+ return false;
+ }
+
+ if (
+ typeof editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .values === "object" &&
+ typeof propSpec.values === "object"
+ ) {
+ if (
+ editor.schema.blockSpecs[blockType].config.propSchema[propName].values
+ .length !== propSpec.values.length
+ ) {
+ return false;
+ }
+
+ for (
+ let i = 0;
+ i <
+ editor.schema.blockSpecs[blockType].config.propSchema[propName].values
+ .length;
+ i++
+ ) {
+ if (
+ editor.schema.blockSpecs[blockType].config.propSchema[propName]
+ .values[i] !== propSpec.values[i]
+ ) {
+ return false;
+ }
+ }
+ }
+ }
+ }
-export function checkBlockIsFileBlockWithPlaceholder<
- B extends BlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
->(block: Block, editor: BlockNoteEditor) {
- const config = editor.schema.blockSchema[block.type];
- return config.isFileBlock && !block.props.url;
+ return true;
}
-export function checkBlockTypeHasDefaultProp<
- Prop extends keyof typeof defaultProps,
- I extends InlineContentSchema,
- S extends StyleSchema,
+export function blockHasType<
+ BType extends string,
+ Props extends
+ | PropSchema
+ | Record
+ | undefined = undefined,
>(
- prop: Prop,
- blockType: string,
- editor: BlockNoteEditor,
-): editor is BlockNoteEditor<
+ block: Block,
+ editor: BlockNoteEditor,
+ blockType: BType,
+ props?: Props,
+): block is Block<
{
- [BT in string]: {
- type: BT;
- propSchema: {
- [P in Prop]: (typeof defaultProps)[P];
- };
- content: "table" | "inline" | "none";
- };
+ [BT in BType]: Props extends PropSchema
+ ? BlockConfig
+ : Props extends Record
+ ? BlockConfig<
+ BT,
+ {
+ [PN in keyof Props]: PropSpec<
+ Props[PN] extends "boolean"
+ ? boolean
+ : Props[PN] extends "number"
+ ? number
+ : Props[PN] extends "string"
+ ? string
+ : never
+ >;
+ }
+ >
+ : BlockConfig;
},
- I,
- S
+ any,
+ any
> {
return (
- blockType in editor.schema.blockSchema &&
- prop in editor.schema.blockSchema[blockType].propSchema &&
- editor.schema.blockSchema[blockType].propSchema[prop] === defaultProps[prop]
+ editorHasBlockWithType(editor, blockType, props) && block.type === blockType
);
}
-export function checkBlockHasDefaultProp<
- Prop extends keyof typeof defaultProps,
- I extends InlineContentSchema,
- S extends StyleSchema,
->(
- prop: Prop,
- block: Block,
- editor: BlockNoteEditor,
-): block is BlockFromConfig<
- {
- type: string;
- propSchema: {
- [P in Prop]: (typeof defaultProps)[P];
- };
- content: "table" | "inline" | "none";
- },
- I,
- S
-> {
- return checkBlockTypeHasDefaultProp(prop, block.type, editor);
-}
-
export function isTableCellSelection(
selection: Selection,
): selection is CellSelection {
diff --git a/packages/core/src/blocks/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts
index 5c1c74da3e..9734548368 100644
--- a/packages/core/src/blocks/defaultBlocks.ts
+++ b/packages/core/src/blocks/defaultBlocks.ts
@@ -3,58 +3,85 @@ 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 {
+ audio,
+ bulletListItem,
+ checkListItem,
+ codeBlock,
+ file,
+ heading,
+ image,
+ numberedListItem,
+ pageBreak,
+ paragraph,
+ quote,
+ toggleListItem,
+ video,
+} from "../blks/index.js";
import { BackgroundColor } from "../extensions/BackgroundColor/BackgroundColorMark.js";
import { TextColor } from "../extensions/TextColor/TextColorMark.js";
import {
+ BlockConfig,
+ BlockDefinition,
BlockNoDefaults,
BlockSchema,
- BlockSpecs,
InlineContentSchema,
InlineContentSpecs,
PartialBlockNoDefaults,
StyleSchema,
StyleSpecs,
createStyleSpecFromTipTapMark,
- getBlockSchemaFromSpecs,
getInlineContentSchemaFromSpecs,
getStyleSchemaFromSpecs,
} from "../schema/index.js";
-
-import { AudioBlock } from "./AudioBlockContent/AudioBlockContent.js";
-import { CodeBlock } from "./CodeBlockContent/CodeBlockContent.js";
-import { FileBlock } from "./FileBlockContent/FileBlockContent.js";
-import { Heading } from "./HeadingBlockContent/HeadingBlockContent.js";
-import { ImageBlock } from "./ImageBlockContent/ImageBlockContent.js";
-import { ToggleListItem } from "./ListItemBlockContent/ToggleListItemBlockContent/ToggleListItemBlockContent.js";
-import { BulletListItem } from "./ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.js";
-import { CheckListItem } from "./ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.js";
-import { NumberedListItem } from "./ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.js";
-import { Paragraph } from "./ParagraphBlockContent/ParagraphBlockContent.js";
-import { Quote } from "./QuoteBlockContent/QuoteBlockContent.js";
import { Table } from "./TableBlockContent/TableBlockContent.js";
-import { VideoBlock } from "./VideoBlockContent/VideoBlockContent.js";
export const defaultBlockSpecs = {
- paragraph: Paragraph,
- heading: Heading,
- quote: Quote,
- codeBlock: CodeBlock,
- toggleListItem: ToggleListItem,
- bulletListItem: BulletListItem,
- numberedListItem: NumberedListItem,
- checkListItem: CheckListItem,
- table: Table,
- file: FileBlock,
- image: ImageBlock,
- video: VideoBlock,
- audio: AudioBlock,
-} satisfies BlockSpecs;
-
-export const defaultBlockSchema = getBlockSchemaFromSpecs(defaultBlockSpecs);
+ paragraph: paragraph.definition(),
+ audio: audio.definition(),
+ bulletListItem: bulletListItem.definition(),
+ checkListItem: checkListItem.definition(),
+ codeBlock: codeBlock.definition(),
+ heading: heading.definition(),
+ numberedListItem: numberedListItem.definition(),
+ pageBreak: pageBreak.definition(),
+ quote: quote.definition(),
+ toggleListItem: toggleListItem.definition(),
+ file: file.definition(),
+ image: image.definition(),
+ video: video.definition(),
+ table: Table as unknown as BlockDefinition<
+ "table",
+ {
+ textColor: {
+ default: "default";
+ };
+ }
+ > & {
+ config: {
+ content: "table";
+ };
+ },
+} as const;
// underscore is used that in case a user overrides DefaultBlockSchema,
// they can still access the original default block schema
-export type _DefaultBlockSchema = typeof defaultBlockSchema;
+export type _DefaultBlockSchema = Omit<
+ {
+ [K in keyof typeof defaultBlockSpecs]: (typeof defaultBlockSpecs)[K]["config"];
+ },
+ "table"
+> & {
+ table: BlockConfig<
+ "table",
+ {
+ textColor: {
+ default: "default";
+ };
+ },
+ "table"
+ >;
+};
export type DefaultBlockSchema = _DefaultBlockSchema;
export const defaultStyleSpecs = {
diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts
index ecdabb4272..6890f6acf8 100644
--- a/packages/core/src/editor/BlockNoteEditor.ts
+++ b/packages/core/src/editor/BlockNoteEditor.ts
@@ -3,6 +3,7 @@ import {
EditorOptions,
Extension,
getSchema,
+ InputRule,
isNodeSelection,
Mark,
posToDOMRect,
@@ -23,7 +24,10 @@ import {
unnestBlock,
} from "../api/blockManipulation/commands/nestBlock/nestBlock.js";
import { removeAndInsertBlocks } from "../api/blockManipulation/commands/replaceBlocks/replaceBlocks.js";
-import { updateBlock } from "../api/blockManipulation/commands/updateBlock/updateBlock.js";
+import {
+ updateBlock,
+ updateBlockTr,
+} from "../api/blockManipulation/commands/updateBlock/updateBlock.js";
import {
getBlock,
getNextBlock,
@@ -84,7 +88,7 @@ import { TextCursorPosition } from "./cursorPositionTypes.js";
import { Selection } from "./selectionTypes.js";
import { transformPasted } from "./transformPasted.js";
-import { checkDefaultBlockTypeInSchema } from "../blocks/defaultBlockTypeGuards.js";
+import { editorHasBlockWithType } from "../blocks/defaultBlockTypeGuards.js";
import { BlockNoteSchema } from "./BlockNoteSchema.js";
import {
BlockNoteTipTapEditor,
@@ -121,6 +125,7 @@ import { BlockNoteExtension } from "./BlockNoteExtension.js";
import "../style.css";
import { BlockChangePlugin } from "../extensions/BlockChange/BlockChangePlugin.js";
+import { getBlockInfoFromTransaction } from "../api/getBlockInfoFromPos.js";
/**
* A factory function that returns a BlockNoteExtension
@@ -662,14 +667,14 @@ export class BlockNoteEditor<
// @ts-ignore
this.schema = newOptions.schema;
- this.blockImplementations = newOptions.schema.blockSpecs;
+ this.blockImplementations = newOptions.schema.blockSpecs as any;
this.inlineContentImplementations = newOptions.schema.inlineContentSpecs;
this.styleImplementations = newOptions.schema.styleSpecs;
this.extensions = getBlockNoteExtensions({
editor: this,
domAttributes: newOptions.domAttributes || {},
- blockSpecs: this.schema.blockSpecs,
+ blockSpecs: this.schema.blockSpecs as any,
styleSpecs: this.schema.styleSpecs,
inlineContentSpecs: this.schema.inlineContentSpecs,
collaboration: newOptions.collaboration,
@@ -677,7 +682,7 @@ export class BlockNoteEditor<
disableExtensions: newOptions.disableExtensions,
setIdAttribute: newOptions.setIdAttribute,
animations: newOptions.animations ?? true,
- tableHandles: checkDefaultBlockTypeInSchema("table", this),
+ tableHandles: editorHasBlockWithType(this, "table"),
dropCursor: this.options.dropCursor ?? dropCursor,
placeholders: newOptions.placeholders,
tabBehavior: newOptions.tabBehavior,
@@ -696,7 +701,7 @@ export class BlockNoteEditor<
// factory
ext = ext(this);
}
- const key = (ext.constructor as any).key();
+ const key = (ext as any).key ?? (ext.constructor as any).key();
if (!key) {
throw new Error(
`Extension ${ext.constructor.name} does not have a key method`,
@@ -798,31 +803,94 @@ export class BlockNoteEditor<
initialContent,
);
}
-
+ const blockExtensions = Object.fromEntries(
+ Object.values(this.schema.blockSpecs)
+ .map((block) => (block as any).extensions as any)
+ .filter((ext) => ext !== undefined)
+ .flat()
+ .map((ext) => [ext.key ?? ext.constructor.key(), ext]),
+ );
const tiptapExtensions = [
- ...Object.entries(this.extensions).map(([key, ext]) => {
- if (
- ext instanceof Extension ||
- ext instanceof TipTapNode ||
- ext instanceof Mark
- ) {
- // tiptap extension
- return ext;
- }
+ ...Object.entries({ ...this.extensions, ...blockExtensions }).map(
+ ([key, ext]) => {
+ if (
+ ext instanceof Extension ||
+ ext instanceof TipTapNode ||
+ ext instanceof Mark
+ ) {
+ // tiptap extension
+ return ext;
+ }
- if (ext instanceof BlockNoteExtension && !ext.plugins.length) {
- return undefined;
- }
+ if (ext instanceof BlockNoteExtension) {
+ if (
+ !ext.plugins.length &&
+ !ext.keyboardShortcuts &&
+ !ext.inputRules
+ ) {
+ return undefined;
+ }
+ // "blocknote" extensions (prosemirror plugins)
+ return Extension.create({
+ name: key,
+ priority: ext.priority,
+ addProseMirrorPlugins: () => ext.plugins,
+ // TODO maybe collect all input rules from all extensions into one plugin
+ // TODO consider using the prosemirror-inputrules package instead
+ addInputRules: ext.inputRules
+ ? () =>
+ ext.inputRules!.map(
+ (inputRule) =>
+ new InputRule({
+ find: inputRule.find,
+ handler: ({ range, match, state }) => {
+ const replaceWith = inputRule.replace({
+ match,
+ range,
+ editor: this,
+ });
+ if (replaceWith) {
+ const blockInfo = getBlockInfoFromTransaction(
+ state.tr,
+ );
+
+ // TODO this is weird, why do we need it?
+ if (
+ blockInfo.isBlockContainer &&
+ blockInfo.blockContent.node.type.spec
+ .content === "inline*"
+ ) {
+ updateBlockTr(
+ state.tr,
+ blockInfo.bnBlock.beforePos,
+ replaceWith,
+ range.from,
+ range.to,
+ );
+ return undefined;
+ }
+ }
+ return null;
+ },
+ }),
+ )
+ : undefined,
+ addKeyboardShortcuts: ext.keyboardShortcuts
+ ? () => {
+ return Object.fromEntries(
+ Object.entries(ext.keyboardShortcuts!).map(
+ ([key, value]) => [key, () => value({ editor: this })],
+ ),
+ );
+ }
+ : undefined,
+ });
+ }
- // "blocknote" extensions (prosemirror plugins)
- return Extension.create({
- name: key,
- priority: ext.priority,
- addProseMirrorPlugins: () => ext.plugins,
- });
- }),
+ return undefined;
+ },
+ ),
].filter((ext): ext is Extension => ext !== undefined);
-
const tiptapOptions: BlockNoteTipTapEditorOptions = {
...blockNoteTipTapOptions,
...newOptions._tiptapOptions,
diff --git a/packages/core/src/editor/BlockNoteExtension.ts b/packages/core/src/editor/BlockNoteExtension.ts
index 0281943575..737f7e3fd1 100644
--- a/packages/core/src/editor/BlockNoteExtension.ts
+++ b/packages/core/src/editor/BlockNoteExtension.ts
@@ -1,6 +1,9 @@
import { Plugin } from "prosemirror-state";
import { EventEmitter } from "../util/EventEmitter.js";
+import { BlockNoteEditor } from "./BlockNoteEditor.js";
+import { PartialBlockNoDefaults } from "../schema/index.js";
+
export abstract class BlockNoteExtension<
TEvent extends Record = any,
> extends EventEmitter {
@@ -23,4 +26,43 @@ export abstract class BlockNoteExtension<
// Allow subclasses to have constructors with parameters
// without this, we can't easily implement BlockNoteEditor.extension(MyExtension) pattern
}
+
+ /**
+ * Input rules for the block
+ */
+ public inputRules?: InputRule[];
+
+ public keyboardShortcuts?: Record<
+ string,
+ (ctx: {
+ // TODO types
+ editor: BlockNoteEditor;
+ }) => boolean
+ >;
}
+
+export type InputRule = {
+ /**
+ * The regex to match when to trigger the input rule
+ */
+ find: RegExp;
+ /**
+ * The function to call when the input rule is matched
+ * @returns undefined if the input rule should not be triggered, or an object with the type and props to update the block
+ */
+ replace: (props: {
+ /**
+ * The result of the regex match
+ */
+ match: RegExpMatchArray;
+ // TODO this will be a Point, when we have the Location API
+ /**
+ * The range of the text that was matched
+ */
+ range: { from: number; to: number };
+ /**
+ * The editor instance
+ */
+ editor: BlockNoteEditor;
+ }) => undefined | PartialBlockNoDefaults;
+};
diff --git a/packages/core/src/editor/BlockNoteSchema.ts b/packages/core/src/editor/BlockNoteSchema.ts
index e9af493884..5dfac1a79b 100644
--- a/packages/core/src/editor/BlockNoteSchema.ts
+++ b/packages/core/src/editor/BlockNoteSchema.ts
@@ -3,70 +3,51 @@ import {
defaultInlineContentSpecs,
defaultStyleSpecs,
} from "../blocks/defaultBlocks.js";
-import type {
- BlockNoDefaults,
- PartialBlockNoDefaults,
-} from "../schema/blocks/types.js";
import {
+ BlockNoDefaults,
BlockSchema,
- BlockSchemaFromSpecs,
- BlockSpecs,
InlineContentSchema,
InlineContentSchemaFromSpecs,
InlineContentSpecs,
+ PartialBlockNoDefaults,
StyleSchema,
StyleSchemaFromSpecs,
StyleSpecs,
- getBlockSchemaFromSpecs,
- getInlineContentSchemaFromSpecs,
- getStyleSchemaFromSpecs,
} from "../schema/index.js";
-import type { BlockNoteEditor } from "./BlockNoteEditor.js";
+import { BlockNoteEditor } from "./BlockNoteEditor.js";
-function removeUndefined | undefined>(obj: T): T {
- if (!obj) {
- return obj;
- }
- return Object.fromEntries(
- Object.entries(obj).filter(([, value]) => value !== undefined),
- ) as T;
-}
+import { BlockSpecOf, CustomBlockNoteSchema } from "./CustomSchema.js";
export class BlockNoteSchema<
- BSchema extends BlockSchema,
+ BSpecs extends BlockSchema,
ISchema extends InlineContentSchema,
SSchema extends StyleSchema,
-> {
- public readonly blockSpecs: BlockSpecs;
- public readonly inlineContentSpecs: InlineContentSpecs;
- public readonly styleSpecs: StyleSpecs;
-
- public readonly blockSchema: BSchema;
- public readonly inlineContentSchema: ISchema;
- public readonly styleSchema: SSchema;
-
+> extends CustomBlockNoteSchema {
// Helper so that you can use typeof schema.BlockNoteEditor
- public readonly BlockNoteEditor: BlockNoteEditor =
+ public readonly BlockNoteEditor: BlockNoteEditor =
"only for types" as any;
- public readonly Block: BlockNoDefaults =
+ public readonly Block: BlockNoDefaults =
"only for types" as any;
public readonly PartialBlock: PartialBlockNoDefaults<
- BSchema,
+ any,
+ // BSchema,
ISchema,
SSchema
> = "only for types" as any;
public static create<
- BSpecs extends BlockSpecs = typeof defaultBlockSpecs,
+ BSpecs extends BlockSchema = {
+ [key in keyof typeof defaultBlockSpecs]: (typeof defaultBlockSpecs)[key]["config"];
+ },
ISpecs extends InlineContentSpecs = typeof defaultInlineContentSpecs,
SSpecs extends StyleSpecs = typeof defaultStyleSpecs,
>(options?: {
/**
* A list of custom block types that should be available in the editor.
*/
- blockSpecs?: BSpecs;
+ blockSpecs?: BlockSpecOf;
/**
* A list of custom InlineContent types that should be available in the editor.
*/
@@ -77,31 +58,14 @@ export class BlockNoteSchema<
styleSpecs?: SSpecs;
}) {
return new BlockNoteSchema<
- BlockSchemaFromSpecs,
+ BSpecs,
InlineContentSchemaFromSpecs,
StyleSchemaFromSpecs
- >(options);
- // as BlockNoteSchema<
- // BlockSchemaFromSpecs,
- // InlineContentSchemaFromSpecs,
- // StyleSchemaFromSpecs
- // >;
- }
-
- constructor(opts?: {
- blockSpecs?: BlockSpecs;
- inlineContentSpecs?: InlineContentSpecs;
- styleSpecs?: StyleSpecs;
- }) {
- this.blockSpecs = removeUndefined(opts?.blockSpecs) || defaultBlockSpecs;
- this.inlineContentSpecs =
- removeUndefined(opts?.inlineContentSpecs) || defaultInlineContentSpecs;
- this.styleSpecs = removeUndefined(opts?.styleSpecs) || defaultStyleSpecs;
-
- this.blockSchema = getBlockSchemaFromSpecs(this.blockSpecs) as any;
- this.inlineContentSchema = getInlineContentSchemaFromSpecs(
- this.inlineContentSpecs,
- ) as any;
- this.styleSchema = getStyleSchemaFromSpecs(this.styleSpecs) as any;
+ >({
+ blockSpecs: options?.blockSpecs ?? (defaultBlockSpecs as any),
+ inlineContentSpecs:
+ options?.inlineContentSpecs ?? defaultInlineContentSpecs,
+ styleSpecs: options?.styleSpecs ?? defaultStyleSpecs,
+ });
}
}
diff --git a/packages/core/src/editor/CustomSchema.ts b/packages/core/src/editor/CustomSchema.ts
new file mode 100644
index 0000000000..295e7b047b
--- /dev/null
+++ b/packages/core/src/editor/CustomSchema.ts
@@ -0,0 +1,119 @@
+import {
+ BlockDefinition,
+ BlockSchema,
+ InlineContentSchema,
+ InlineContentSpecs,
+ PropSchema,
+ StyleSchema,
+ StyleSpecs,
+ createBlockSpec,
+ getInlineContentSchemaFromSpecs,
+ getStyleSchemaFromSpecs,
+} from "../schema/index.js";
+import { createDependencyGraph, toposortReverse } from "../util/topo-sort.js";
+
+function removeUndefined | undefined>(obj: T): T {
+ if (!obj) {
+ return obj;
+ }
+ return Object.fromEntries(
+ Object.entries(obj).filter(([, value]) => value !== undefined),
+ ) as T;
+}
+
+export type BlockSpecOf = {
+ [key in keyof BSpecs]: key extends string
+ ? BlockDefinition
+ : never;
+};
+
+export class CustomBlockNoteSchema<
+ BSpecs extends BlockSchema,
+ ISchema extends InlineContentSchema,
+ SSchema extends StyleSchema,
+> {
+ public readonly inlineContentSpecs: InlineContentSpecs;
+ public readonly styleSpecs: StyleSpecs;
+ public readonly blockSpecs: BlockSpecOf;
+
+ public readonly blockSchema: BSpecs;
+ public readonly inlineContentSchema: ISchema;
+ public readonly styleSchema: SSchema;
+
+ constructor(opts: {
+ blockSpecs: BlockSpecOf;
+ inlineContentSpecs: InlineContentSpecs;
+ styleSpecs: StyleSpecs;
+ }) {
+ this.blockSpecs = this.initBlockSpecs(opts.blockSpecs);
+ this.blockSchema = Object.fromEntries(
+ Object.entries(this.blockSpecs).map(([key, blockDef]) => {
+ return [key, blockDef.config];
+ }),
+ ) as any;
+ this.inlineContentSpecs = removeUndefined(opts.inlineContentSpecs);
+ this.styleSpecs = removeUndefined(opts.styleSpecs);
+
+ this.inlineContentSchema = getInlineContentSchemaFromSpecs(
+ this.inlineContentSpecs,
+ ) as any;
+ this.styleSchema = getStyleSchemaFromSpecs(this.styleSpecs) as any;
+ }
+
+ private initBlockSpecs(specs: BlockSpecOf): BlockSpecOf {
+ const dag = createDependencyGraph();
+ const defaultSet = new Set();
+ dag.set("default", defaultSet);
+
+ for (const [key, specDef] of Object.entries(specs)) {
+ if (specDef.implementation.runsBefore) {
+ dag.set(key, new Set(specDef.implementation.runsBefore));
+ } else {
+ defaultSet.add(key);
+ }
+ }
+ const sortedSpecs = toposortReverse(dag);
+ const defaultIndex = sortedSpecs.findIndex((set) => set.has("default"));
+
+ /**
+ * The priority of a block is described relative to the "default" block (an arbitrary block which can be used as the reference)
+ *
+ * Since blocks are topologically sorted, we can see what their relative position is to the "default" block
+ * Each layer away from the default block is 10 priority points (arbitrarily chosen)
+ * The default block is fixed at 101 (1 point higher than any tiptap extension, giving priority to custom blocks than any defaults)
+ *
+ * This is a bit of a hack, but it's a simple way to ensure that custom blocks are always rendered with higher priority than default blocks
+ * and that custom blocks are rendered in the order they are defined in the schema
+ */
+ const getPriority = (key: string) => {
+ const index = sortedSpecs.findIndex((set) => set.has(key));
+ // the default index should map to 101
+ // one before the default index is 91
+ // one after is 111
+ return 91 + (index + defaultIndex) * 10;
+ };
+
+ return Object.fromEntries(
+ Object.entries(specs).map(
+ ([key, blockDef]: [string, BlockDefinition]) => {
+ return [
+ key,
+ Object.assign(
+ {
+ extensions: blockDef.extensions,
+ },
+ // TODO annoying hack to get tables to work
+ blockDef.config.type === "table"
+ ? blockDef
+ : createBlockSpec(
+ blockDef.config as any,
+ blockDef.implementation as any,
+ getPriority(key),
+ ),
+ ),
+ ];
+ },
+ ),
+ ) as unknown as BlockSpecOf;
+ }
+}
diff --git a/packages/core/src/extensions/FilePanel/FilePanelPlugin.ts b/packages/core/src/extensions/FilePanel/FilePanelPlugin.ts
index 5b42b866d4..33b6e3e152 100644
--- a/packages/core/src/extensions/FilePanel/FilePanelPlugin.ts
+++ b/packages/core/src/extensions/FilePanel/FilePanelPlugin.ts
@@ -7,7 +7,7 @@ import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js";
import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js";
import type {
BlockFromConfig,
- FileBlockConfig,
+ // FileBlockConfig,
InlineContentSchema,
StyleSchema,
} from "../../schema/index.js";
@@ -17,7 +17,7 @@ export type FilePanelState<
S extends StyleSchema,
> = UiElementPosition & {
// TODO: This typing is not quite right (children should be from BSchema)
- block: BlockFromConfig;
+ block: BlockFromConfig;
};
export class FilePanelView
@@ -27,11 +27,7 @@ export class FilePanelView
public emitUpdate: () => void;
constructor(
- private readonly editor: BlockNoteEditor<
- Record,
- I,
- S
- >,
+ private readonly editor: BlockNoteEditor, I, S>,
private readonly pluginKey: PluginKey>,
private readonly pmView: EditorView,
emitUpdate: (state: FilePanelState) => void,
@@ -145,17 +141,17 @@ export class FilePanelProsemirrorPlugin<
private view: FilePanelView | undefined;
- constructor(editor: BlockNoteEditor, I, S>) {
+ constructor(editor: BlockNoteEditor, I, S>) {
super();
this.addProsemirrorPlugin(
new Plugin<{
- block: BlockFromConfig | undefined;
+ block: BlockFromConfig | undefined;
}>({
key: filePanelPluginKey,
view: (editorView) => {
this.view = new FilePanelView(
editor,
- filePanelPluginKey,
+ filePanelPluginKey as any,
editorView,
(state) => {
this.emit("update", state);
diff --git a/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
index 902fb9bc21..e86cae7f66 100644
--- a/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
+++ b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
@@ -434,6 +434,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
]);
const handleEnter = (withShift = false) => {
+ console.log("handleEnter");
return this.editor.commands.first(({ commands, tr }) => [
// Removes a level of nesting if the block is empty & indented, while the selection is also empty & at the start
// of the block.
@@ -528,6 +529,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (dispatch) {
const newBlock =
state.schema.nodes["blockContainer"].createAndFill()!;
+ console.log(newBlock);
state.tr
.insert(newBlockInsertionPos, newBlock)
diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts
index 4f02c94625..85ab84ded2 100644
--- a/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts
+++ b/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts
@@ -1,6 +1,6 @@
import type { Emoji, EmojiMartData } from "@emoji-mart/data";
-import { checkDefaultInlineContentTypeInSchema } from "../../blocks/defaultBlockTypeGuards.js";
+import { defaultInlineContentSchema } from "../../blocks/defaultBlocks.js";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import {
BlockSchema,
@@ -54,7 +54,11 @@ export async function getDefaultEmojiPickerItems<
editor: BlockNoteEditor,
query: string,
): Promise {
- if (!checkDefaultInlineContentTypeInSchema("text", editor)) {
+ if (
+ !("text" in editor.schema.inlineContentSchema) ||
+ editor.schema.inlineContentSchema["text"] !==
+ defaultInlineContentSchema["text"]
+ ) {
return [];
}
diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts
index bcc95c83a7..3ba14d2b81 100644
--- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts
+++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts
@@ -1,7 +1,7 @@
import { Block, PartialBlock } from "../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
-import { checkDefaultBlockTypeInSchema } from "../../blocks/defaultBlockTypeGuards.js";
+import { editorHasBlockWithType } from "../../blocks/defaultBlockTypeGuards.js";
import {
BlockSchema,
InlineContentSchema,
@@ -87,7 +87,7 @@ export function getDefaultSlashMenuItems<
>(editor: BlockNoteEditor) {
const items: DefaultSuggestionItem[] = [];
- if (checkDefaultBlockTypeInSchema("heading", editor)) {
+ if (editorHasBlockWithType(editor, "heading", { level: "number" })) {
items.push(
{
onItemClick: () => {
@@ -125,7 +125,7 @@ export function getDefaultSlashMenuItems<
);
}
- if (checkDefaultBlockTypeInSchema("quote", editor)) {
+ if (editorHasBlockWithType(editor, "quote")) {
items.push({
onItemClick: () => {
insertOrUpdateBlock(editor, {
@@ -137,7 +137,7 @@ export function getDefaultSlashMenuItems<
});
}
- if (checkDefaultBlockTypeInSchema("toggleListItem", editor)) {
+ if (editorHasBlockWithType(editor, "toggleListItem")) {
items.push({
onItemClick: () => {
insertOrUpdateBlock(editor, {
@@ -150,7 +150,7 @@ export function getDefaultSlashMenuItems<
});
}
- if (checkDefaultBlockTypeInSchema("numberedListItem", editor)) {
+ if (editorHasBlockWithType(editor, "numberedListItem")) {
items.push({
onItemClick: () => {
insertOrUpdateBlock(editor, {
@@ -163,7 +163,7 @@ export function getDefaultSlashMenuItems<
});
}
- if (checkDefaultBlockTypeInSchema("bulletListItem", editor)) {
+ if (editorHasBlockWithType(editor, "bulletListItem")) {
items.push({
onItemClick: () => {
insertOrUpdateBlock(editor, {
@@ -176,7 +176,7 @@ export function getDefaultSlashMenuItems<
});
}
- if (checkDefaultBlockTypeInSchema("checkListItem", editor)) {
+ if (editorHasBlockWithType(editor, "checkListItem")) {
items.push({
onItemClick: () => {
insertOrUpdateBlock(editor, {
@@ -189,7 +189,7 @@ export function getDefaultSlashMenuItems<
});
}
- if (checkDefaultBlockTypeInSchema("paragraph", editor)) {
+ if (editorHasBlockWithType(editor, "paragraph")) {
items.push({
onItemClick: () => {
insertOrUpdateBlock(editor, {
@@ -202,7 +202,7 @@ export function getDefaultSlashMenuItems<
});
}
- if (checkDefaultBlockTypeInSchema("codeBlock", editor)) {
+ if (editorHasBlockWithType(editor, "codeBlock")) {
items.push({
onItemClick: () => {
insertOrUpdateBlock(editor, {
@@ -215,7 +215,7 @@ export function getDefaultSlashMenuItems<
});
}
- if (checkDefaultBlockTypeInSchema("table", editor)) {
+ if (editorHasBlockWithType(editor, "table")) {
items.push({
onItemClick: () => {
insertOrUpdateBlock(editor, {
@@ -230,7 +230,7 @@ export function getDefaultSlashMenuItems<
cells: ["", "", ""],
},
],
- },
+ } as any,
});
},
badge: undefined,
@@ -239,7 +239,7 @@ export function getDefaultSlashMenuItems<
});
}
- if (checkDefaultBlockTypeInSchema("image", editor)) {
+ if (editorHasBlockWithType(editor, "image", { url: "string" })) {
items.push({
onItemClick: () => {
const insertedBlock = insertOrUpdateBlock(editor, {
@@ -258,7 +258,7 @@ export function getDefaultSlashMenuItems<
});
}
- if (checkDefaultBlockTypeInSchema("video", editor)) {
+ if (editorHasBlockWithType(editor, "video", { url: "string" })) {
items.push({
onItemClick: () => {
const insertedBlock = insertOrUpdateBlock(editor, {
@@ -277,7 +277,7 @@ export function getDefaultSlashMenuItems<
});
}
- if (checkDefaultBlockTypeInSchema("audio", editor)) {
+ if (editorHasBlockWithType(editor, "audio", { url: "string" })) {
items.push({
onItemClick: () => {
const insertedBlock = insertOrUpdateBlock(editor, {
@@ -296,7 +296,7 @@ export function getDefaultSlashMenuItems<
});
}
- if (checkDefaultBlockTypeInSchema("file", editor)) {
+ if (editorHasBlockWithType(editor, "file", { url: "string" })) {
items.push({
onItemClick: () => {
const insertedBlock = insertOrUpdateBlock(editor, {
@@ -315,7 +315,12 @@ export function getDefaultSlashMenuItems<
});
}
- if (checkDefaultBlockTypeInSchema("heading", editor)) {
+ if (
+ editorHasBlockWithType(editor, "heading", {
+ level: "number",
+ isToggleable: "boolean",
+ })
+ ) {
items.push(
{
onItemClick: () => {
diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
index 388cb47f36..a8e444db71 100644
--- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
+++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
@@ -27,7 +27,7 @@ import {
import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js";
import { getNodeById } from "../../api/nodeUtil.js";
import {
- checkBlockIsDefaultType,
+ editorHasBlockWithType,
isTableCellSelection,
} from "../../blocks/defaultBlockTypeGuards.js";
import { DefaultBlockSchema } from "../../blocks/defaultBlocks.js";
@@ -278,7 +278,7 @@ export class TableHandlesView<
this.editor.schema.styleSchema,
);
- if (checkBlockIsDefaultType("table", block, this.editor)) {
+ if (editorHasBlockWithType(this.editor, "table")) {
this.tablePos = pmNodeInfo.posBeforeNode + 1;
tableBlock = block;
}
diff --git a/packages/core/src/extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/UniqueID/UniqueID.ts
index b0f5be9827..23f6591256 100644
--- a/packages/core/src/extensions/UniqueID/UniqueID.ts
+++ b/packages/core/src/extensions/UniqueID/UniqueID.ts
@@ -134,19 +134,12 @@ const UniqueID = Extension.create({
new Plugin({
key: new PluginKey("uniqueID"),
appendTransaction: (transactions, oldState, newState) => {
- // console.log("appendTransaction");
const docChanges =
transactions.some((transaction) => transaction.docChanged) &&
!oldState.doc.eq(newState.doc);
const filterTransactions =
this.options.filterTransaction &&
- transactions.some((tr) => {
- let _a, _b;
- return !((_b = (_a = this.options).filterTransaction) === null ||
- _b === void 0
- ? void 0
- : _b.call(_a, tr));
- });
+ transactions.some((tr) => !this.options.filterTransaction?.(tr));
if (!docChanges || filterTransactions) {
return;
}
@@ -172,16 +165,14 @@ const UniqueID = Extension.create({
.map(({ node }) => node.attrs[attributeName])
.filter((id) => id !== null);
const duplicatedNewIds = findDuplicates(newIds);
+
newNodes.forEach(({ node, pos }) => {
- let _a;
// instead of checking `node.attrs[attributeName]` directly
// we look at the current state of the node within `tr.doc`.
// this helps to prevent adding new ids to the same node
// if the node changed multiple times within one transaction
- const id =
- (_a = tr.doc.nodeAt(pos)) === null || _a === void 0
- ? void 0
- : _a.attrs[attributeName];
+ const id = tr.doc.nodeAt(pos)?.attrs[attributeName];
+
if (id === null) {
// edge case, when using collaboration, yjs will set the id to null in `_forceRerender`
// when loading the editor
@@ -230,6 +221,8 @@ const UniqueID = Extension.create({
if (!tr.steps.length) {
return;
}
+ // mark the transaction as having been processed by the uniqueID plugin
+ tr.setMeta("uniqueID", true);
return tr;
},
// we register a global drag handler to track the current drag source element
diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts
index 8a5bc3cb96..289687e39b 100644
--- a/packages/core/src/schema/blocks/createSpec.ts
+++ b/packages/core/src/schema/blocks/createSpec.ts
@@ -1,6 +1,7 @@
import { Editor } from "@tiptap/core";
-import { TagParseRule } from "@tiptap/pm/model";
+import { DOMParser, Fragment, Schema, TagParseRule } from "@tiptap/pm/model";
import { NodeView, ViewMutationRecord } from "@tiptap/pm/view";
+import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { InlineContentSchema } from "../inlineContent/types.js";
import { StyleSchema } from "../styles/types.js";
@@ -13,10 +14,13 @@ import {
} from "./internal.js";
import {
BlockConfig,
+ BlockDefinition,
BlockFromConfig,
+ BlockImplementation,
BlockSchemaWithBlock,
PartialBlockFromConfig,
} from "./types.js";
+import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js";
// restrict content to "inline" and "none" only
export type CustomBlockConfig = BlockConfig & {
@@ -28,6 +32,9 @@ export type CustomBlockImplementation<
I extends InlineContentSchema,
S extends StyleSchema,
> = {
+ /**
+ * A function that converts the block into a DOM element
+ */
render: (
/**
* The custom block to render
@@ -42,26 +49,44 @@ export type CustomBlockImplementation<
// (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;
+ dom: HTMLElement | DocumentFragment;
contentDOM?: HTMLElement;
ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
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?
+
+ /**
+ * Exports block to external HTML. If not defined, the output will be the same
+ * as `render(...).dom`.
+ */
toExternalHTML?: (
block: BlockFromConfig,
editor: BlockNoteEditor, I, S>,
- ) => {
- dom: HTMLElement;
- contentDOM?: HTMLElement;
- };
+ ) =>
+ | {
+ dom: HTMLElement;
+ contentDOM?: HTMLElement;
+ }
+ | undefined;
+ /**
+ * Parses an external HTML element into a block of this type when it returns the block props object, otherwise undefined
+ */
parse?: (
el: HTMLElement,
) => PartialBlockFromConfig["props"] | undefined;
+
+ /**
+ * The blocks that this block should run before.
+ * This is used to determine the order in which blocks are rendered.
+ */
+ runsBefore?: string[];
+
+ /**
+ * Advanced parsing function that controls how content within the block is parsed.
+ * This is not recommended to use, and is only useful for advanced use cases.
+ */
+ parseContent?: (options: { el: HTMLElement; schema: Schema }) => Fragment;
};
// Function that causes events within non-selectable blocks to be handled by the
@@ -87,6 +112,11 @@ export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) {
export function getParseRules(
config: BlockConfig,
customParseFunction: CustomBlockImplementation["parse"],
+ customParseContentFunction: CustomBlockImplementation<
+ any,
+ any,
+ any
+ >["parseContent"],
) {
const rules: TagParseRule[] = [
{
@@ -111,6 +141,37 @@ export function getParseRules(
return props;
},
+ getContent:
+ config.content === "inline" || config.content === "none"
+ ? (node, schema) => {
+ if (customParseContentFunction) {
+ return customParseContentFunction({
+ el: node as HTMLElement,
+ schema,
+ });
+ }
+
+ if (config.content === "inline") {
+ // Parse the blockquote content as inline content
+ const element = node as HTMLElement;
+
+ // Clone to avoid modifying the original
+ const clone = element.cloneNode(true) as HTMLElement;
+
+ // Merge multiple paragraphs into one with line breaks
+ mergeParagraphs(clone, config.meta?.code ? "\n" : "
");
+
+ // Parse the content directly as a paragraph to extract inline content
+ const parser = DOMParser.fromSchema(schema);
+ const parsed = parser.parse(clone, {
+ topNode: schema.nodes.paragraph.create(),
+ });
+
+ return parsed.content;
+ }
+ return Fragment.empty;
+ }
+ : undefined,
});
}
// getContent(node, schema) {
@@ -141,21 +202,33 @@ export function createBlockSpec<
>(
blockConfig: T,
blockImplementation: CustomBlockImplementation, I, S>,
+ priority?: number,
) {
const node = createStronglyTypedTiptapNode({
name: blockConfig.type as T["type"],
content: (blockConfig.content === "inline"
? "inline*"
- : "") as T["content"] extends "inline" ? "inline*" : "",
+ : blockConfig.content === "none"
+ ? ""
+ : blockConfig.content) as T["content"] extends "inline"
+ ? "inline*"
+ : "",
group: "blockContent",
- selectable: blockConfig.isSelectable ?? true,
+ selectable: blockConfig.meta?.selectable ?? true,
isolating: true,
+ code: blockConfig.meta?.code ?? false,
+ defining: blockConfig.meta?.defining ?? true,
+ priority,
addAttributes() {
return propsToAttributes(blockConfig.propSchema);
},
parseHTML() {
- return getParseRules(blockConfig, blockImplementation.parse);
+ return getParseRules(
+ blockConfig,
+ blockImplementation.parse,
+ (blockImplementation as any).parseContent,
+ );
},
renderHTML({ HTMLAttributes }) {
@@ -173,7 +246,7 @@ export function createBlockSpec<
blockConfig.type,
{},
blockConfig.propSchema,
- blockConfig.isFileBlock,
+ blockConfig.meta?.fileBlockAccept !== undefined,
HTMLAttributes,
);
},
@@ -195,16 +268,16 @@ export function createBlockSpec<
const output = blockImplementation.render(block as any, editor);
- const nodeView: NodeView = wrapInBlockStructure(
+ const nodeView = wrapInBlockStructure(
output,
block.type,
block.props,
blockConfig.propSchema,
- blockConfig.isFileBlock,
+ blockConfig.meta?.fileBlockAccept !== undefined,
blockContentDOMAttributes,
- );
+ ) satisfies NodeView;
- if (blockConfig.isSelectable === false) {
+ if (blockConfig.meta?.selectable === false) {
applyNonSelectableBlockFix(nodeView, this.editor);
}
@@ -221,7 +294,7 @@ export function createBlockSpec<
return createInternalBlockSpec(blockConfig, {
node,
- toInternalHTML: (block, editor) => {
+ render: (block, editor) => {
const blockContentDOMAttributes =
node.options.domAttributes?.blockContent || {};
@@ -232,7 +305,7 @@ export function createBlockSpec<
block.type,
block.props,
blockConfig.propSchema,
- blockConfig.isFileBlock,
+ blockConfig.meta?.fileBlockAccept !== undefined,
blockContentDOMAttributes,
);
},
@@ -242,7 +315,12 @@ export function createBlockSpec<
const blockContentDOMAttributes =
node.options.domAttributes?.blockContent || {};
- let output = blockImplementation.toExternalHTML?.(
+ let output:
+ | {
+ dom: HTMLElement | DocumentFragment;
+ contentDOM?: HTMLElement;
+ }
+ | undefined = blockImplementation.toExternalHTML?.(
block as any,
editor as any,
);
@@ -257,5 +335,66 @@ export function createBlockSpec<
blockContentDOMAttributes,
);
},
+ // Only needed for tables right now, remove later
+ requiredExtensions: (blockImplementation as any).requiredExtensions,
});
}
+
+/**
+ * Helper function to create a block config.
+ */
+export function createBlockConfig<
+ TCallback extends (
+ options: Partial>,
+ ) => BlockConfig,
+ TOptions extends Parameters[0],
+ TName extends ReturnType["type"],
+ TProps extends ReturnType["propSchema"],
+ TContent extends ReturnType["content"],
+>(
+ callback: TCallback,
+): (options: TOptions) => BlockConfig {
+ return callback;
+}
+
+/**
+ * Helper function to create a block definition.
+ */
+export function createBlockDefinition<
+ TCallback extends (options?: any) => BlockConfig,
+ TOptions extends Parameters[0],
+ TName extends ReturnType["type"],
+ TProps extends ReturnType["propSchema"],
+ TContent extends ReturnType["content"],
+>(
+ callback: TCallback,
+): {
+ implementation: (
+ cb: (options?: TOptions) => BlockImplementation,
+ addExtensions?: (options?: TOptions) => BlockNoteExtension[],
+ ) => (options?: TOptions) => BlockDefinition;
+} {
+ return {
+ implementation: (cb, addExtensions) => (options) => ({
+ config: callback(options) as any,
+ implementation: cb(options),
+ extensions: addExtensions?.(options),
+ }),
+ };
+}
+/**
+ * This creates an instance of a BlockNoteExtension that can be used to add to a schema.
+ * It is a bit of a hack, but it works.
+ */
+export function createBlockNoteExtension(
+ options: Partial<
+ Pick
+ > & { key: string },
+) {
+ const x = Object.create(BlockNoteExtension.prototype);
+ x.key = options.key;
+ x.inputRules = options.inputRules;
+ x.keyboardShortcuts = options.keyboardShortcuts;
+ x.plugins = options.plugins ?? [];
+ return x as BlockNoteExtension;
+}
diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts
index d3749ab53a..b8e33ce798 100644
--- a/packages/core/src/schema/blocks/internal.ts
+++ b/packages/core/src/schema/blocks/internal.ts
@@ -16,12 +16,10 @@ import { PropSchema, Props } from "../propTypes.js";
import { StyleSchema } from "../styles/types.js";
import {
BlockConfig,
- BlockSchemaFromSpecs,
+ BlockDefinition,
+ BlockImplementation,
BlockSchemaWithBlock,
- BlockSpec,
- BlockSpecs,
SpecificBlock,
- TiptapBlockImplementation,
} from "./types.js";
// Function that uses the 'propSchema' of a blockConfig to create a TipTap
@@ -145,7 +143,7 @@ export function wrapInBlockStructure<
PSchema extends PropSchema,
>(
element: {
- dom: HTMLElement;
+ dom: HTMLElement | DocumentFragment;
contentDOM?: HTMLElement;
destroy?: () => void;
},
@@ -234,17 +232,15 @@ export function createStronglyTypedTiptapNode<
// config and implementation that conform to the type of Config
export function createInternalBlockSpec(
config: T,
- implementation: TiptapBlockImplementation<
- T,
- any,
- InlineContentSchema,
- StyleSchema
- >,
-) {
+ implementation: BlockImplementation & {
+ node: Node;
+ requiredExtensions?: Array;
+ },
+): BlockDefinition {
return {
config,
implementation,
- } satisfies BlockSpec;
+ };
}
export function createBlockSpecFromStronglyTypedTiptapNode<
@@ -258,25 +254,14 @@ export function createBlockSpecFromStronglyTypedTiptapNode<
? "inline"
: node.config.content === "tableRow+"
? "table"
- : "none") as T["config"]["content"] extends "inline*"
- ? "inline"
- : T["config"]["content"] extends "tableRow+"
- ? "table"
- : "none",
+ : "none") as any, // TODO does this typing even matter?
propSchema,
},
{
node,
requiredExtensions,
- toInternalHTML: defaultBlockToHTML,
+ render: 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
index 0f97205638..09d82c2048 100644
--- a/packages/core/src/schema/blocks/types.ts
+++ b/packages/core/src/schema/blocks/types.ts
@@ -1,7 +1,9 @@
/** Define the main block types **/
-import type { Extension, Node } from "@tiptap/core";
+import type { Fragment, Schema } from "prosemirror-model";
+import type { ViewMutationRecord } from "prosemirror-view";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import type { BlockNoteExtension } from "../../editor/BlockNoteExtension.js";
import type {
InlineContent,
InlineContentSchema,
@@ -21,92 +23,62 @@ export type BlockNoteDOMAttributes = Partial<{
[DOMElement in BlockNoteDOMElement]: Record;
}>;
-export type FileBlockConfig = {
- type: string;
- readonly propSchema: PropSchema & {
- caption: {
- default: "";
- };
- name: {
- default: "";
- };
-
- // URL is optional, as we also want to accept files with no URL, but for example ids
- // (ids can be used for files that are resolved on the backend)
- url?: {
- default: "";
- };
-
- // Whether to show the file preview or the name only.
- // This is useful for some file blocks, but not all
- // (e.g.: not relevant for default "file" block which doesn;'t show previews)
- showPreview?: {
- default: boolean;
- };
- // File preview width in px.
- previewWidth?: {
- default: undefined;
- type: "number";
- };
- };
- content: "none";
- isSelectable?: boolean;
- isFileBlock: true;
+export interface BlockConfigMeta {
+ /**
+ * Whether the block is selectable
+ */
+ selectable?: boolean;
+
+ /**
+ * The accept mime types for the file block
+ */
fileBlockAccept?: string[];
-};
-// 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";
- isSelectable?: boolean;
- isFileBlock?: false;
- hardBreakShortcut?: "shift+enter" | "enter" | "none";
- }
- | FileBlockConfig;
-
-// 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: BlockNoDefaults[];
- },
- editor: BlockNoteEditor,
- ) => {
- dom: HTMLElement;
- contentDOM?: HTMLElement;
- };
- toExternalHTML: (
- block: BlockFromConfigNoChildren & {
- children: BlockNoDefaults[];
- },
- editor: BlockNoteEditor,
- ) => {
- dom: HTMLElement;
- contentDOM?: HTMLElement;
- };
-};
+ /**
+ * Whether the block is a {@link https://prosemirror.net/docs/ref/#model.NodeSpec.code} block
+ */
+ code?: boolean;
+
+ /**
+ * Whether the block is a {@link https://prosemirror.net/docs/ref/#model.NodeSpec.defining} block
+ */
+ defining?: boolean;
+}
+
+/**
+ * BlockConfig contains the "schema" info about a Block type
+ * i.e. what props it supports, what content it supports, etc.
+ */
+export interface BlockConfig<
+ TName extends string = string,
+ TSchema extends PropSchema = PropSchema,
+ TContent extends "inline" | "none" | "table" = "inline" | "none" | "table",
+> {
+ /**
+ * The type of the block (unique identifier within a schema)
+ */
+ type: TName;
+ /**
+ * The properties that the block supports
+ * @todo will be zod schema in the future
+ */
+ readonly propSchema: TSchema;
+ /**
+ * The content that the block supports
+ */
+ content: TContent;
+ // TODO: how do you represent things that have nested content?
+ // e.g. tables, alerts (with title & content)
+ /**
+ * Metadata
+ */
+ meta?: BlockConfigMeta;
+}
// A Spec contains both the Config and Implementation
-export type BlockSpec<
- T extends BlockConfig,
- B extends BlockSchema,
- I extends InlineContentSchema,
- S extends StyleSchema,
-> = {
+export type BlockSpec = {
config: T;
- implementation: TiptapBlockImplementation, B, I, S>;
+ implementation: BlockImplementation, PropSchema>;
};
// Utility type. For a given object block schema, ensures that the key of each
@@ -125,14 +97,11 @@ type NamesMatch> = Blocks extends {
// The keys are the "type" of a block
export type BlockSchema = NamesMatch>;
-export type BlockSpecs = Record<
- string,
- BlockSpec
->;
+export type BlockSpecs = Record>;
export type BlockImplementations = Record<
string,
- TiptapBlockImplementation
+ BlockImplementation
>;
export type BlockSchemaFromSpecs = {
@@ -193,7 +162,7 @@ export type BlockFromConfigNoChildren<
? TableContent
: B["content"] extends "none"
? undefined
- : never;
+ : undefined | never;
};
export type BlockFromConfig<
@@ -275,7 +244,7 @@ type PartialBlockFromConfigNoChildren<
? PartialInlineContent
: B["content"] extends "table"
? PartialTableContent
- : undefined;
+ : undefined | never;
};
type PartialBlocksWithoutChildren<
@@ -321,3 +290,91 @@ export type PartialBlockFromConfig<
};
export type BlockIdentifier = { id: string } | string;
+
+export interface BlockImplementation<
+ TName extends string,
+ TProps extends PropSchema,
+ TContent extends "inline" | "none" | "table" = "inline" | "none" | "table",
+> {
+ /**
+ * A function that converts the block into a DOM element
+ */
+ render: (
+ /**
+ * The custom block to render
+ */
+ block: BlockNoDefaults<
+ Record>,
+ any,
+ any
+ >,
+ /**
+ * The BlockNote editor instance
+ */
+ editor: BlockNoteEditor<
+ Record>
+ >,
+ ) => {
+ dom: HTMLElement | DocumentFragment;
+ contentDOM?: HTMLElement;
+ ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
+ destroy?: () => void;
+ };
+
+ /**
+ * Exports block to external HTML. If not defined, the output will be the same
+ * as `render(...).dom`.
+ */
+ toExternalHTML?: (
+ block: BlockNoDefaults<
+ Record>,
+ any,
+ any
+ >,
+ editor: BlockNoteEditor<
+ Record>
+ >,
+ ) =>
+ | {
+ dom: HTMLElement;
+ contentDOM?: HTMLElement;
+ }
+ | undefined;
+
+ /**
+ * Parses an external HTML element into a block of this type when it returns the block props object, otherwise undefined
+ */
+ parse?: (el: HTMLElement) => NoInfer>> | undefined;
+
+ /**
+ * The blocks that this block should run before.
+ * This is used to determine the order in which blocks are rendered.
+ */
+ runsBefore?: string[];
+
+ /**
+ * Advanced parsing function that controls how content within the block is parsed.
+ * This is not recommended to use, and is only useful for advanced use cases.
+ */
+ parseContent?: (options: { el: HTMLElement; schema: Schema }) => Fragment;
+}
+
+export type BlockDefinition<
+ TName extends string = string,
+ TProps extends PropSchema = PropSchema,
+ TContent extends "inline" | "none" | "table" = "inline" | "none" | "table",
+> = {
+ config: BlockConfig;
+ implementation: BlockImplementation<
+ string,
+ PropSchema,
+ "inline" | "none" | "table"
+ >;
+ extensions?: BlockNoteExtension[];
+};
+
+export type ExtractBlockConfig = T extends (
+ options: any,
+) => BlockDefinition
+ ? BlockConfig
+ : never;
diff --git a/packages/core/src/schema/propTypes.ts b/packages/core/src/schema/propTypes.ts
index 76a8df2769..23752f92ea 100644
--- a/packages/core/src/schema/propTypes.ts
+++ b/packages/core/src/schema/propTypes.ts
@@ -9,6 +9,7 @@ export type PropSpec =
| {
// We infer the type of the prop from the default value
default: PType;
+ type?: "string" | "number" | "boolean";
// a list of possible values, for example for a string prop (this will then be used as a string union type)
values?: readonly PType[];
}
@@ -34,17 +35,23 @@ export type Props = {
// for required props, get type from type of "default" value,
// and if values are specified, get type from values
[PName in keyof PSchema]: (
- PSchema[PName] extends { default: boolean } | { type: "boolean" }
- ? PSchema[PName]["values"] extends readonly boolean[]
- ? PSchema[PName]["values"][number]
+ NonNullable extends
+ | { default: boolean }
+ | { type: "boolean" }
+ ? NonNullable["values"] extends readonly boolean[]
+ ? NonNullable["values"][number]
: boolean
- : PSchema[PName] extends { default: number } | { type: "number" }
- ? PSchema[PName]["values"] extends readonly number[]
- ? PSchema[PName]["values"][number]
+ : NonNullable extends
+ | { default: number }
+ | { type: "number" }
+ ? NonNullable["values"] extends readonly number[]
+ ? NonNullable["values"][number]
: number
- : PSchema[PName] extends { default: string } | { type: "string" }
- ? PSchema[PName]["values"] extends readonly string[]
- ? PSchema[PName]["values"][number]
+ : NonNullable extends
+ | { default: string }
+ | { type: "string" }
+ ? NonNullable["values"] extends readonly string[]
+ ? NonNullable["values"][number]
: string
: never
) extends infer T
diff --git a/packages/core/src/util/topo-sort.test.ts b/packages/core/src/util/topo-sort.test.ts
new file mode 100644
index 0000000000..95753cd132
--- /dev/null
+++ b/packages/core/src/util/topo-sort.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from "vitest";
+import { toposort as batchingToposort, toposortReverse } from "./topo-sort.js";
+
+describe("toposort", () => {
+ it("toposorts an empty graph", () => {
+ expect(batchingToposort(new Map())).toEqual([]);
+ });
+
+ it("toposorts a simple DAG", () => {
+ expect(
+ batchingToposort(
+ new Map([
+ ["a", ["b"]],
+ ["b", ["c"]],
+ ["c", []],
+ ]),
+ ),
+ ).toEqual([new Set(["a"]), new Set(["b"]), new Set(["c"])]);
+ });
+
+ it("toposorts a richer DAG", () => {
+ expect(
+ batchingToposort(
+ new Map([
+ ["a", ["c"]],
+ ["b", ["c"]],
+ ["c", []],
+ ]),
+ ),
+ ).toEqual([new Set(["a", "b"]), new Set(["c"])]);
+ });
+
+ it("toposorts a complex DAG", () => {
+ expect(
+ batchingToposort(
+ new Map([
+ ["a", ["c", "f"]],
+ ["b", ["d", "e"]],
+ ["c", ["f"]],
+ ["d", ["f", "g"]],
+ ["e", ["h"]],
+ ["f", ["i"]],
+ ["g", ["j"]],
+ ["h", ["j"]],
+ ["i", []],
+ ["j", []],
+ ]),
+ ),
+ ).toEqual([
+ new Set(["a", "b"]),
+ new Set(["c", "d", "e"]),
+ new Set(["f", "g", "h"]),
+ new Set(["i", "j"]),
+ ]);
+ });
+
+ it("errors on a small cyclic graph", () => {
+ const dg = new Map([
+ ["a", ["b"]],
+ ["b", ["a"]],
+ ["c", []],
+ ]);
+ const sortCyclicGraph = () => {
+ batchingToposort(dg);
+ };
+ expect(sortCyclicGraph).toThrowError(Error);
+ });
+
+ it("errors on a larger cyclic graph", () => {
+ const dg = new Map([
+ ["a", ["b", "c"]],
+ ["b", ["c"]],
+ ["c", ["d", "e"]],
+ ["d", ["b"]],
+ ["e", []],
+ ]);
+ const sortCyclicGraph = () => {
+ batchingToposort(dg);
+ };
+ expect(sortCyclicGraph).toThrowError(Error);
+ });
+
+ it("can sort a graph with missing dependencies", () => {
+ const dg = new Map([
+ ["a", ["non-existent-node"]],
+ ["b", ["c"]],
+ ["c", []],
+ ]);
+ const result = batchingToposort(dg);
+ expect(result).toEqual([
+ new Set(["a", "b"]),
+ new Set(["non-existent-node", "c"]),
+ ]);
+ });
+});
+
+describe("toposortReverse", () => {
+ it("can sort stuff", () => {
+ const graph = new Map([
+ ["floss", ["brushTeeth"]],
+ ["drinkCoffee", ["wakeUp"]],
+ ["wakeUp", []],
+ ["brushTeeth", ["drinkCoffee", "eatBreakfast"]],
+ ["eatBreakfast", ["wakeUp"]],
+ ]);
+ const result = toposortReverse(graph);
+ expect(result).toMatchInlineSnapshot(`
+ [
+ Set {
+ "wakeUp",
+ },
+ Set {
+ "drinkCoffee",
+ "eatBreakfast",
+ },
+ Set {
+ "brushTeeth",
+ },
+ Set {
+ "floss",
+ },
+ ]
+ `);
+ });
+});
diff --git a/packages/core/src/util/topo-sort.ts b/packages/core/src/util/topo-sort.ts
new file mode 100644
index 0000000000..fb09fc2075
--- /dev/null
+++ b/packages/core/src/util/topo-sort.ts
@@ -0,0 +1,160 @@
+/**
+ * Instead of depending on the NPM package, we vendor this file from https://github.com/n1ru4l/toposort/blob/main/src/toposort.ts (MIT)
+ *
+ * There was a recent publish, despite not having been updated in 2 years, which is suspicious.
+ *
+ * This file is also simple enough that we can maintain it ourselves.
+ */
+
+export type DirectedAcyclicGraph = Map>;
+export type DependencyGraph = DirectedAcyclicGraph;
+
+export type TaskList = Array>;
+
+// Add more specific types for better type safety
+export type NodeId = string;
+export type DependencyMap = Map>;
+
+export function toposort(dag: DirectedAcyclicGraph): TaskList {
+ const inDegrees = countInDegrees(dag);
+
+ let { roots, nonRoots } = getRootsAndNonRoots(inDegrees);
+
+ const sorted: TaskList = [];
+
+ while (roots.size) {
+ sorted.push(roots);
+
+ const newRoots = new Set();
+ for (const root of roots) {
+ const dependents = dag.get(root);
+ if (!dependents) {
+ // Handle case where node has no dependents
+ continue;
+ }
+
+ for (const dependent of dependents) {
+ const currentDegree = inDegrees.get(dependent);
+ if (currentDegree === undefined) {
+ // Handle case where dependent node is not in inDegrees
+ continue;
+ }
+
+ const newDegree = currentDegree - 1;
+ inDegrees.set(dependent, newDegree);
+
+ if (newDegree === 0) {
+ newRoots.add(dependent);
+ }
+ }
+ }
+
+ roots = newRoots;
+ }
+ nonRoots = getRootsAndNonRoots(inDegrees).nonRoots;
+
+ if (nonRoots.size) {
+ throw new Error(
+ `Cycle(s) detected; toposort only works on acyclic graphs. Cyclic nodes: ${Array.from(nonRoots).join(", ")}`,
+ );
+ }
+
+ return sorted;
+}
+
+export function toposortReverse(deps: DependencyGraph): TaskList {
+ const dag = reverse(deps);
+ return toposort(dag);
+}
+
+type InDegrees = Map;
+
+function countInDegrees(dag: DirectedAcyclicGraph): InDegrees {
+ const counts: InDegrees = new Map();
+
+ for (const [vx, dependents] of dag.entries()) {
+ // Initialize count for current node if not present
+ if (!counts.has(vx)) {
+ counts.set(vx, 0);
+ }
+
+ for (const dependent of dependents) {
+ const currentCount = counts.get(dependent) ?? 0;
+ counts.set(dependent, currentCount + 1);
+ }
+ }
+
+ return counts;
+}
+
+function getRootsAndNonRoots(counts: InDegrees) {
+ const roots = new Set();
+ const nonRoots = new Set();
+
+ for (const [id, deg] of counts.entries()) {
+ if (deg === 0) {
+ roots.add(id);
+ } else {
+ nonRoots.add(id);
+ }
+ }
+
+ return { roots, nonRoots };
+}
+
+function reverse(deps: DirectedAcyclicGraph): DependencyGraph {
+ const reversedDeps: DependencyMap = new Map();
+
+ for (const [name, dependsOn] of deps.entries()) {
+ // Ensure the source node exists in the reversed map
+ if (!reversedDeps.has(name)) {
+ reversedDeps.set(name, new Set());
+ }
+
+ for (const dependsOnName of dependsOn) {
+ if (!reversedDeps.has(dependsOnName)) {
+ reversedDeps.set(dependsOnName, new Set());
+ }
+ reversedDeps.get(dependsOnName)!.add(name);
+ }
+ }
+
+ return reversedDeps;
+}
+
+export function createDependencyGraph(): DependencyMap {
+ return new Map();
+}
+
+export function addDependency(
+ graph: DependencyMap,
+ from: NodeId,
+ to: NodeId,
+): DependencyMap {
+ if (!graph.has(from)) {
+ graph.set(from, new Set());
+ }
+ graph.get(from)!.add(to);
+ return graph;
+}
+
+export function removeDependency(
+ graph: DependencyMap,
+ from: NodeId,
+ to: NodeId,
+): boolean {
+ const dependents = graph.get(from);
+ if (!dependents) {
+ return false;
+ }
+ return dependents.delete(to);
+}
+
+export function hasDependency(
+ graph: DependencyMap,
+ from: NodeId,
+ to: NodeId,
+): boolean {
+ const dependents = graph.get(from);
+ return dependents ? dependents.has(to) : false;
+}
diff --git a/packages/react/src/components/Comments/schema.ts b/packages/react/src/components/Comments/schema.ts
index 576aa2c3ef..34d0980ca4 100644
--- a/packages/react/src/components/Comments/schema.ts
+++ b/packages/react/src/components/Comments/schema.ts
@@ -1,20 +1,8 @@
-import {
- BlockNoteSchema,
- createBlockSpecFromStronglyTypedTiptapNode,
- createStronglyTypedTiptapNode,
- defaultBlockSpecs,
- defaultStyleSpecs,
-} from "@blocknote/core";
+import { BlockNoteSchema, defaultStyleSpecs } from "@blocknote/core";
+import { paragraph } from "../../../../core/src/blks/index.js";
// this is quite convoluted. we'll clean this up when we make
// it easier to extend / customize the default blocks
-const paragraph = createBlockSpecFromStronglyTypedTiptapNode(
- createStronglyTypedTiptapNode<"paragraph", "inline*">(
- defaultBlockSpecs.paragraph.implementation.node.config as any,
- ),
- // disable default props on paragraph (such as textalignment and colors)
- {},
-);
// remove textColor, backgroundColor from styleSpecs
const { textColor, backgroundColor, ...styleSpecs } = defaultStyleSpecs;
@@ -22,7 +10,7 @@ const { textColor, backgroundColor, ...styleSpecs } = defaultStyleSpecs;
// the schema to use for comments
export const schema = BlockNoteSchema.create({
blockSpecs: {
- paragraph,
+ paragraph: paragraph.definition(),
},
styleSpecs,
});
diff --git a/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx b/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx
index e650875720..f1ca112f0a 100644
--- a/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx
+++ b/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx
@@ -75,10 +75,9 @@ export const UploadTab = <
);
const config = editor.schema.blockSchema[block.type];
- const accept =
- config.isFileBlock && config.fileBlockAccept?.length
- ? config.fileBlockAccept.join(",")
- : "*/*";
+ const accept = config.meta?.fileBlockAccept?.length
+ ? config.meta.fileBlockAccept.join(",")
+ : "*/*";
return (
diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx
index 204e5bc5dc..38b1fef90c 100644
--- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx
+++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx
@@ -1,7 +1,7 @@
import {
+ blockHasType,
BlockSchema,
- checkBlockIsFileBlock,
- checkBlockIsFileBlockWithPlaceholder,
+ editorHasBlockWithType,
InlineContentSchema,
StyleSchema,
} from "@blocknote/core";
@@ -41,7 +41,12 @@ export const FileCaptionButton = () => {
const block = selectedBlocks[0];
- if (checkBlockIsFileBlock(block, editor)) {
+ if (
+ blockHasType(block, editor, block.type, {
+ url: "string",
+ caption: "string",
+ })
+ ) {
setCurrentEditingCaption(block.props.caption);
return block;
}
@@ -51,11 +56,17 @@ export const FileCaptionButton = () => {
const handleEnter = useCallback(
(event: KeyboardEvent) => {
- if (fileBlock && event.key === "Enter") {
+ if (
+ fileBlock &&
+ editorHasBlockWithType(editor, fileBlock.type, {
+ caption: "string",
+ }) &&
+ event.key === "Enter"
+ ) {
event.preventDefault();
editor.updateBlock(fileBlock, {
props: {
- caption: currentEditingCaption as any, // TODO
+ caption: currentEditingCaption,
},
});
}
@@ -69,11 +80,7 @@ export const FileCaptionButton = () => {
[],
);
- if (
- !fileBlock ||
- checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) ||
- !editor.isEditable
- ) {
+ if (!fileBlock || fileBlock.props.url === "" || !editor.isEditable) {
return null;
}
diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx
index 747e5942a0..2ea096de36 100644
--- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx
+++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx
@@ -1,7 +1,6 @@
import {
+ blockHasType,
BlockSchema,
- checkBlockIsFileBlock,
- checkBlockIsFileBlockWithPlaceholder,
InlineContentSchema,
StyleSchema,
} from "@blocknote/core";
@@ -33,7 +32,7 @@ export const FileDeleteButton = () => {
const block = selectedBlocks[0];
- if (checkBlockIsFileBlock(block, editor)) {
+ if (blockHasType(block, editor, block.type, { url: "string" })) {
return block;
}
@@ -45,11 +44,7 @@ export const FileDeleteButton = () => {
editor.removeBlocks([fileBlock!]);
}, [editor, fileBlock]);
- if (
- !fileBlock ||
- checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) ||
- !editor.isEditable
- ) {
+ if (!fileBlock || fileBlock.props.url === "" || !editor.isEditable) {
return null;
}
diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx
index a80eb5e50e..d19e202564 100644
--- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx
+++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx
@@ -1,7 +1,6 @@
import {
+ blockHasType,
BlockSchema,
- checkBlockIsFileBlock,
- checkBlockIsFileBlockWithPlaceholder,
InlineContentSchema,
StyleSchema,
} from "@blocknote/core";
@@ -34,7 +33,7 @@ export const FileDownloadButton = () => {
const block = selectedBlocks[0];
- if (checkBlockIsFileBlock(block, editor)) {
+ if (blockHasType(block, editor, block.type, { url: "string" })) {
return block;
}
@@ -57,7 +56,7 @@ export const FileDownloadButton = () => {
}
}, [editor, fileBlock]);
- if (!fileBlock || checkBlockIsFileBlockWithPlaceholder(fileBlock, editor)) {
+ if (!fileBlock || fileBlock.props.url === "") {
return null;
}
diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx
index c52159c3d7..9e8fcb0425 100644
--- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx
+++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx
@@ -1,7 +1,7 @@
import {
+ blockHasType,
BlockSchema,
- checkBlockIsFileBlockWithPlaceholder,
- checkBlockIsFileBlockWithPreview,
+ editorHasBlockWithType,
InlineContentSchema,
StyleSchema,
} from "@blocknote/core";
@@ -33,7 +33,12 @@ export const FilePreviewButton = () => {
const block = selectedBlocks[0];
- if (checkBlockIsFileBlockWithPreview(block, editor)) {
+ if (
+ blockHasType(block, editor, block.type, {
+ url: "string",
+ showPreview: "boolean",
+ })
+ ) {
return block;
}
@@ -41,20 +46,21 @@ export const FilePreviewButton = () => {
}, [editor, selectedBlocks]);
const onClick = useCallback(() => {
- if (fileBlock) {
+ if (
+ fileBlock &&
+ editorHasBlockWithType(editor, fileBlock.type, {
+ showPreview: "boolean",
+ })
+ ) {
editor.updateBlock(fileBlock, {
props: {
- showPreview: !fileBlock.props.showPreview as any, // TODO
+ showPreview: !fileBlock.props.showPreview,
},
});
}
}, [editor, fileBlock]);
- if (
- !fileBlock ||
- checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) ||
- !editor.isEditable
- ) {
+ if (!fileBlock || fileBlock.props.url === "" || !editor.isEditable) {
return null;
}
diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx
index 595b9c5271..583494917f 100644
--- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx
+++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx
@@ -1,7 +1,7 @@
import {
+ blockHasType,
BlockSchema,
- checkBlockIsFileBlock,
- checkBlockIsFileBlockWithPlaceholder,
+ editorHasBlockWithType,
InlineContentSchema,
StyleSchema,
} from "@blocknote/core";
@@ -41,7 +41,12 @@ export const FileRenameButton = () => {
const block = selectedBlocks[0];
- if (checkBlockIsFileBlock(block, editor)) {
+ if (
+ blockHasType(block, editor, block.type, {
+ url: "string",
+ name: "string",
+ })
+ ) {
setCurrentEditingName(block.props.name);
return block;
}
@@ -51,11 +56,17 @@ export const FileRenameButton = () => {
const handleEnter = useCallback(
(event: KeyboardEvent) => {
- if (fileBlock && event.key === "Enter") {
+ if (
+ fileBlock &&
+ editorHasBlockWithType(editor, fileBlock.type, {
+ name: "string",
+ }) &&
+ event.key === "Enter"
+ ) {
event.preventDefault();
editor.updateBlock(fileBlock, {
props: {
- name: currentEditingName as any, // TODO
+ name: currentEditingName,
},
});
}
@@ -69,11 +80,7 @@ export const FileRenameButton = () => {
[],
);
- if (
- !fileBlock ||
- checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) ||
- !editor.isEditable
- ) {
+ if (!fileBlock || fileBlock.props.name === "" || !editor.isEditable) {
return null;
}
diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx
index 7770cd4fd3..ed68593186 100644
--- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx
+++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx
@@ -1,6 +1,6 @@
import {
+ blockHasType,
BlockSchema,
- checkBlockIsFileBlock,
InlineContentSchema,
StyleSchema,
} from "@blocknote/core";
@@ -35,7 +35,9 @@ export const FileReplaceButton = () => {
if (
block === undefined ||
- !checkBlockIsFileBlock(block, editor) ||
+ !blockHasType(block, editor, block.type, {
+ url: "string",
+ }) ||
!editor.isEditable
) {
return null;
diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx
index 934fc3a32a..05b38f6a7a 100644
--- a/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx
+++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx
@@ -1,8 +1,9 @@
import {
+ blockHasType,
BlockSchema,
- checkBlockHasDefaultProp,
- checkBlockTypeHasDefaultProp,
+ defaultProps,
DefaultProps,
+ editorHasBlockWithType,
InlineContentSchema,
mapTableCell,
StyleSchema,
@@ -46,7 +47,11 @@ export const TextAlignButton = (props: { textAlignment: TextAlignment }) => {
const textAlignment = useMemo(() => {
const block = selectedBlocks[0];
- if (checkBlockHasDefaultProp("textAlignment", block, editor)) {
+ if (
+ blockHasType(block, editor, block.type, {
+ textAlignment: defaultProps.textAlignment,
+ })
+ ) {
return block.props.textAlignment;
}
if (block.type === "table") {
@@ -75,7 +80,14 @@ export const TextAlignButton = (props: { textAlignment: TextAlignment }) => {
editor.focus();
for (const block of selectedBlocks) {
- if (checkBlockTypeHasDefaultProp("textAlignment", block.type, editor)) {
+ if (
+ blockHasType(block, editor, block.type, {
+ textAlignment: defaultProps.textAlignment,
+ }) &&
+ editorHasBlockWithType(editor, block.type, {
+ textAlignment: defaultProps.textAlignment,
+ })
+ ) {
editor.updateBlock(block, {
props: { textAlignment: textAlignment },
});
@@ -122,10 +134,12 @@ export const TextAlignButton = (props: { textAlignment: TextAlignment }) => {
const show = useMemo(() => {
return !!selectedBlocks.find(
(block) =>
- "textAlignment" in block.props ||
+ blockHasType(block, editor, block.type, {
+ textAlignment: defaultProps.textAlignment,
+ }) ||
(block.type === "table" && block.children),
);
- }, [selectedBlocks]);
+ }, [editor, selectedBlocks]);
if (!show || !editor.isEditable) {
return null;
diff --git a/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx
index 5e27e1eb21..c8733458d6 100644
--- a/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx
+++ b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx
@@ -1,10 +1,11 @@
import {
+ Block,
+ blockHasType,
BlockSchema,
- checkBlockHasDefaultProp,
- checkBlockTypeHasDefaultProp,
DefaultBlockSchema,
DefaultInlineContentSchema,
DefaultStyleSchema,
+ editorHasBlockWithType,
InlineContentSchema,
StyleSchema,
} from "@blocknote/core";
@@ -28,9 +29,18 @@ export const BlockColorsItem = <
const editor = useBlockNoteEditor();
+ // We cast the block to a generic one, as the base type causes type errors
+ // with runtime type checking using `blockHasType`. Runtime type checking is
+ // more valuable than static checks, so better to do it like this.
+ const block = props.block as Block;
+
if (
- !checkBlockTypeHasDefaultProp("textColor", props.block.type, editor) &&
- !checkBlockTypeHasDefaultProp("backgroundColor", props.block.type, editor)
+ !blockHasType(block, editor, block.type, {
+ textColor: "string",
+ }) ||
+ !blockHasType(block, editor, block.type, {
+ backgroundColor: "string",
+ })
) {
return null;
}
@@ -53,32 +63,33 @@ export const BlockColorsItem = <
- editor.updateBlock(props.block, {
- type: props.block.type,
+ editor.updateBlock(block, {
+ type: block.type,
props: { textColor: color },
}),
}
: undefined
}
background={
- checkBlockTypeHasDefaultProp(
- "backgroundColor",
- props.block.type,
- editor,
- ) &&
- checkBlockHasDefaultProp("backgroundColor", props.block, editor)
+ blockHasType(block, editor, block.type, {
+ backgroundColor: "string",
+ }) &&
+ editorHasBlockWithType(editor, block.type, {
+ backgroundColor: "string",
+ })
? {
- color: props.block.props.backgroundColor,
+ color: block.props.backgroundColor,
setColor: (color) =>
- editor.updateBlock(props.block, {
+ editor.updateBlock(block, {
props: { backgroundColor: color },
}),
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9657b2a136..447eed64f9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -393,6 +393,9 @@ importers:
'@blocknote/ariakit':
specifier: latest
version: link:../../../packages/ariakit
+ '@blocknote/code-block':
+ specifier: latest
+ version: link:../../../packages/code-block
'@blocknote/core':
specifier: latest
version: link:../../../packages/core