From fcdbf18b0dde74124b50a3ee2e858d318284270b Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 28 Jul 2025 12:59:05 +0200 Subject: [PATCH 01/19] feat: wip --- examples/01-basic/01-minimal/src/App.tsx | 17 +- packages/core/src/blks/Audio/definition.ts | 129 ++++++++++++ .../src/blks/BulletListItem/definition.ts | 103 +++++++++ .../core/src/blks/CheckListItem/definition.ts | 143 +++++++++++++ packages/core/src/blks/File/definition.ts | 86 ++++++++ packages/core/src/blks/Heading/definition.ts | 111 ++++++++++ packages/core/src/blks/Image/definition.ts | 145 +++++++++++++ .../blks/NumberedListItem/IndexingPlugin.ts | 190 +++++++++++++++++ .../src/blks/NumberedListItem/definition.ts | 107 ++++++++++ .../core/src/blks/PageBreak/definition.ts | 39 ++++ .../core/src/blks/Paragraph/definition.ts | 64 ++++++ .../core/src/blks/QuoteBlock/definition.ts | 75 +++++++ .../src/blks/ToggleListItem/definition.ts | 63 ++++++ packages/core/src/blks/Video/definition.ts | 128 ++++++++++++ packages/core/src/blks/index.ts | 13 ++ .../NumberedListItemBlockContent.ts | 2 + .../core/src/blocks/defaultBlockTypeGuards.ts | 4 +- packages/core/src/editor/BlockNoteEditor.ts | 114 ++++++++-- .../core/src/editor/BlockNoteExtension.ts | 48 +++++ packages/core/src/editor/BlockNoteSchema.ts | 3 + packages/core/src/editor/playground.ts | 196 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/schema/blocks/playground.ts | 194 +++++++++++++++++ packages/core/src/schema/blocks/types.ts | 2 + 24 files changed, 1954 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/blks/Audio/definition.ts create mode 100644 packages/core/src/blks/BulletListItem/definition.ts create mode 100644 packages/core/src/blks/CheckListItem/definition.ts create mode 100644 packages/core/src/blks/File/definition.ts create mode 100644 packages/core/src/blks/Heading/definition.ts create mode 100644 packages/core/src/blks/Image/definition.ts create mode 100644 packages/core/src/blks/NumberedListItem/IndexingPlugin.ts create mode 100644 packages/core/src/blks/NumberedListItem/definition.ts create mode 100644 packages/core/src/blks/PageBreak/definition.ts create mode 100644 packages/core/src/blks/Paragraph/definition.ts create mode 100644 packages/core/src/blks/QuoteBlock/definition.ts create mode 100644 packages/core/src/blks/ToggleListItem/definition.ts create mode 100644 packages/core/src/blks/Video/definition.ts create mode 100644 packages/core/src/blks/index.ts create mode 100644 packages/core/src/editor/playground.ts create mode 100644 packages/core/src/schema/blocks/playground.ts diff --git a/examples/01-basic/01-minimal/src/App.tsx b/examples/01-basic/01-minimal/src/App.tsx index a3b92bafd2..53d990c454 100644 --- a/examples/01-basic/01-minimal/src/App.tsx +++ b/examples/01-basic/01-minimal/src/App.tsx @@ -2,10 +2,25 @@ import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; +import { BlockNoteSchema2 } from "@blocknote/core"; + +const schema = BlockNoteSchema2.create(); export default function App() { // Creates a new editor instance. - const editor = useCreateBlockNote(); + const editor = useCreateBlockNote({ + schema: schema as any, + initialContent: [ + { + type: "numberedListItem", + content: "Numbered List Item 1", + }, + { + type: "numberedListItem", + content: "Numbered List Item 2", + }, + ], + }); // Renders the editor instance using a React component. return ; diff --git a/packages/core/src/blks/Audio/definition.ts b/packages/core/src/blks/Audio/definition.ts new file mode 100644 index 0000000000..a7e9712e80 --- /dev/null +++ b/packages/core/src/blks/Audio/definition.ts @@ -0,0 +1,129 @@ +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, + createBlockSpec, +} from "../../schema/blocks/playground.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" as const, + meta: { + fileBlockAccept: ["audio/*"], + }, +})); + +export const definition = createBlockSpec(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, + }; + }, +})); diff --git a/packages/core/src/blks/BulletListItem/definition.ts b/packages/core/src/blks/BulletListItem/definition.ts new file mode 100644 index 0000000000..291973d899 --- /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 { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { + createBlockConfig, + createBlockSpec, +} from "../../schema/blocks/playground.js"; + +const config = createBlockConfig(() => ({ + type: "bulletListItem" as const, + propSchema: { + ...defaultProps, + }, + content: "inline", +})); + +export class BulletListItemExtension extends BlockNoteExtension { + public static key() { + return "bullet-list-item-shortcuts"; + } + + constructor() { + super(); + this.inputRules = [ + { + find: new RegExp(`^[-+*]\\s$`), + replace() { + return { + type: "bulletListItem", + props: {}, + }; + }, + }, + ]; + + this.keyboardShortcuts = { + "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; + }), + }; + } +} + +export const definition = createBlockSpec(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; + }, + // TODO how do we represent this?? + // // As `li` elements can contain multiple paragraphs, we need to merge their contents + // // into a single one so that ProseMirror can parse everything correctly. + // getContent: (node, schema) => + // getListItemContent(node, schema, this.name), + // node: "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, + }; + }, + }), + () => [new BulletListItemExtension()], +); diff --git a/packages/core/src/blks/CheckListItem/definition.ts b/packages/core/src/blks/CheckListItem/definition.ts new file mode 100644 index 0000000000..9e1319e55c --- /dev/null +++ b/packages/core/src/blks/CheckListItem/definition.ts @@ -0,0 +1,143 @@ +import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; +import { defaultProps } from "../../blocks/defaultProps.js"; +import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { + createBlockConfig, + createBlockSpec, +} from "../../schema/blocks/playground.js"; + +const config = createBlockConfig(() => ({ + type: "checkListItem" as const, + propSchema: { + ...defaultProps, + checked: { default: false, type: "boolean" }, + }, + content: "inline", +})); + +export class CheckListItemExtension extends BlockNoteExtension { + public static key() { + return "check-list-item-shortcuts"; + } + + constructor() { + super(); + this.inputRules = [ + { + find: new RegExp(`\\[\\s*\\]\\s$`), + replace() { + return { + type: "checkListItem", + props: { + checked: false, + }, + }; + }, + }, + { + find: new RegExp(`\\[[Xx]\\]\\s$`), + replace() { + return { + type: "checkListItem", + props: { + checked: true, + }, + }; + }, + }, + ]; + + this.keyboardShortcuts = { + "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; + }), + }; + } +} + +export const definition = createBlockSpec(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; + }, + // TODO how do we represent this?? + // // As `li` elements can contain multiple paragraphs, we need to merge their contents + // // into a single one so that ProseMirror can parse everything correctly. + // getContent: (node, schema) => + // getListItemContent(node, schema, this.name), + // node: "bulletListItem", + 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, + }; + }, + }), + () => [new CheckListItemExtension()], +); diff --git a/packages/core/src/blks/File/definition.ts b/packages/core/src/blks/File/definition.ts new file mode 100644 index 0000000000..470eb7dee0 --- /dev/null +++ b/packages/core/src/blks/File/definition.ts @@ -0,0 +1,86 @@ +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, + createBlockSpec, +} from "../../schema/blocks/playground.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: ["*/*"], + }, +})); + +export const definition = createBlockSpec(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..ff262993c0 --- /dev/null +++ b/packages/core/src/blks/Heading/definition.ts @@ -0,0 +1,111 @@ +import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; +import { createToggleWrapper } from "../../blocks/ToggleWrapper/createToggleWrapper.js"; +import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { + createBlockConfig, + createBlockSpec, +} from "../../schema/blocks/playground.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: { + level: { default: defaultLevel, values: levels }, + ...(allowToggleHeadings ? { isToggleable: { default: false } } : {}), + }, + content: "inline", + }), +); +export class HeadingExtension extends BlockNoteExtension { + public static key() { + return "heading-shortcuts"; + } + + constructor(options: HeadingOptions) { + super(); + this.keyboardShortcuts = Object.fromEntries( + (options.levels ?? HEADING_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; + }), + ]) ?? [], + ); + + this.inputRules = (options.levels ?? HEADING_LEVELS).map((level) => ({ + find: new RegExp(`^(#{${level}})\\s$`), + replace({ match }: { match: RegExpMatchArray }) { + return { + type: "heading", + props: { + level: match[1].length, + }, + }; + }, + })); + } +} + +export const definition = createBlockSpec(config).implementation( + ({ allowToggleHeadings }) => ({ + parse(e) { + const heading = e.querySelector("h1, h2, h3, h4, h5, h6"); + if (!heading) { + return undefined; + } + + const level = heading.tagName.slice(1); + + return { + level: parseInt(level), + }; + }, + render(block, editor) { + const dom = document.createElement(`h${block.props.level}`); + + if (allowToggleHeadings) { + const toggleWrapper = createToggleWrapper(block as any, editor, dom); + dom.appendChild(toggleWrapper.dom); + return toggleWrapper; + } + + return { + dom, + contentDOM: dom, + }; + }, + }), + (options) => [new HeadingExtension(options)], +); diff --git a/packages/core/src/blks/Image/definition.ts b/packages/core/src/blks/Image/definition.ts new file mode 100644 index 0000000000..d29653b8cc --- /dev/null +++ b/packages/core/src/blks/Image/definition.ts @@ -0,0 +1,145 @@ +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, + createBlockSpec, +} from "../../schema/blocks/playground.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", + }, + }, + content: "none" as const, + meta: { + fileBlockAccept: ["image/*"], + }, +})); + +export const definition = createBlockSpec(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, + }; + }, +})); diff --git a/packages/core/src/blks/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blks/NumberedListItem/IndexingPlugin.ts new file mode 100644 index 0000000000..cb2fb80ca7 --- /dev/null +++ b/packages/core/src/blks/NumberedListItem/IndexingPlugin.ts @@ -0,0 +1,190 @@ +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( + Decoration.widget( + pos + 1, + () => { + const element = document.createElement("span"); + element.classList.add("numbered-list-index"); + element.textContent = index.toString(); + + // Add data attributes for styling + element.setAttribute("data-index", index.toString()); + element.setAttribute("data-is-first", isFirst.toString()); + element.setAttribute("data-has-start", hasStart.toString()); + + return element; + }, + { + index, + isFirst, + hasStart, + side: -1, + } satisfies DecoSpec, + ), + ); + } + } + }); + + // 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..3edfc23ecd --- /dev/null +++ b/packages/core/src/blks/NumberedListItem/definition.ts @@ -0,0 +1,107 @@ +import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; +import { defaultProps } from "../../blocks/defaultProps.js"; +import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { + createBlockConfig, + createBlockSpec, +} from "../../schema/blocks/playground.js"; +import { NumberedListIndexingDecorationPlugin } from "./IndexingPlugin.js"; + +const config = createBlockConfig(() => ({ + type: "numberedListItem" as const, + propSchema: { + ...defaultProps, + start: { default: undefined, type: "number" }, + }, + content: "inline", +})); + +export class NumberedListItemExtension extends BlockNoteExtension { + public static key() { + return "numbered-list-item-shortcuts"; + } + + constructor() { + super(); + this.inputRules = [ + { + find: new RegExp(`^\\d+\\.\\s$`), + replace() { + return { + type: "numberedListItem", + props: {}, + }; + }, + }, + ]; + + this.keyboardShortcuts = { + "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; + }), + }; + + this.addProsemirrorPlugin(NumberedListIndexingDecorationPlugin()); + } +} + +export const definition = createBlockSpec(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; + }, + // TODO how do we represent this?? + // // As `li` elements can contain multiple paragraphs, we need to merge their contents + // // into a single one so that ProseMirror can parse everything correctly. + // getContent: (node, schema) => + // getListItemContent(node, schema, this.name), + // node: "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, + }; + }, + }), + () => [new NumberedListItemExtension()], +); diff --git a/packages/core/src/blks/PageBreak/definition.ts b/packages/core/src/blks/PageBreak/definition.ts new file mode 100644 index 0000000000..aa7869785f --- /dev/null +++ b/packages/core/src/blks/PageBreak/definition.ts @@ -0,0 +1,39 @@ +import { + createBlockConfig, + createBlockSpec, +} from "../../schema/blocks/playground.js"; + +const config = createBlockConfig(() => ({ + type: "pageBreak" as const, + propSchema: {}, + content: "none", +})); + +export const definition = createBlockSpec(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..a4a106d14b --- /dev/null +++ b/packages/core/src/blks/Paragraph/definition.ts @@ -0,0 +1,64 @@ +import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; +import { defaultProps } from "../../blocks/defaultProps.js"; +import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { + createBlockConfig, + createBlockSpec, +} from "../../schema/blocks/playground.js"; + +const config = createBlockConfig(() => ({ + type: "paragraph" as const, + propSchema: defaultProps, + content: "inline", +})); + +export class ParagraphExtension extends BlockNoteExtension { + public static key() { + return "paragraph-shortcuts"; + } + + constructor() { + super(); + this.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; + }), + }; + } +} + +export const definition = createBlockSpec(config).implementation( + () => ({ + parse: (e) => { + const paragraph = e.querySelector("p"); + if (!paragraph) { + return undefined; + } + + return {}; + }, + render: () => { + const dom = document.createElement("p"); + return { + dom, + contentDOM: dom, + }; + }, + }), + () => [new ParagraphExtension()], +); diff --git a/packages/core/src/blks/QuoteBlock/definition.ts b/packages/core/src/blks/QuoteBlock/definition.ts new file mode 100644 index 0000000000..9f9be4b89d --- /dev/null +++ b/packages/core/src/blks/QuoteBlock/definition.ts @@ -0,0 +1,75 @@ +import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; +import { defaultProps } from "../../blocks/defaultProps.js"; +import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { + createBlockConfig, + createBlockSpec, +} from "../../schema/blocks/playground.js"; + +const config = createBlockConfig(() => ({ + type: "quote" as const, + propSchema: { ...defaultProps }, + content: "inline", +})); + +export class QuoteBlockExtension extends BlockNoteExtension { + public static key() { + return "quote-block-shortcuts"; + } + + constructor() { + super(); + this.inputRules = [ + { + find: new RegExp(`^>\\s$`), + replace() { + return { + type: "quote", + props: {}, + }; + }, + }, + ]; + + this.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; + }), + }; + } +} + +export const definition = createBlockSpec(config).implementation( + () => ({ + parse(element) { + if (element.querySelector("blockquote")) { + return {}; + } + + return undefined; + }, + render() { + const quote = document.createElement("blockquote"); + + return { + dom: quote, + contentDOM: quote, + }; + }, + }), + () => [new QuoteBlockExtension()], +); diff --git a/packages/core/src/blks/ToggleListItem/definition.ts b/packages/core/src/blks/ToggleListItem/definition.ts new file mode 100644 index 0000000000..02b171a38e --- /dev/null +++ b/packages/core/src/blks/ToggleListItem/definition.ts @@ -0,0 +1,63 @@ +import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; +import { defaultProps } from "../../blocks/defaultProps.js"; +import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { + createBlockConfig, + createBlockSpec, +} from "../../schema/blocks/playground.js"; + +const config = createBlockConfig(() => ({ + type: "toggleListItem" as const, + propSchema: { + ...defaultProps, + }, + content: "inline", +})); + +export class ToggleListItemExtension extends BlockNoteExtension { + public static key() { + return "toggle-list-item-shortcuts"; + } + + constructor() { + super(); + this.keyboardShortcuts = { + "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; + }), + }; + } +} + +export const definition = createBlockSpec(config).implementation( + () => ({ + render() { + // TODO actual rendering + const div = document.createElement("div"); + const paragraphEl = document.createElement("p"); + + div.appendChild(paragraphEl); + + return { + dom: div, + contentDOM: paragraphEl, + }; + }, + }), + () => [new ToggleListItemExtension()], +); diff --git a/packages/core/src/blks/Video/definition.ts b/packages/core/src/blks/Video/definition.ts new file mode 100644 index 0000000000..8f486e2ebc --- /dev/null +++ b/packages/core/src/blks/Video/definition.ts @@ -0,0 +1,128 @@ +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, + createBlockSpec, +} from "../../schema/blocks/playground.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 = createBlockSpec(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, + }; + }, +})); diff --git a/packages/core/src/blks/index.ts b/packages/core/src/blks/index.ts new file mode 100644 index 0000000000..c8787ae991 --- /dev/null +++ b/packages/core/src/blks/index.ts @@ -0,0 +1,13 @@ +export * as audio from "./Audio/definition.js"; +export * as bulletListItem from "./BulletListItem/definition.js"; +export * as checkListItem from "./CheckListItem/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 quoteBlock from "./QuoteBlock/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/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/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts index 69ac5fdae9..e2788871ec 100644 --- a/packages/core/src/blocks/defaultBlockTypeGuards.ts +++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts @@ -32,8 +32,8 @@ export function checkDefaultBlockTypeInSchema< S > { return ( - blockType in editor.schema.blockSchema && - editor.schema.blockSchema[blockType] === defaultBlockSchema[blockType] + blockType in editor.schema.blockSchema //&& + // editor.schema.blockSchema[blockType] === defaultBlockSchema[blockType] ); } diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index ecdabb4272..c8c7012374 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, @@ -96,6 +100,7 @@ import { en } from "../i18n/locales/index.js"; import { redo, undo } from "@tiptap/pm/history"; import { + Selection, TextSelection, type Command, type Plugin, @@ -121,6 +126,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 @@ -799,28 +805,96 @@ export class BlockNoteEditor< ); } + console.log(this.schema.blockSchema); + 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.constructor.key(), ext]), + ); + console.log(blockExtensions); 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 && + !ext.plugins.length && + !ext.keyboardShortcuts && + !ext.inputRules + ) { + return undefined; + } - // "blocknote" extensions (prosemirror plugins) - return Extension.create({ - name: key, - priority: ext.priority, - addProseMirrorPlugins: () => ext.plugins, - }); - }), + // "blocknote" extensions (prosemirror plugins) + return Extension.create({ + name: key, + priority: ext.priority, + addProseMirrorPlugins: () => ext.plugins, + addInputRules: ext.inputRules + ? () => + ext.inputRules!.map( + (inputRule) => + new InputRule({ + find: inputRule.find, + handler: ({ range, match }) => { + this.transact((tr) => { + const replaceWith = inputRule.replace({ + match, + range, + editor: this, + }); + if (replaceWith) { + const blockInfo = getBlockInfoFromTransaction(tr); + + // TODO this is weird, why do we need it? + if ( + blockInfo.isBlockContainer && + blockInfo.blockContent.node.type.spec + .content === "inline*" + ) { + updateBlockTr( + tr, + blockInfo.bnBlock.beforePos, + replaceWith, + range.from, + range.to, + ); + // TODO there is something here, but we should get rid of the other default blocks + const from = tr.mapping.map(range.from); + const to = tr.mapping.map(range.to); + tr.delete(from, to); + tr.setSelection( + TextSelection.create(tr.doc, from), + ); + } + } + }); + }, + }), + ) + : undefined, + addKeyboardShortcuts: ext.keyboardShortcuts + ? () => { + return Object.fromEntries( + Object.entries(ext.keyboardShortcuts!).map( + ([key, value]) => [key, () => value({ editor: this })], + ), + ); + } + : undefined, + }); + }, + ), ].filter((ext): ext is Extension => ext !== undefined); const tiptapOptions: BlockNoteTipTapEditorOptions = { diff --git a/packages/core/src/editor/BlockNoteExtension.ts b/packages/core/src/editor/BlockNoteExtension.ts index 0281943575..55561f5b3f 100644 --- a/packages/core/src/editor/BlockNoteExtension.ts +++ b/packages/core/src/editor/BlockNoteExtension.ts @@ -1,6 +1,8 @@ import { Plugin } from "prosemirror-state"; import { EventEmitter } from "../util/EventEmitter.js"; +import { BlockNoteEditor } from "./BlockNoteEditor.js"; + export abstract class BlockNoteExtension< TEvent extends Record = any, > extends EventEmitter { @@ -23,4 +25,50 @@ 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 + | { + // TODO types + type: string; + props: Partial>; + children?: any[]; + }; +}; diff --git a/packages/core/src/editor/BlockNoteSchema.ts b/packages/core/src/editor/BlockNoteSchema.ts index e9af493884..1fe6e5fb17 100644 --- a/packages/core/src/editor/BlockNoteSchema.ts +++ b/packages/core/src/editor/BlockNoteSchema.ts @@ -105,3 +105,6 @@ export class BlockNoteSchema< this.styleSchema = getStyleSchemaFromSpecs(this.styleSpecs) as any; } } + +// const s = BlockNoteSchema.create(); +// s.blockSchema.audio. diff --git a/packages/core/src/editor/playground.ts b/packages/core/src/editor/playground.ts new file mode 100644 index 0000000000..48458643dd --- /dev/null +++ b/packages/core/src/editor/playground.ts @@ -0,0 +1,196 @@ +import { + audio, + bulletListItem, + checkListItem, + heading, + numberedListItem, + pageBreak, + paragraph, + quoteBlock, + toggleListItem, + file, + image, + video, +} from "../blks/index.js"; +import { + defaultInlineContentSpecs, + defaultStyleSpecs, +} from "../blocks/defaultBlocks.js"; +import { BlockDefinition } from "../schema/blocks/playground.js"; +import { + BlockSpecs, + InlineContentSchema, + InlineContentSchemaFromSpecs, + InlineContentSpecs, + PropSchema, + StyleSchema, + StyleSchemaFromSpecs, + StyleSpecs, + createBlockSpec, + getInlineContentSchemaFromSpecs, + getStyleSchemaFromSpecs, +} from "../schema/index.js"; + +function removeUndefined | undefined>(obj: T): T { + if (!obj) { + return obj; + } + return Object.fromEntries( + Object.entries(obj).filter(([, value]) => value !== undefined), + ) as T; +} + +const defaultBlockSpecs = { + audio: audio.definition, + bulletListItem: bulletListItem.definition, + checkListItem: checkListItem.definition, + heading: heading.definition, + numberedListItem: numberedListItem.definition, + pageBreak: pageBreak.definition, + paragraph: paragraph.definition, + quoteBlock: quoteBlock.definition, + toggleListItem: toggleListItem.definition, + file: file.definition, + image: image.definition, + video: video.definition, +}; + +export class BlockNoteSchema2< + BSpecs extends Record< + string, + (options: TOptions) => BlockDefinition + >, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema, + TOptions extends Partial< + Record> + > = Record, +> { + private _blockSpecs: BSpecs; + public get blockSpecs(): BlockSpecs { + const obj = Object.fromEntries( + Object.entries(this._blockSpecs).map(([key, value]) => { + const blockDef = value(this.options); + return [ + key, + Object.assign( + createBlockSpec(blockDef.config, blockDef.implementation as any), + { + extensions: blockDef.extensions, + }, + ), + ]; + }), + ) as any; + return obj; + } + public readonly inlineContentSpecs: InlineContentSpecs; + public readonly styleSpecs: StyleSpecs; + + public get blockSchema(): Record< + keyof BSpecs, + ReturnType + > { + const obj = Object.fromEntries( + Object.entries(this._blockSpecs).map(([key, value]) => { + const blockDef = value(this.options); + return [key, blockDef.config]; + }), + ) as any; + return obj; + } + public readonly inlineContentSchema: ISchema; + public readonly styleSchema: SSchema; + + public readonly options: TOptions = {} as TOptions; + + public static create< + BSpecs extends Record< + string, + (options: TOptions) => BlockDefinition + >, + ISpecs extends InlineContentSpecs = typeof defaultInlineContentSpecs, + SSpecs extends StyleSpecs = typeof defaultStyleSpecs, + TOptions extends Partial< + Record> + > = Record, + >( + options?: { + /** + * A list of custom block types that should be available in the editor. + */ + blockSpecs?: BSpecs; + /** + * A list of custom InlineContent types that should be available in the editor. + */ + inlineContentSpecs?: ISpecs; + /** + * A list of custom Styles that should be available in the editor. + */ + styleSpecs?: SSpecs; + }, + configOptions?: TOptions, + ) { + return new BlockNoteSchema2< + Record< + keyof BSpecs, + (options: TOptions) => BlockDefinition + >, + InlineContentSchemaFromSpecs, + StyleSchemaFromSpecs, + TOptions + >(options as any, configOptions); + } + + constructor( + opts?: { + blockSpecs?: BSpecs; + inlineContentSpecs?: InlineContentSpecs; + styleSpecs?: StyleSpecs; + }, + configOptions?: TOptions, + ) { + this._blockSpecs = opts?.blockSpecs || (defaultBlockSpecs as any); + this.inlineContentSpecs = + removeUndefined(opts?.inlineContentSpecs) || defaultInlineContentSpecs; + this.styleSpecs = removeUndefined(opts?.styleSpecs) || defaultStyleSpecs; + + this.inlineContentSchema = getInlineContentSchemaFromSpecs( + this.inlineContentSpecs, + ) as any; + this.styleSchema = getStyleSchemaFromSpecs(this.styleSpecs) as any; + this.options = configOptions || ({} as TOptions); + } + + /** + * This will add the options per block spec, allowing you to configure the block spec + */ + public config>>>( + options: T, + ): BlockNoteSchema2 { + // TODO should this be a deep merge? + Object.assign(this.options, options); + + return this as any; + } + + // public addBlockSpec( + // blockConfig: T, + // blockImplementation: BlockImplementation, + // ): { + // this.blockSpecs[blockConfig.type] = blockConfig; + // } +} + +// { +// blockSpecs: { +// // TODO figure out if this is better or worse, (what are the tradeoffs) +// // this is simpler, but it's not as flexible +// heading({levels:3}), +// }, +// } +// const schema = BlockNoteSchema.create().config({ +// heading: { +// levels: [1, 2, 3], +// }, +// }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 74f91bee5c..f423fa93eb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,6 +27,7 @@ export * from "./blocks/PageBreakBlockContent/getPageBreakSlashMenuItems.js"; export * from "./blocks/PageBreakBlockContent/PageBreakBlockContent.js"; export * from "./blocks/PageBreakBlockContent/schema.js"; export * from "./blocks/ToggleWrapper/createToggleWrapper.js"; +export { BlockNoteSchema2 } from "./editor/playground.js"; export { EMPTY_CELL_HEIGHT, EMPTY_CELL_WIDTH, diff --git a/packages/core/src/schema/blocks/playground.ts b/packages/core/src/schema/blocks/playground.ts new file mode 100644 index 0000000000..3c75a51a40 --- /dev/null +++ b/packages/core/src/schema/blocks/playground.ts @@ -0,0 +1,194 @@ +import { ViewMutationRecord } from "prosemirror-view"; +import type { Props, PropSchema } from "../../schema/index.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { Block } from "../../blocks/defaultBlocks.js"; +import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; + +export type BlockDefs = Record>; + +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 interface BlockConfig< + TName extends string, + TSchema extends PropSchema = PropSchema, +> { + /** + * 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: "inline" | "none"; + // TODO: how do you represent things that have nested content? + // e.g. tables, alerts (with title & content) + /** + * Metadata + */ + meta?: BlockConfigMeta; +} + +export interface BlockImplementation< + TName extends string, + TProps extends PropSchema, +> { + /** + * A function that converts the block into a DOM element + */ + render: ( + /** + * The custom block to render + */ + block: Block>>, + /** + * The BlockNote editor instance + */ + editor: BlockNoteEditor>>, + ) => { + dom: HTMLElement; + 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: Block>>, + editor: BlockNoteEditor>>, + ) => + | { + 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; + + // TODO there needs to be simper way to do this, it is a bit of a gap to force them to bridge html to block content + // parseContent?: ( + // el: HTMLElement, + // ) => Block>>["children"]; + + // // TODO this should be only on extensions, not on the block config + // /** + // * Input rules for the block + // */ + // inputRules?: { + // /** + // * The regex to match when to trigger the input rule + // */ + // find: RegExp; + // /** + // * The function to call when the input rule is matched + // */ + // replace: (props: { match: RegExpMatchArray }) => + // | undefined + // | { + // type: string; + // props: Partial>; + // }; + // }[]; + + // keymap?: { + // [key: string]: { + // type: "replace" | "insert"; + // predicate: ( + // editor: BlockNoteEditor>>, + // ) => boolean; + // action: ( + // editor: BlockNoteEditor>>, + // ) => boolean; + // }; + // }; +} + +// input rules & keyboard shortcuts where do they fit into this? + +export type BlockDefinition< + TName extends string = string, + TProps extends PropSchema = PropSchema, +> = { + config: BlockConfig; + implementation: BlockImplementation, NoInfer>; + extensions?: BlockNoteExtension[]; +}; + +export function createBlockConfig< + TCallback extends ( + options: Partial>, + ) => BlockConfig, + TOptions extends Parameters[0], + TName extends ReturnType["type"], + TProps extends ReturnType["propSchema"], +>(callback: TCallback): (options: TOptions) => BlockConfig { + return callback; +} + +export type ExtractOptions = T extends (options: infer TOptions) => any + ? TOptions + : never; + +export type ExtractBlockConfig = T extends ( + options: any, +) => BlockDefinition + ? BlockConfig + : never; + +export type ExtractBlockImplementation = T extends ( + options: any, +) => BlockDefinition + ? BlockImplementation + : never; + +export type ExtractBlock = T extends ( + options: any, +) => BlockDefinition + ? Block>> + : never; + +export function createBlockSpec< + TCallback extends (options: any) => BlockConfig, + TOptions extends Parameters[0], + TName extends ReturnType["type"], + TProps extends ReturnType["propSchema"], +>( + callback: TCallback, +): { + implementation: ( + cb: (options: TOptions) => BlockImplementation, + addExtensions?: (options: TOptions) => BlockNoteExtension[], + ) => (options: TOptions) => BlockDefinition; +} { + return { + implementation: (cb, addExtensions) => (options) => ({ + config: callback(options), + implementation: cb(options), + extensions: addExtensions?.(options), + }), + }; +} diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 0f97205638..a5075fd6b5 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -21,6 +21,8 @@ export type BlockNoteDOMAttributes = Partial<{ [DOMElement in BlockNoteDOMElement]: Record; }>; +// TODO we should remove FileBlockConfig, and only use BlockConfig +// Ideally something like this would be represented via `groups: ["file"]` or similar export type FileBlockConfig = { type: string; readonly propSchema: PropSchema & { From e1bb45ea53591e8140507e6340b1c6ddc00dc8a5 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 30 Jul 2025 17:21:52 +0200 Subject: [PATCH 02/19] feat: topological sorting, input rules and keyboard shortcuts --- examples/01-basic/01-minimal/src/App.tsx | 10 - .../commands/splitBlock/splitBlock.ts | 59 +++--- packages/core/src/blks/Audio/definition.ts | 1 + .../src/blks/BulletListItem/definition.ts | 84 ++++---- .../core/src/blks/CheckListItem/definition.ts | 113 +++++------ packages/core/src/blks/Heading/definition.ts | 92 ++++----- packages/core/src/blks/Image/definition.ts | 1 + .../blks/NumberedListItem/IndexingPlugin.ts | 27 +-- .../src/blks/NumberedListItem/definition.ts | 89 +++++---- .../core/src/blks/Paragraph/definition.ts | 1 + .../core/src/blks/QuoteBlock/definition.ts | 77 ++++---- .../src/blks/ToggleListItem/definition.ts | 60 +++--- packages/core/src/blks/Video/definition.ts | 1 + .../src/blks/utils/listItemEnterHandler.ts | 42 ++++ packages/core/src/editor/BlockNoteEditor.ts | 102 +++++----- packages/core/src/editor/playground.ts | 186 +++++++++--------- .../KeyboardShortcutsExtension.ts | 2 + .../core/src/extensions/UniqueID/UniqueID.ts | 19 +- packages/core/src/schema/blocks/createSpec.ts | 2 + packages/core/src/schema/blocks/playground.ts | 22 +++ packages/core/src/util/topo-sort.test.ts | 125 ++++++++++++ packages/core/src/util/topo-sort.ts | 154 +++++++++++++++ 22 files changed, 795 insertions(+), 474 deletions(-) create mode 100644 packages/core/src/blks/utils/listItemEnterHandler.ts create mode 100644 packages/core/src/util/topo-sort.test.ts create mode 100644 packages/core/src/util/topo-sort.ts diff --git a/examples/01-basic/01-minimal/src/App.tsx b/examples/01-basic/01-minimal/src/App.tsx index 53d990c454..8c18e2b2f4 100644 --- a/examples/01-basic/01-minimal/src/App.tsx +++ b/examples/01-basic/01-minimal/src/App.tsx @@ -10,16 +10,6 @@ export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ schema: schema as any, - initialContent: [ - { - type: "numberedListItem", - content: "Numbered List Item 1", - }, - { - type: "numberedListItem", - content: "Numbered List Item 2", - }, - ], }); // Renders the editor instance using a React component. 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/blks/Audio/definition.ts b/packages/core/src/blks/Audio/definition.ts index a7e9712e80..46e85dc65c 100644 --- a/packages/core/src/blks/Audio/definition.ts +++ b/packages/core/src/blks/Audio/definition.ts @@ -126,4 +126,5 @@ export const definition = createBlockSpec(config).implementation((config) => ({ dom: audio, }; }, + runsBefore: ["file"], })); diff --git a/packages/core/src/blks/BulletListItem/definition.ts b/packages/core/src/blks/BulletListItem/definition.ts index 291973d899..dd0db13b91 100644 --- a/packages/core/src/blks/BulletListItem/definition.ts +++ b/packages/core/src/blks/BulletListItem/definition.ts @@ -1,11 +1,12 @@ import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; import { defaultProps } from "../../blocks/defaultProps.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, + createBlockNoteExtension, createBlockSpec, } from "../../schema/blocks/playground.js"; +import { handleEnter } from "../utils/listItemEnterHandler.js"; const config = createBlockConfig(() => ({ type: "bulletListItem" as const, @@ -15,47 +16,6 @@ const config = createBlockConfig(() => ({ content: "inline", })); -export class BulletListItemExtension extends BlockNoteExtension { - public static key() { - return "bullet-list-item-shortcuts"; - } - - constructor() { - super(); - this.inputRules = [ - { - find: new RegExp(`^[-+*]\\s$`), - replace() { - return { - type: "bulletListItem", - props: {}, - }; - }, - }, - ]; - - this.keyboardShortcuts = { - "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; - }), - }; - } -} - export const definition = createBlockSpec(config).implementation( () => ({ parse(element) { @@ -99,5 +59,43 @@ export const definition = createBlockSpec(config).implementation( }; }, }), - () => [new BulletListItemExtension()], + () => [ + 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 index 9e1319e55c..bb0be2f81a 100644 --- a/packages/core/src/blks/CheckListItem/definition.ts +++ b/packages/core/src/blks/CheckListItem/definition.ts @@ -1,11 +1,12 @@ import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; import { defaultProps } from "../../blocks/defaultProps.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, + createBlockNoteExtension, createBlockSpec, } from "../../schema/blocks/playground.js"; +import { handleEnter } from "../utils/listItemEnterHandler.js"; const config = createBlockConfig(() => ({ type: "checkListItem" as const, @@ -16,60 +17,6 @@ const config = createBlockConfig(() => ({ content: "inline", })); -export class CheckListItemExtension extends BlockNoteExtension { - public static key() { - return "check-list-item-shortcuts"; - } - - constructor() { - super(); - this.inputRules = [ - { - find: new RegExp(`\\[\\s*\\]\\s$`), - replace() { - return { - type: "checkListItem", - props: { - checked: false, - }, - }; - }, - }, - { - find: new RegExp(`\\[[Xx]\\]\\s$`), - replace() { - return { - type: "checkListItem", - props: { - checked: true, - }, - }; - }, - }, - ]; - - this.keyboardShortcuts = { - "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; - }), - }; - } -} - export const definition = createBlockSpec(config).implementation( () => ({ parse(element) { @@ -138,6 +85,60 @@ export const definition = createBlockSpec(config).implementation( contentDOM: paragraphEl, }; }, + runsBefore: ["bulletListItem"], }), - () => [new CheckListItemExtension()], + () => [ + 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() { + console.log("trigger"); + 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/Heading/definition.ts b/packages/core/src/blks/Heading/definition.ts index ff262993c0..e84a0b86d3 100644 --- a/packages/core/src/blks/Heading/definition.ts +++ b/packages/core/src/blks/Heading/definition.ts @@ -1,9 +1,9 @@ import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; import { createToggleWrapper } from "../../blocks/ToggleWrapper/createToggleWrapper.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, + createBlockNoteExtension, createBlockSpec, } from "../../schema/blocks/playground.js"; @@ -30,53 +30,6 @@ const config = createBlockConfig( content: "inline", }), ); -export class HeadingExtension extends BlockNoteExtension { - public static key() { - return "heading-shortcuts"; - } - - constructor(options: HeadingOptions) { - super(); - this.keyboardShortcuts = Object.fromEntries( - (options.levels ?? HEADING_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; - }), - ]) ?? [], - ); - - this.inputRules = (options.levels ?? HEADING_LEVELS).map((level) => ({ - find: new RegExp(`^(#{${level}})\\s$`), - replace({ match }: { match: RegExpMatchArray }) { - return { - type: "heading", - props: { - level: match[1].length, - }, - }; - }, - })); - } -} export const definition = createBlockSpec(config).implementation( ({ allowToggleHeadings }) => ({ @@ -107,5 +60,46 @@ export const definition = createBlockSpec(config).implementation( }; }, }), - (options) => [new HeadingExtension(options)], + (options) => [ + createBlockNoteExtension({ + key: "heading-shortcuts", + keyboardShortcuts: Object.fromEntries( + (options.levels ?? HEADING_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: (options.levels ?? HEADING_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 index d29653b8cc..2cb36afb92 100644 --- a/packages/core/src/blks/Image/definition.ts +++ b/packages/core/src/blks/Image/definition.ts @@ -142,4 +142,5 @@ export const definition = createBlockSpec(config).implementation((config) => ({ dom: image, }; }, + runsBefore: ["file"], })); diff --git a/packages/core/src/blks/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blks/NumberedListItem/IndexingPlugin.ts index cb2fb80ca7..370cddbefd 100644 --- a/packages/core/src/blks/NumberedListItem/IndexingPlugin.ts +++ b/packages/core/src/blks/NumberedListItem/IndexingPlugin.ts @@ -114,27 +114,12 @@ function getDecorations( if (existingDecorations.length === 0) { // Create a widget decoration to display the index decorationsToAdd.push( - Decoration.widget( - pos + 1, - () => { - const element = document.createElement("span"); - element.classList.add("numbered-list-index"); - element.textContent = index.toString(); - - // Add data attributes for styling - element.setAttribute("data-index", index.toString()); - element.setAttribute("data-is-first", isFirst.toString()); - element.setAttribute("data-has-start", hasStart.toString()); - - return element; - }, - { - index, - isFirst, - hasStart, - side: -1, - } satisfies DecoSpec, - ), + // 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, + }), ); } } diff --git a/packages/core/src/blks/NumberedListItem/definition.ts b/packages/core/src/blks/NumberedListItem/definition.ts index 3edfc23ecd..ed5d8ea49e 100644 --- a/packages/core/src/blks/NumberedListItem/definition.ts +++ b/packages/core/src/blks/NumberedListItem/definition.ts @@ -1,11 +1,12 @@ import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; import { defaultProps } from "../../blocks/defaultProps.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, + createBlockNoteExtension, createBlockSpec, } from "../../schema/blocks/playground.js"; +import { handleEnter } from "../utils/listItemEnterHandler.js"; import { NumberedListIndexingDecorationPlugin } from "./IndexingPlugin.js"; const config = createBlockConfig(() => ({ @@ -17,49 +18,6 @@ const config = createBlockConfig(() => ({ content: "inline", })); -export class NumberedListItemExtension extends BlockNoteExtension { - public static key() { - return "numbered-list-item-shortcuts"; - } - - constructor() { - super(); - this.inputRules = [ - { - find: new RegExp(`^\\d+\\.\\s$`), - replace() { - return { - type: "numberedListItem", - props: {}, - }; - }, - }, - ]; - - this.keyboardShortcuts = { - "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; - }), - }; - - this.addProsemirrorPlugin(NumberedListIndexingDecorationPlugin()); - } -} - export const definition = createBlockSpec(config).implementation( () => ({ parse(element) { @@ -103,5 +61,46 @@ export const definition = createBlockSpec(config).implementation( }; }, }), - () => [new NumberedListItemExtension()], + () => [ + 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/Paragraph/definition.ts b/packages/core/src/blks/Paragraph/definition.ts index a4a106d14b..893ac2d8fc 100644 --- a/packages/core/src/blks/Paragraph/definition.ts +++ b/packages/core/src/blks/Paragraph/definition.ts @@ -59,6 +59,7 @@ export const definition = createBlockSpec(config).implementation( contentDOM: dom, }; }, + runsBefore: ["default"], }), () => [new ParagraphExtension()], ); diff --git a/packages/core/src/blks/QuoteBlock/definition.ts b/packages/core/src/blks/QuoteBlock/definition.ts index 9f9be4b89d..8a6725c71d 100644 --- a/packages/core/src/blks/QuoteBlock/definition.ts +++ b/packages/core/src/blks/QuoteBlock/definition.ts @@ -1,9 +1,9 @@ import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; import { defaultProps } from "../../blocks/defaultProps.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, + createBlockNoteExtension, createBlockSpec, } from "../../schema/blocks/playground.js"; @@ -13,46 +13,6 @@ const config = createBlockConfig(() => ({ content: "inline", })); -export class QuoteBlockExtension extends BlockNoteExtension { - public static key() { - return "quote-block-shortcuts"; - } - - constructor() { - super(); - this.inputRules = [ - { - find: new RegExp(`^>\\s$`), - replace() { - return { - type: "quote", - props: {}, - }; - }, - }, - ]; - - this.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; - }), - }; - } -} - export const definition = createBlockSpec(config).implementation( () => ({ parse(element) { @@ -71,5 +31,38 @@ export const definition = createBlockSpec(config).implementation( }; }, }), - () => [new QuoteBlockExtension()], + () => [ + 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 index 02b171a38e..b6cf9771cc 100644 --- a/packages/core/src/blks/ToggleListItem/definition.ts +++ b/packages/core/src/blks/ToggleListItem/definition.ts @@ -1,11 +1,12 @@ import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; import { defaultProps } from "../../blocks/defaultProps.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, + createBlockNoteExtension, createBlockSpec, } from "../../schema/blocks/playground.js"; +import { handleEnter } from "../utils/listItemEnterHandler.js"; const config = createBlockConfig(() => ({ type: "toggleListItem" as const, @@ -15,35 +16,6 @@ const config = createBlockConfig(() => ({ content: "inline", })); -export class ToggleListItemExtension extends BlockNoteExtension { - public static key() { - return "toggle-list-item-shortcuts"; - } - - constructor() { - super(); - this.keyboardShortcuts = { - "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; - }), - }; - } -} - export const definition = createBlockSpec(config).implementation( () => ({ render() { @@ -59,5 +31,31 @@ export const definition = createBlockSpec(config).implementation( }; }, }), - () => [new ToggleListItemExtension()], + () => [ + 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 index 8f486e2ebc..88cb6aff56 100644 --- a/packages/core/src/blks/Video/definition.ts +++ b/packages/core/src/blks/Video/definition.ts @@ -125,4 +125,5 @@ export const definition = createBlockSpec(config).implementation((config) => ({ dom: video, }; }, + runsBefore: ["file"], })); 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/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index c8c7012374..a0c339a738 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -804,16 +804,13 @@ export class BlockNoteEditor< initialContent, ); } - - console.log(this.schema.blockSchema); 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.constructor.key(), ext]), + .map((ext) => [ext.key?.() ?? ext.constructor.key(), ext]), ); - console.log(blockExtensions); const tiptapExtensions = [ ...Object.entries({ ...this.extensions, ...blockExtensions }).map( ([key, ext]) => { @@ -826,35 +823,37 @@ export class BlockNoteEditor< return ext; } - if ( - ext instanceof BlockNoteExtension && - !ext.plugins.length && - !ext.keyboardShortcuts && - !ext.inputRules - ) { - return undefined; - } - - // "blocknote" extensions (prosemirror plugins) - return Extension.create({ - name: key, - priority: ext.priority, - addProseMirrorPlugins: () => ext.plugins, - addInputRules: ext.inputRules - ? () => - ext.inputRules!.map( - (inputRule) => - new InputRule({ - find: inputRule.find, - handler: ({ range, match }) => { - this.transact((tr) => { + 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, + addInputRules: ext.inputRules + ? () => + ext.inputRules!.map( + (inputRule) => + new InputRule({ + find: inputRule.find, + handler: ({ range, match, state }) => { + console.log("input rule triggered"); const replaceWith = inputRule.replace({ match, range, editor: this, }); if (replaceWith) { - const blockInfo = getBlockInfoFromTransaction(tr); + console.log(replaceWith); + const blockInfo = getBlockInfoFromTransaction( + state.tr, + ); // TODO this is weird, why do we need it? if ( @@ -863,40 +862,41 @@ export class BlockNoteEditor< .content === "inline*" ) { updateBlockTr( - tr, + state.tr, blockInfo.bnBlock.beforePos, replaceWith, range.from, range.to, ); - // TODO there is something here, but we should get rid of the other default blocks - const from = tr.mapping.map(range.from); - const to = tr.mapping.map(range.to); - tr.delete(from, to); - tr.setSelection( - TextSelection.create(tr.doc, from), - ); + // tr.replaceRange( + // range.from, + // range.to, + // Slice.empty, + // ); + return undefined; } } - }); - }, - }), - ) - : undefined, - addKeyboardShortcuts: ext.keyboardShortcuts - ? () => { - return Object.fromEntries( - Object.entries(ext.keyboardShortcuts!).map( - ([key, value]) => [key, () => value({ editor: this })], - ), - ); - } - : undefined, - }); + return null; + }, + }), + ) + : undefined, + addKeyboardShortcuts: ext.keyboardShortcuts + ? () => { + return Object.fromEntries( + Object.entries(ext.keyboardShortcuts!).map( + ([key, value]) => [key, () => value({ editor: this })], + ), + ); + } + : undefined, + }); + } + + return undefined; }, ), ].filter((ext): ext is Extension => ext !== undefined); - const tiptapOptions: BlockNoteTipTapEditorOptions = { ...blockNoteTipTapOptions, ...newOptions._tiptapOptions, diff --git a/packages/core/src/editor/playground.ts b/packages/core/src/editor/playground.ts index 48458643dd..60e679fb7a 100644 --- a/packages/core/src/editor/playground.ts +++ b/packages/core/src/editor/playground.ts @@ -30,6 +30,11 @@ import { getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "../schema/index.js"; +import { + createDependencyGraph, + toposort, + toposortReverse, +} from "../util/topo-sort.js"; function removeUndefined | undefined>(obj: T): T { if (!obj) { @@ -41,13 +46,13 @@ function removeUndefined | undefined>(obj: T): T { } const defaultBlockSpecs = { + paragraph: paragraph.definition, audio: audio.definition, bulletListItem: bulletListItem.definition, checkListItem: checkListItem.definition, heading: heading.definition, numberedListItem: numberedListItem.definition, pageBreak: pageBreak.definition, - paragraph: paragraph.definition, quoteBlock: quoteBlock.definition, toggleListItem: toggleListItem.definition, file: file.definition, @@ -55,102 +60,120 @@ const defaultBlockSpecs = { video: video.definition, }; +type DefaultBlockSpecs = { + [key in keyof typeof defaultBlockSpecs]: ReturnType< + (typeof defaultBlockSpecs)[key] + >; +}; + +type BlockSpecMap = Record< + K, + BlockDefinition +>; export class BlockNoteSchema2< - BSpecs extends Record< - string, - (options: TOptions) => BlockDefinition - >, + BSpecs extends BlockSpecMap, ISchema extends InlineContentSchema, SSchema extends StyleSchema, TOptions extends Partial< Record> > = Record, > { - private _blockSpecs: BSpecs; - public get blockSpecs(): BlockSpecs { - const obj = Object.fromEntries( - Object.entries(this._blockSpecs).map(([key, value]) => { - const blockDef = value(this.options); - return [ - key, - Object.assign( - createBlockSpec(blockDef.config, blockDef.implementation as any), - { - extensions: blockDef.extensions, - }, - ), - ]; - }), - ) as any; - return obj; - } public readonly inlineContentSpecs: InlineContentSpecs; public readonly styleSpecs: StyleSpecs; + public readonly blockSpecs: BlockSpecs; - public get blockSchema(): Record< - keyof BSpecs, - ReturnType - > { - const obj = Object.fromEntries( - Object.entries(this._blockSpecs).map(([key, value]) => { - const blockDef = value(this.options); - return [key, blockDef.config]; - }), - ) as any; - return obj; - } + public readonly blockSchema: Record; public readonly inlineContentSchema: ISchema; public readonly styleSchema: SSchema; public readonly options: TOptions = {} as TOptions; public static create< - BSpecs extends Record< - string, - (options: TOptions) => BlockDefinition - >, + BSpecs extends BlockSpecMap, ISpecs extends InlineContentSpecs = typeof defaultInlineContentSpecs, SSpecs extends StyleSpecs = typeof defaultStyleSpecs, - TOptions extends Partial< - Record> - > = Record, - >( - options?: { - /** - * A list of custom block types that should be available in the editor. - */ - blockSpecs?: BSpecs; - /** - * A list of custom InlineContent types that should be available in the editor. - */ - inlineContentSpecs?: ISpecs; - /** - * A list of custom Styles that should be available in the editor. - */ - styleSpecs?: SSpecs; - }, - configOptions?: TOptions, - ) { + >(options?: { + /** + * A list of custom block types that should be available in the editor. + */ + blockSpecs?: BSpecs; + /** + * A list of custom InlineContent types that should be available in the editor. + */ + inlineContentSpecs?: ISpecs; + /** + * A list of custom Styles that should be available in the editor. + */ + styleSpecs?: SSpecs; + }) { return new BlockNoteSchema2< - Record< - keyof BSpecs, - (options: TOptions) => BlockDefinition - >, + Record>, InlineContentSchemaFromSpecs, - StyleSchemaFromSpecs, - TOptions - >(options as any, configOptions); + StyleSchemaFromSpecs + >(options as any); } - constructor( - opts?: { - blockSpecs?: BSpecs; - inlineContentSpecs?: InlineContentSpecs; - styleSpecs?: StyleSpecs; - }, - configOptions?: TOptions, - ) { - this._blockSpecs = opts?.blockSpecs || (defaultBlockSpecs as any); + constructor(opts?: { + blockSpecs?: BSpecs; + inlineContentSpecs?: InlineContentSpecs; + styleSpecs?: StyleSpecs; + }) { + const specs: BSpecs = + opts?.blockSpecs || + (Object.fromEntries( + Object.entries(defaultBlockSpecs).map(([key, value]) => [ + key, + value({} as never), + ]), + ) as any); + 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); + } + } + console.log(dag); + const sortedSpecs = toposortReverse(dag); + console.log(sortedSpecs); + const defaultIndex = sortedSpecs.findIndex((set) => set.has("default")); + + // the default index should map to 100 + // one before the default index is 90 + // one after is 110 + + this.blockSpecs = Object.fromEntries( + Object.entries(specs).map( + ([key, blockDef]: [string, BlockDefinition]) => { + const index = sortedSpecs.findIndex((set) => set.has(key)); + const priority = 91 + (index + defaultIndex) * 10; + console.log(key, index, priority, blockDef.extensions); + return [ + key, + Object.assign( + { + extensions: blockDef.extensions, + }, + createBlockSpec( + blockDef.config, + blockDef.implementation as any, + priority, + ), + ), + ]; + }, + ), + ); + console.log(this.blockSpecs); + this.blockSchema = Object.fromEntries( + Object.entries(this.blockSpecs).map(([key, blockDef]) => { + return [key, blockDef.config]; + }), + ) as any; this.inlineContentSpecs = removeUndefined(opts?.inlineContentSpecs) || defaultInlineContentSpecs; this.styleSpecs = removeUndefined(opts?.styleSpecs) || defaultStyleSpecs; @@ -159,19 +182,6 @@ export class BlockNoteSchema2< this.inlineContentSpecs, ) as any; this.styleSchema = getStyleSchemaFromSpecs(this.styleSpecs) as any; - this.options = configOptions || ({} as TOptions); - } - - /** - * This will add the options per block spec, allowing you to configure the block spec - */ - public config>>>( - options: T, - ): BlockNoteSchema2 { - // TODO should this be a deep merge? - Object.assign(this.options, options); - - return this as any; } // public addBlockSpec( 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/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..514114ab44 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -141,6 +141,7 @@ export function createBlockSpec< >( blockConfig: T, blockImplementation: CustomBlockImplementation, I, S>, + priority?: number, ) { const node = createStronglyTypedTiptapNode({ name: blockConfig.type as T["type"], @@ -150,6 +151,7 @@ export function createBlockSpec< group: "blockContent", selectable: blockConfig.isSelectable ?? true, isolating: true, + priority, addAttributes() { return propsToAttributes(blockConfig.propSchema); }, diff --git a/packages/core/src/schema/blocks/playground.ts b/packages/core/src/schema/blocks/playground.ts index 3c75a51a40..af0aaba974 100644 --- a/packages/core/src/schema/blocks/playground.ts +++ b/packages/core/src/schema/blocks/playground.ts @@ -89,6 +89,12 @@ export interface BlockImplementation< */ 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[]; + // TODO there needs to be simper way to do this, it is a bit of a gap to force them to bridge html to block content // parseContent?: ( // el: HTMLElement, @@ -192,3 +198,19 @@ export function createBlockSpec< }), }; } +/** + * 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/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..d3dd9ff4f7 --- /dev/null +++ b/packages/core/src/util/topo-sort.ts @@ -0,0 +1,154 @@ +// Based on https://github.com/n1ru4l/toposort/blob/main/src/toposort.ts (MIT) + +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; +} From 6b10846b426554f6780530210adc716007be95dd Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 31 Jul 2025 16:19:39 +0200 Subject: [PATCH 03/19] feat: move code block over too --- examples/01-basic/01-minimal/package.json | 3 +- examples/01-basic/01-minimal/src/App.tsx | 5 +- packages/core/src/blks/Code/definition.ts | 269 ++++++++++++++++++ packages/core/src/blks/Code/shiki.ts | 73 +++++ .../core/src/blks/Paragraph/definition.ts | 56 ++-- .../blks/{QuoteBlock => Quote}/definition.ts | 0 packages/core/src/blks/index.ts | 3 +- packages/core/src/editor/BlockNoteEditor.ts | 8 +- .../core/src/editor/BlockNoteExtension.ts | 1 + packages/core/src/editor/playground.ts | 61 ++-- packages/core/src/schema/blocks/createSpec.ts | 2 + packages/core/src/schema/blocks/playground.ts | 14 +- pnpm-lock.yaml | 3 + 13 files changed, 428 insertions(+), 70 deletions(-) create mode 100644 packages/core/src/blks/Code/definition.ts create mode 100644 packages/core/src/blks/Code/shiki.ts rename packages/core/src/blks/{QuoteBlock => Quote}/definition.ts (100%) 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/01-basic/01-minimal/src/App.tsx b/examples/01-basic/01-minimal/src/App.tsx index 8c18e2b2f4..f0a129c00c 100644 --- a/examples/01-basic/01-minimal/src/App.tsx +++ b/examples/01-basic/01-minimal/src/App.tsx @@ -3,8 +3,11 @@ import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; import { BlockNoteSchema2 } from "@blocknote/core"; +import { codeBlock } from "@blocknote/code-block"; -const schema = BlockNoteSchema2.create(); +const schema = BlockNoteSchema2.create(undefined, { + codeBlock, +}); export default function App() { // Creates a new editor instance. diff --git a/packages/core/src/blks/Code/definition.ts b/packages/core/src/blks/Code/definition.ts new file mode 100644 index 0000000000..0611fec290 --- /dev/null +++ b/packages/core/src/blks/Code/definition.ts @@ -0,0 +1,269 @@ +import type { HighlighterGeneric } from "@shikijs/types"; +import { + createBlockConfig, + createBlockNoteExtension, + createBlockSpec, +} from "../../schema/blocks/playground.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, + }, + }), +); + +export const definition = createBlockSpec(config).implementation( + (options) => ({ + parse: (e) => { + const pre = e.querySelector("pre"); + if (!pre) { + return undefined; + } + + return {}; + }, + + // TODO parsecontent + + render(block, editor) { + const wrapper = document.createDocumentFragment(); + const pre = document.createElement("pre"); + const code = document.createElement("code"); + code.textContent = block.content; + 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; + 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/Paragraph/definition.ts b/packages/core/src/blks/Paragraph/definition.ts index 893ac2d8fc..7f4057095c 100644 --- a/packages/core/src/blks/Paragraph/definition.ts +++ b/packages/core/src/blks/Paragraph/definition.ts @@ -1,9 +1,9 @@ import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoFromTransaction } from "../../api/getBlockInfoFromPos.js"; import { defaultProps } from "../../blocks/defaultProps.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, + createBlockNoteExtension, createBlockSpec, } from "../../schema/blocks/playground.js"; @@ -13,35 +13,6 @@ const config = createBlockConfig(() => ({ content: "inline", })); -export class ParagraphExtension extends BlockNoteExtension { - public static key() { - return "paragraph-shortcuts"; - } - - constructor() { - super(); - this.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; - }), - }; - } -} - export const definition = createBlockSpec(config).implementation( () => ({ parse: (e) => { @@ -61,5 +32,28 @@ export const definition = createBlockSpec(config).implementation( }, runsBefore: ["default"], }), - () => [new ParagraphExtension()], + () => [ + 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/QuoteBlock/definition.ts b/packages/core/src/blks/Quote/definition.ts similarity index 100% rename from packages/core/src/blks/QuoteBlock/definition.ts rename to packages/core/src/blks/Quote/definition.ts diff --git a/packages/core/src/blks/index.ts b/packages/core/src/blks/index.ts index c8787ae991..a501019f03 100644 --- a/packages/core/src/blks/index.ts +++ b/packages/core/src/blks/index.ts @@ -1,11 +1,12 @@ 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 quoteBlock from "./QuoteBlock/definition.js"; +export * as quoteBlock from "./Quote/definition.js"; export * as toggleListItem from "./ToggleListItem/definition.js"; export * as file from "./File/definition.js"; diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index a0c339a738..0765754905 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -702,7 +702,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`, @@ -809,7 +809,7 @@ export class BlockNoteEditor< .map((block) => (block as any).extensions as any) .filter((ext) => ext !== undefined) .flat() - .map((ext) => [ext.key?.() ?? ext.constructor.key(), ext]), + .map((ext) => [ext.key ?? ext.constructor.key(), ext]), ); const tiptapExtensions = [ ...Object.entries({ ...this.extensions, ...blockExtensions }).map( @@ -836,6 +836,8 @@ export class BlockNoteEditor< 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( @@ -843,14 +845,12 @@ export class BlockNoteEditor< new InputRule({ find: inputRule.find, handler: ({ range, match, state }) => { - console.log("input rule triggered"); const replaceWith = inputRule.replace({ match, range, editor: this, }); if (replaceWith) { - console.log(replaceWith); const blockInfo = getBlockInfoFromTransaction( state.tr, ); diff --git a/packages/core/src/editor/BlockNoteExtension.ts b/packages/core/src/editor/BlockNoteExtension.ts index 55561f5b3f..92a94ed7f7 100644 --- a/packages/core/src/editor/BlockNoteExtension.ts +++ b/packages/core/src/editor/BlockNoteExtension.ts @@ -70,5 +70,6 @@ export type InputRule = { type: string; props: Partial>; children?: any[]; + content?: any[]; }; }; diff --git a/packages/core/src/editor/playground.ts b/packages/core/src/editor/playground.ts index 60e679fb7a..2342ac1c21 100644 --- a/packages/core/src/editor/playground.ts +++ b/packages/core/src/editor/playground.ts @@ -2,6 +2,7 @@ import { audio, bulletListItem, checkListItem, + codeBlock, heading, numberedListItem, pageBreak, @@ -30,11 +31,7 @@ import { getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "../schema/index.js"; -import { - createDependencyGraph, - toposort, - toposortReverse, -} from "../util/topo-sort.js"; +import { createDependencyGraph, toposortReverse } from "../util/topo-sort.js"; function removeUndefined | undefined>(obj: T): T { if (!obj) { @@ -50,6 +47,7 @@ const defaultBlockSpecs = { audio: audio.definition, bulletListItem: bulletListItem.definition, checkListItem: checkListItem.definition, + codeBlock: codeBlock.definition, heading: heading.definition, numberedListItem: numberedListItem.definition, pageBreak: pageBreak.definition, @@ -92,42 +90,48 @@ export class BlockNoteSchema2< BSpecs extends BlockSpecMap, 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; - /** - * A list of custom InlineContent types that should be available in the editor. - */ - inlineContentSpecs?: ISpecs; - /** - * A list of custom Styles that should be available in the editor. - */ - styleSpecs?: SSpecs; - }) { + >( + options?: { + /** + * A list of custom block types that should be available in the editor. + */ + blockSpecs?: BSpecs; + /** + * A list of custom InlineContent types that should be available in the editor. + */ + inlineContentSpecs?: ISpecs; + /** + * A list of custom Styles that should be available in the editor. + */ + styleSpecs?: SSpecs; + }, + config?: Record, + ) { return new BlockNoteSchema2< Record>, InlineContentSchemaFromSpecs, StyleSchemaFromSpecs - >(options as any); + >(options as any, config as any); } - constructor(opts?: { - blockSpecs?: BSpecs; - inlineContentSpecs?: InlineContentSpecs; - styleSpecs?: StyleSpecs; - }) { + constructor( + opts?: { + blockSpecs?: BSpecs; + inlineContentSpecs?: InlineContentSpecs; + styleSpecs?: StyleSpecs; + }, + config?: Record, + ) { const specs: BSpecs = opts?.blockSpecs || (Object.fromEntries( Object.entries(defaultBlockSpecs).map(([key, value]) => [ key, - value({} as never), + value(({ ...config }[key] ?? {}) as never), ]), ) as any); const dag = createDependencyGraph(); - const defaultSet = new Set(); + const defaultSet = new Set(); dag.set("default", defaultSet); for (const [key, specDef] of Object.entries(specs)) { @@ -137,9 +141,7 @@ export class BlockNoteSchema2< defaultSet.add(key); } } - console.log(dag); const sortedSpecs = toposortReverse(dag); - console.log(sortedSpecs); const defaultIndex = sortedSpecs.findIndex((set) => set.has("default")); // the default index should map to 100 @@ -151,7 +153,6 @@ export class BlockNoteSchema2< ([key, blockDef]: [string, BlockDefinition]) => { const index = sortedSpecs.findIndex((set) => set.has(key)); const priority = 91 + (index + defaultIndex) * 10; - console.log(key, index, priority, blockDef.extensions); return [ key, Object.assign( diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 514114ab44..0c395c634d 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -151,6 +151,8 @@ export function createBlockSpec< group: "blockContent", selectable: blockConfig.isSelectable ?? true, isolating: true, + code: blockConfig.meta?.code ?? false, + defining: blockConfig.meta?.defining ?? false, priority, addAttributes() { return propsToAttributes(blockConfig.propSchema); diff --git a/packages/core/src/schema/blocks/playground.ts b/packages/core/src/schema/blocks/playground.ts index af0aaba974..9b275ef39f 100644 --- a/packages/core/src/schema/blocks/playground.ts +++ b/packages/core/src/schema/blocks/playground.ts @@ -16,6 +16,16 @@ export interface BlockConfigMeta { * The accept mime types for the file block */ fileBlockAccept?: string[]; + + /** + * 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; } /** @@ -64,7 +74,7 @@ export interface BlockImplementation< */ editor: BlockNoteEditor>>, ) => { - dom: HTMLElement; + dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; @@ -208,7 +218,7 @@ export function createBlockNoteExtension( > & { key: string }, ) { const x = Object.create(BlockNoteExtension.prototype); - x.key = () => options.key; + x.key = options.key; x.inputRules = options.inputRules; x.keyboardShortcuts = options.keyboardShortcuts; x.plugins = options.plugins ?? []; 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 From 92952e7b2ae262d8cad663dc0882a2a304e1c21e Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 1 Aug 2025 12:21:43 +0200 Subject: [PATCH 04/19] feat: `getContent` parsing for custom blocks --- .../src/blks/BulletListItem/definition.ts | 11 ++-- .../core/src/blks/CheckListItem/definition.ts | 12 ++-- packages/core/src/blks/Code/definition.ts | 2 - .../src/blks/NumberedListItem/definition.ts | 11 ++-- .../core/src/blocks/defaultBlockHelpers.ts | 4 +- packages/core/src/schema/blocks/createSpec.ts | 55 +++++++++++++++++-- packages/core/src/schema/blocks/playground.ts | 10 ++-- 7 files changed, 73 insertions(+), 32 deletions(-) diff --git a/packages/core/src/blks/BulletListItem/definition.ts b/packages/core/src/blks/BulletListItem/definition.ts index dd0db13b91..d840f08250 100644 --- a/packages/core/src/blks/BulletListItem/definition.ts +++ b/packages/core/src/blks/BulletListItem/definition.ts @@ -1,6 +1,7 @@ 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, @@ -38,12 +39,10 @@ export const definition = createBlockSpec(config).implementation( return false; }, - // TODO how do we represent this?? - // // As `li` elements can contain multiple paragraphs, we need to merge their contents - // // into a single one so that ProseMirror can parse everything correctly. - // getContent: (node, schema) => - // getListItemContent(node, schema, this.name), - // node: "bulletListItem", + // 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 diff --git a/packages/core/src/blks/CheckListItem/definition.ts b/packages/core/src/blks/CheckListItem/definition.ts index bb0be2f81a..cba8db17dc 100644 --- a/packages/core/src/blks/CheckListItem/definition.ts +++ b/packages/core/src/blks/CheckListItem/definition.ts @@ -1,6 +1,7 @@ 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, @@ -58,12 +59,10 @@ export const definition = createBlockSpec(config).implementation( return; }, - // TODO how do we represent this?? - // // As `li` elements can contain multiple paragraphs, we need to merge their contents - // // into a single one so that ProseMirror can parse everything correctly. - // getContent: (node, schema) => - // getListItemContent(node, schema, this.name), - // node: "bulletListItem", + // 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"); @@ -116,7 +115,6 @@ export const definition = createBlockSpec(config).implementation( { find: new RegExp(`\\[\\s*\\]\\s$`), replace() { - console.log("trigger"); return { type: "checkListItem", props: { diff --git a/packages/core/src/blks/Code/definition.ts b/packages/core/src/blks/Code/definition.ts index 0611fec290..81bd959f1f 100644 --- a/packages/core/src/blks/Code/definition.ts +++ b/packages/core/src/blks/Code/definition.ts @@ -80,8 +80,6 @@ export const definition = createBlockSpec(config).implementation( return {}; }, - // TODO parsecontent - render(block, editor) { const wrapper = document.createDocumentFragment(); const pre = document.createElement("pre"); diff --git a/packages/core/src/blks/NumberedListItem/definition.ts b/packages/core/src/blks/NumberedListItem/definition.ts index ed5d8ea49e..7556b0d005 100644 --- a/packages/core/src/blks/NumberedListItem/definition.ts +++ b/packages/core/src/blks/NumberedListItem/definition.ts @@ -1,6 +1,7 @@ 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, @@ -40,12 +41,10 @@ export const definition = createBlockSpec(config).implementation( return false; }, - // TODO how do we represent this?? - // // As `li` elements can contain multiple paragraphs, we need to merge their contents - // // into a single one so that ProseMirror can parse everything correctly. - // getContent: (node, schema) => - // getListItemContent(node, schema, this.name), - // node: "bulletListItem", + // 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 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/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 0c395c634d..c4d4c08ccf 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, 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"; @@ -87,6 +88,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 +117,37 @@ export function getParseRules( return props; }, + getContent: + config.content === "inline" || config.content === "none" + ? (node, schema) => { + if (customParseContentFunction) { + return customParseContentFunction({ + el: node, + 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) { @@ -147,19 +184,27 @@ export function createBlockSpec< 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 ?? 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 }) { diff --git a/packages/core/src/schema/blocks/playground.ts b/packages/core/src/schema/blocks/playground.ts index 9b275ef39f..59866069e3 100644 --- a/packages/core/src/schema/blocks/playground.ts +++ b/packages/core/src/schema/blocks/playground.ts @@ -3,6 +3,7 @@ import type { Props, PropSchema } from "../../schema/index.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { Block } from "../../blocks/defaultBlocks.js"; import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import { Fragment, Schema } from "prosemirror-model"; export type BlockDefs = Record>; @@ -105,10 +106,11 @@ export interface BlockImplementation< */ runsBefore?: string[]; - // TODO there needs to be simper way to do this, it is a bit of a gap to force them to bridge html to block content - // parseContent?: ( - // el: HTMLElement, - // ) => Block>>["children"]; + /** + * 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; // // TODO this should be only on extensions, not on the block config // /** From 6d85ef5893df27cf4fada8d9e4fa62bfffe28135 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 1 Aug 2025 15:28:17 +0200 Subject: [PATCH 05/19] fix: render toggle lists --- .../core/src/blks/ToggleListItem/definition.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/core/src/blks/ToggleListItem/definition.ts b/packages/core/src/blks/ToggleListItem/definition.ts index b6cf9771cc..1025758f6e 100644 --- a/packages/core/src/blks/ToggleListItem/definition.ts +++ b/packages/core/src/blks/ToggleListItem/definition.ts @@ -1,6 +1,7 @@ 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, @@ -18,17 +19,14 @@ const config = createBlockConfig(() => ({ export const definition = createBlockSpec(config).implementation( () => ({ - render() { - // TODO actual rendering - const div = document.createElement("div"); + render(block, editor) { const paragraphEl = document.createElement("p"); - - div.appendChild(paragraphEl); - - return { - dom: div, - contentDOM: paragraphEl, - }; + const toggleWrapper = createToggleWrapper( + block as any, + editor, + paragraphEl, + ); + return { ...toggleWrapper, contentDOM: paragraphEl }; }, }), () => [ From ec77940f0574cc348bade6f979f8705d84b221e2 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 1 Aug 2025 15:31:09 +0200 Subject: [PATCH 06/19] fix: toggle headings --- packages/core/src/blks/Heading/definition.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/core/src/blks/Heading/definition.ts b/packages/core/src/blks/Heading/definition.ts index e84a0b86d3..635f6d8231 100644 --- a/packages/core/src/blks/Heading/definition.ts +++ b/packages/core/src/blks/Heading/definition.ts @@ -32,7 +32,7 @@ const config = createBlockConfig( ); export const definition = createBlockSpec(config).implementation( - ({ allowToggleHeadings }) => ({ + ({ allowToggleHeadings = true }) => ({ parse(e) { const heading = e.querySelector("h1, h2, h3, h4, h5, h6"); if (!heading) { @@ -49,9 +49,8 @@ export const definition = createBlockSpec(config).implementation( const dom = document.createElement(`h${block.props.level}`); if (allowToggleHeadings) { - const toggleWrapper = createToggleWrapper(block as any, editor, dom); - dom.appendChild(toggleWrapper.dom); - return toggleWrapper; + const toggleWrapper = createToggleWrapper(block, editor, dom); + return { ...toggleWrapper, contentDOM: dom }; } return { From 809ea0a281df3a9a1f40e5be2474f993d9841fa6 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 1 Aug 2025 15:31:30 +0200 Subject: [PATCH 07/19] refactor: custom schema, which depends on no blocks --- packages/core/src/editor/CustomSchema.ts | 112 +++++++++++ packages/core/src/editor/playground.ts | 186 ++++-------------- packages/core/src/schema/blocks/playground.ts | 46 ++--- 3 files changed, 159 insertions(+), 185 deletions(-) create mode 100644 packages/core/src/editor/CustomSchema.ts diff --git a/packages/core/src/editor/CustomSchema.ts b/packages/core/src/editor/CustomSchema.ts new file mode 100644 index 0000000000..292b458a65 --- /dev/null +++ b/packages/core/src/editor/CustomSchema.ts @@ -0,0 +1,112 @@ +import { BlockDefinition } from "../schema/blocks/playground.js"; +import { + BlockSpecs, + 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 class CustomBlockNoteSchema< + BSpecs extends { + [key in string]: BlockDefinition; + }, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema, +> { + public readonly inlineContentSpecs: InlineContentSpecs; + public readonly styleSpecs: StyleSpecs; + public readonly blockSpecs: BlockSpecs; + + public readonly blockSchema: Record; + public readonly inlineContentSchema: ISchema; + public readonly styleSchema: SSchema; + + constructor(opts: { + blockSpecs: BSpecs; + 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: BSpecs) { + 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, + }, + createBlockSpec( + blockDef.config, + blockDef.implementation as any, + getPriority(key), + ), + ), + ]; + }, + ), + ); + } +} diff --git a/packages/core/src/editor/playground.ts b/packages/core/src/editor/playground.ts index 2342ac1c21..1bd25f7b5e 100644 --- a/packages/core/src/editor/playground.ts +++ b/packages/core/src/editor/playground.ts @@ -3,14 +3,14 @@ import { bulletListItem, checkListItem, codeBlock, + file, heading, + image, numberedListItem, pageBreak, paragraph, quoteBlock, toggleListItem, - file, - image, video, } from "../blks/index.js"; import { @@ -19,7 +19,6 @@ import { } from "../blocks/defaultBlocks.js"; import { BlockDefinition } from "../schema/blocks/playground.js"; import { - BlockSpecs, InlineContentSchema, InlineContentSchemaFromSpecs, InlineContentSpecs, @@ -27,20 +26,8 @@ import { StyleSchema, StyleSchemaFromSpecs, 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; -} +import { CustomBlockNoteSchema } from "./CustomSchema.js"; const defaultBlockSpecs = { paragraph: paragraph.definition, @@ -58,150 +45,49 @@ const defaultBlockSpecs = { video: video.definition, }; -type DefaultBlockSpecs = { - [key in keyof typeof defaultBlockSpecs]: ReturnType< - (typeof defaultBlockSpecs)[key] - >; -}; - -type BlockSpecMap = Record< - K, - BlockDefinition ->; export class BlockNoteSchema2< - BSpecs extends BlockSpecMap, + BSpecs extends { + [key in string]: BlockDefinition; + }, ISchema extends InlineContentSchema, SSchema extends StyleSchema, - TOptions extends Partial< - Record> - > = Record, -> { - public readonly inlineContentSpecs: InlineContentSpecs; - public readonly styleSpecs: StyleSpecs; - public readonly blockSpecs: BlockSpecs; - - public readonly blockSchema: Record; - public readonly inlineContentSchema: ISchema; - public readonly styleSchema: SSchema; - - public readonly options: TOptions = {} as TOptions; - +> extends CustomBlockNoteSchema { public static create< - BSpecs extends BlockSpecMap, + BSpecs extends { + [key in string]: BlockDefinition; + }, 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; - /** - * A list of custom InlineContent types that should be available in the editor. - */ - inlineContentSpecs?: ISpecs; - /** - * A list of custom Styles that should be available in the editor. - */ - styleSpecs?: SSpecs; - }, - config?: Record, - ) { + >(options?: { + /** + * A list of custom block types that should be available in the editor. + */ + blockSpecs?: BSpecs; + /** + * A list of custom InlineContent types that should be available in the editor. + */ + inlineContentSpecs?: ISpecs; + /** + * A list of custom Styles that should be available in the editor. + */ + styleSpecs?: SSpecs; + }) { return new BlockNoteSchema2< Record>, InlineContentSchemaFromSpecs, StyleSchemaFromSpecs - >(options as any, config as any); - } - - constructor( - opts?: { - blockSpecs?: BSpecs; - inlineContentSpecs?: InlineContentSpecs; - styleSpecs?: StyleSpecs; - }, - config?: Record, - ) { - const specs: BSpecs = - opts?.blockSpecs || - (Object.fromEntries( - Object.entries(defaultBlockSpecs).map(([key, value]) => [ - key, - value(({ ...config }[key] ?? {}) as never), - ]), - ) as any); - 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 default index should map to 100 - // one before the default index is 90 - // one after is 110 - - this.blockSpecs = Object.fromEntries( - Object.entries(specs).map( - ([key, blockDef]: [string, BlockDefinition]) => { - const index = sortedSpecs.findIndex((set) => set.has(key)); - const priority = 91 + (index + defaultIndex) * 10; - return [ + >({ + blockSpecs: + options?.blockSpecs ?? + (Object.fromEntries( + Object.entries(defaultBlockSpecs).map(([key, value]) => [ key, - Object.assign( - { - extensions: blockDef.extensions, - }, - createBlockSpec( - blockDef.config, - blockDef.implementation as any, - priority, - ), - ), - ]; - }, - ), - ); - console.log(this.blockSpecs); - this.blockSchema = Object.fromEntries( - Object.entries(this.blockSpecs).map(([key, blockDef]) => { - return [key, blockDef.config]; - }), - ) as any; - this.inlineContentSpecs = - removeUndefined(opts?.inlineContentSpecs) || defaultInlineContentSpecs; - this.styleSpecs = removeUndefined(opts?.styleSpecs) || defaultStyleSpecs; - - this.inlineContentSchema = getInlineContentSchemaFromSpecs( - this.inlineContentSpecs, - ) as any; - this.styleSchema = getStyleSchemaFromSpecs(this.styleSpecs) as any; + value(({ ...options }[key] ?? {}) as never), + ]), + ) as any), + inlineContentSpecs: + options?.inlineContentSpecs ?? defaultInlineContentSpecs, + styleSpecs: options?.styleSpecs ?? defaultStyleSpecs, + }); } - - // public addBlockSpec( - // blockConfig: T, - // blockImplementation: BlockImplementation, - // ): { - // this.blockSpecs[blockConfig.type] = blockConfig; - // } } - -// { -// blockSpecs: { -// // TODO figure out if this is better or worse, (what are the tradeoffs) -// // this is simpler, but it's not as flexible -// heading({levels:3}), -// }, -// } -// const schema = BlockNoteSchema.create().config({ -// heading: { -// levels: [1, 2, 3], -// }, -// }); diff --git a/packages/core/src/schema/blocks/playground.ts b/packages/core/src/schema/blocks/playground.ts index 59866069e3..411d06a237 100644 --- a/packages/core/src/schema/blocks/playground.ts +++ b/packages/core/src/schema/blocks/playground.ts @@ -1,9 +1,9 @@ +import { Fragment, Schema } from "prosemirror-model"; import { ViewMutationRecord } from "prosemirror-view"; -import type { Props, PropSchema } from "../../schema/index.js"; -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { Block } from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; -import { Fragment, Schema } from "prosemirror-model"; +import type { Props, PropSchema } from "../../schema/index.js"; export type BlockDefs = Record>; @@ -111,38 +111,6 @@ export interface BlockImplementation< * This is not recommended to use, and is only useful for advanced use cases. */ parseContent?: (options: { el: HTMLElement; schema: Schema }) => Fragment; - - // // TODO this should be only on extensions, not on the block config - // /** - // * Input rules for the block - // */ - // inputRules?: { - // /** - // * The regex to match when to trigger the input rule - // */ - // find: RegExp; - // /** - // * The function to call when the input rule is matched - // */ - // replace: (props: { match: RegExpMatchArray }) => - // | undefined - // | { - // type: string; - // props: Partial>; - // }; - // }[]; - - // keymap?: { - // [key: string]: { - // type: "replace" | "insert"; - // predicate: ( - // editor: BlockNoteEditor>>, - // ) => boolean; - // action: ( - // editor: BlockNoteEditor>>, - // ) => boolean; - // }; - // }; } // input rules & keyboard shortcuts where do they fit into this? @@ -167,6 +135,14 @@ export function createBlockConfig< return callback; } +export type BlockConfigDefinition< + T extends (options: any) => BlockConfig = ( + options: any, + ) => BlockConfig, +> = T extends (options: any) => BlockConfig + ? BlockConfig + : never; + export type ExtractOptions = T extends (options: infer TOptions) => any ? TOptions : never; From b46fcc668b69656038f707c34cc090b8513485e2 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 4 Aug 2025 17:59:20 +0200 Subject: [PATCH 08/19] refactor: trying to fit it in --- packages/core/src/blks/Audio/definition.ts | 150 ++++++------- .../src/blks/BulletListItem/definition.ts | 6 +- .../core/src/blks/CheckListItem/definition.ts | 6 +- packages/core/src/blks/Code/definition.ts | 12 +- packages/core/src/blks/File/definition.ts | 6 +- packages/core/src/blks/Heading/definition.ts | 16 +- packages/core/src/blks/Image/definition.ts | 173 +++++++-------- .../src/blks/NumberedListItem/definition.ts | 6 +- .../core/src/blks/PageBreak/definition.ts | 6 +- .../core/src/blks/Paragraph/definition.ts | 6 +- packages/core/src/blks/Quote/definition.ts | 6 +- .../src/blks/ToggleListItem/definition.ts | 6 +- packages/core/src/blks/Video/definition.ts | 164 +++++++------- .../core/src/blocks/defaultBlockTypeGuards.ts | 6 +- packages/core/src/blocks/defaultBlocks.ts | 69 +++--- packages/core/src/editor/BlockNoteEditor.ts | 6 - .../core/src/editor/BlockNoteExtension.ts | 11 +- packages/core/src/editor/BlockNoteSchema.ts | 85 +++----- packages/core/src/editor/CustomSchema.ts | 2 +- packages/core/src/editor/playground.ts | 2 +- packages/core/src/schema/blocks/createSpec.ts | 120 +++++++++-- packages/core/src/schema/blocks/internal.ts | 16 +- packages/core/src/schema/blocks/playground.ts | 204 ------------------ packages/core/src/schema/blocks/types.ts | 167 ++++++++++---- packages/core/src/util/topo-sort.ts | 8 +- 25 files changed, 585 insertions(+), 674 deletions(-) delete mode 100644 packages/core/src/schema/blocks/playground.ts diff --git a/packages/core/src/blks/Audio/definition.ts b/packages/core/src/blks/Audio/definition.ts index 46e85dc65c..eb36220e3a 100644 --- a/packages/core/src/blks/Audio/definition.ts +++ b/packages/core/src/blks/Audio/definition.ts @@ -6,8 +6,8 @@ import { createFigureWithCaption } from "../../blocks/FileBlockContent/helpers/t import { createLinkWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js"; import { createBlockConfig, - createBlockSpec, -} from "../../schema/blocks/playground.js"; + createBlockDefinition, +} from "../../schema/index.js"; export const FILE_AUDIO_ICON_SVG = ''; @@ -42,89 +42,91 @@ const config = createBlockConfig((_ctx: AudioOptions) => ({ }, })); -export const definition = createBlockSpec(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); - } +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; + } - if (element.tagName === "FIGURE") { - const parsedFigure = parseFigureElement(element, "audio"); - if (!parsedFigure) { - return undefined; + return parseAudioElement(element as HTMLAudioElement); } - const { targetElement, caption } = parsedFigure; + if (element.tagName === "FIGURE") { + const parsedFigure = parseFigureElement(element, "audio"); + if (!parsedFigure) { + return undefined; + } - return { - ...parseAudioElement(targetElement as HTMLAudioElement), - caption, - }; - } + const { targetElement, caption } = parsedFigure; - return undefined; - }, - render: (block, editor) => { - const icon = document.createElement("div"); - icon.innerHTML = config.icon ?? FILE_AUDIO_ICON_SVG; + return { + ...parseAudioElement(targetElement as HTMLAudioElement), + caption, + }; + } - 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 undefined; + }, + render: (block, editor) => { + const icon = document.createElement("div"); + icon.innerHTML = config.icon ?? FILE_AUDIO_ICON_SVG; - 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"; + 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 { - dom: div, - }; - } + 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"; - 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; - } + return { + dom: div, + }; + } - if (block.props.caption) { + let audio; if (block.props.showPreview) { - return createFigureWithCaption(audio, block.props.caption); + audio = document.createElement("audio"); + audio.src = block.props.url; } else { - return createLinkWithCaption(audio, block.props.caption); + audio = document.createElement("a"); + audio.href = block.props.url; + audio.textContent = block.props.name || block.props.url; } - } - return { - dom: audio, - }; - }, - runsBefore: ["file"], -})); + 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 index d840f08250..e1c2e5b618 100644 --- a/packages/core/src/blks/BulletListItem/definition.ts +++ b/packages/core/src/blks/BulletListItem/definition.ts @@ -4,9 +4,9 @@ import { defaultProps } from "../../blocks/defaultProps.js"; import { getListItemContent } from "../../blocks/ListItemBlockContent/getListItemContent.js"; import { createBlockConfig, + createBlockDefinition, createBlockNoteExtension, - createBlockSpec, -} from "../../schema/blocks/playground.js"; +} from "../../schema/index.js"; import { handleEnter } from "../utils/listItemEnterHandler.js"; const config = createBlockConfig(() => ({ @@ -17,7 +17,7 @@ const config = createBlockConfig(() => ({ content: "inline", })); -export const definition = createBlockSpec(config).implementation( +export const definition = createBlockDefinition(config).implementation( () => ({ parse(element) { if (element.tagName !== "LI") { diff --git a/packages/core/src/blks/CheckListItem/definition.ts b/packages/core/src/blks/CheckListItem/definition.ts index cba8db17dc..5f58c7ce5d 100644 --- a/packages/core/src/blks/CheckListItem/definition.ts +++ b/packages/core/src/blks/CheckListItem/definition.ts @@ -4,9 +4,9 @@ import { defaultProps } from "../../blocks/defaultProps.js"; import { getListItemContent } from "../../blocks/ListItemBlockContent/getListItemContent.js"; import { createBlockConfig, + createBlockDefinition, createBlockNoteExtension, - createBlockSpec, -} from "../../schema/blocks/playground.js"; +} from "../../schema/index.js"; import { handleEnter } from "../utils/listItemEnterHandler.js"; const config = createBlockConfig(() => ({ @@ -18,7 +18,7 @@ const config = createBlockConfig(() => ({ content: "inline", })); -export const definition = createBlockSpec(config).implementation( +export const definition = createBlockDefinition(config).implementation( () => ({ parse(element) { if (element.tagName === "input") { diff --git a/packages/core/src/blks/Code/definition.ts b/packages/core/src/blks/Code/definition.ts index 81bd959f1f..da881d3962 100644 --- a/packages/core/src/blks/Code/definition.ts +++ b/packages/core/src/blks/Code/definition.ts @@ -1,9 +1,9 @@ import type { HighlighterGeneric } from "@shikijs/types"; import { createBlockConfig, + createBlockDefinition, createBlockNoteExtension, - createBlockSpec, -} from "../../schema/blocks/playground.js"; +} from "../../schema/index.js"; import { lazyShikiPlugin } from "./shiki.js"; export type CodeBlockOptions = { @@ -54,7 +54,7 @@ export type CodeBlockOptions = { }; const config = createBlockConfig( - ({ defaultLanguage = "text" }: CodeBlockOptions) => ({ + ({ defaultLanguage = "text" }: CodeBlockOptions = {}) => ({ type: "codeBlock" as const, propSchema: { language: { @@ -69,8 +69,8 @@ const config = createBlockConfig( }), ); -export const definition = createBlockSpec(config).implementation( - (options) => ({ +export const definition = createBlockDefinition(config).implementation( + (options = {}) => ({ parse: (e) => { const pre = e.querySelector("pre"); if (!pre) { @@ -130,7 +130,7 @@ export const definition = createBlockSpec(config).implementation( }; }, }), - (options) => { + (options = {}) => { return [ createBlockNoteExtension({ key: "code-block-highlighter", diff --git a/packages/core/src/blks/File/definition.ts b/packages/core/src/blks/File/definition.ts index 470eb7dee0..5350505010 100644 --- a/packages/core/src/blks/File/definition.ts +++ b/packages/core/src/blks/File/definition.ts @@ -5,8 +5,8 @@ import { createFileBlockWrapper } from "../../blocks/FileBlockContent/helpers/re import { createLinkWithCaption } from "../../blocks/FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js"; import { createBlockConfig, - createBlockSpec, -} from "../../schema/blocks/playground.js"; + createBlockDefinition, +} from "../../schema/index.js"; const config = createBlockConfig(() => ({ type: "file" as const, @@ -31,7 +31,7 @@ const config = createBlockConfig(() => ({ }, })); -export const definition = createBlockSpec(config).implementation(() => ({ +export const definition = createBlockDefinition(config).implementation(() => ({ parse: (element) => { if (element.tagName === "EMBED") { // Ignore if parent figure has already been parsed. diff --git a/packages/core/src/blks/Heading/definition.ts b/packages/core/src/blks/Heading/definition.ts index 635f6d8231..443c5672ea 100644 --- a/packages/core/src/blks/Heading/definition.ts +++ b/packages/core/src/blks/Heading/definition.ts @@ -4,8 +4,8 @@ import { createToggleWrapper } from "../../blocks/ToggleWrapper/createToggleWrap import { createBlockConfig, createBlockNoteExtension, - createBlockSpec, -} from "../../schema/blocks/playground.js"; + createBlockDefinition, +} from "../../schema/index.js"; const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const; @@ -21,7 +21,7 @@ const config = createBlockConfig( defaultLevel = 1, levels = HEADING_LEVELS, allowToggleHeadings = true, - }: HeadingOptions) => ({ + }: HeadingOptions = {}) => ({ type: "heading" as const, propSchema: { level: { default: defaultLevel, values: levels }, @@ -31,8 +31,8 @@ const config = createBlockConfig( }), ); -export const definition = createBlockSpec(config).implementation( - ({ allowToggleHeadings = true }) => ({ +export const definition = createBlockDefinition(config).implementation( + ({ allowToggleHeadings = true }: HeadingOptions = {}) => ({ parse(e) { const heading = e.querySelector("h1, h2, h3, h4, h5, h6"); if (!heading) { @@ -59,11 +59,11 @@ export const definition = createBlockSpec(config).implementation( }; }, }), - (options) => [ + ({ levels = HEADING_LEVELS }: HeadingOptions = {}) => [ createBlockNoteExtension({ key: "heading-shortcuts", keyboardShortcuts: Object.fromEntries( - (options.levels ?? HEADING_LEVELS).map((level) => [ + levels.map((level) => [ `Mod-Alt-${level}`, ({ editor }) => editor.transact((tr) => { @@ -88,7 +88,7 @@ export const definition = createBlockSpec(config).implementation( }), ]) ?? [], ), - inputRules: (options.levels ?? HEADING_LEVELS).map((level) => ({ + inputRules: levels.map((level) => ({ find: new RegExp(`^(#{${level}})\\s$`), replace({ match }: { match: RegExpMatchArray }) { return { diff --git a/packages/core/src/blks/Image/definition.ts b/packages/core/src/blks/Image/definition.ts index 2cb36afb92..af8c34e025 100644 --- a/packages/core/src/blks/Image/definition.ts +++ b/packages/core/src/blks/Image/definition.ts @@ -6,8 +6,8 @@ import { createLinkWithCaption } from "../../blocks/FileBlockContent/helpers/toE import { parseImageElement } from "../../blocks/ImageBlockContent/parseImageElement.js"; import { createBlockConfig, - createBlockSpec, -} from "../../schema/blocks/playground.js"; + createBlockDefinition, +} from "../../schema/index.js"; export const FILE_IMAGE_ICON_SVG = ''; @@ -15,7 +15,7 @@ export const FILE_IMAGE_ICON_SVG = export interface ImageOptions { icon?: string; } -const config = createBlockConfig((_ctx: ImageOptions) => ({ +const config = createBlockConfig((_ctx: ImageOptions = {}) => ({ type: "image" as const, propSchema: { textAlignment: defaultProps.textAlignment, @@ -48,99 +48,102 @@ const config = createBlockConfig((_ctx: ImageOptions) => ({ }, })); -export const definition = createBlockSpec(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); - } +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; + } - if (element.tagName === "FIGURE") { - const parsedFigure = parseFigureElement(element, "img"); - if (!parsedFigure) { - return undefined; + return parseImageElement(element as HTMLImageElement); } - const { targetElement, caption } = parsedFigure; + if (element.tagName === "FIGURE") { + const parsedFigure = parseFigureElement(element, "img"); + if (!parsedFigure) { + return undefined; + } - return { - ...parseImageElement(targetElement as HTMLImageElement), - caption, - }; - } + const { targetElement, caption } = parsedFigure; - 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 { + ...parseImageElement(targetElement as HTMLImageElement), + caption, + }; + } - return { - dom: div, - }; - } + 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; + } - 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; + 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, + }; } - } else { - image = document.createElement("a"); - image.href = block.props.url; - image.textContent = block.props.name || block.props.url; - } - if (block.props.caption) { + let image; if (block.props.showPreview) { - return createFigureWithCaption(image, block.props.caption); + 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 { - return createLinkWithCaption(image, block.props.caption); + image = document.createElement("a"); + image.href = block.props.url; + image.textContent = block.props.name || block.props.url; } - } - return { - dom: image, - }; - }, - runsBefore: ["file"], -})); + 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/definition.ts b/packages/core/src/blks/NumberedListItem/definition.ts index 7556b0d005..6e6f35476a 100644 --- a/packages/core/src/blks/NumberedListItem/definition.ts +++ b/packages/core/src/blks/NumberedListItem/definition.ts @@ -5,8 +5,8 @@ import { getListItemContent } from "../../blocks/ListItemBlockContent/getListIte import { createBlockConfig, createBlockNoteExtension, - createBlockSpec, -} from "../../schema/blocks/playground.js"; + createBlockDefinition, +} from "../../schema/index.js"; import { handleEnter } from "../utils/listItemEnterHandler.js"; import { NumberedListIndexingDecorationPlugin } from "./IndexingPlugin.js"; @@ -19,7 +19,7 @@ const config = createBlockConfig(() => ({ content: "inline", })); -export const definition = createBlockSpec(config).implementation( +export const definition = createBlockDefinition(config).implementation( () => ({ parse(element) { if (element.tagName !== "LI") { diff --git a/packages/core/src/blks/PageBreak/definition.ts b/packages/core/src/blks/PageBreak/definition.ts index aa7869785f..506c7a1f26 100644 --- a/packages/core/src/blks/PageBreak/definition.ts +++ b/packages/core/src/blks/PageBreak/definition.ts @@ -1,7 +1,7 @@ import { createBlockConfig, - createBlockSpec, -} from "../../schema/blocks/playground.js"; + createBlockDefinition, +} from "../../schema/index.js"; const config = createBlockConfig(() => ({ type: "pageBreak" as const, @@ -9,7 +9,7 @@ const config = createBlockConfig(() => ({ content: "none", })); -export const definition = createBlockSpec(config).implementation(() => ({ +export const definition = createBlockDefinition(config).implementation(() => ({ parse(element) { if (element.tagName === "DIV" && element.hasAttribute("data-page-break")) { return {}; diff --git a/packages/core/src/blks/Paragraph/definition.ts b/packages/core/src/blks/Paragraph/definition.ts index 7f4057095c..633e060d60 100644 --- a/packages/core/src/blks/Paragraph/definition.ts +++ b/packages/core/src/blks/Paragraph/definition.ts @@ -4,8 +4,8 @@ import { defaultProps } from "../../blocks/defaultProps.js"; import { createBlockConfig, createBlockNoteExtension, - createBlockSpec, -} from "../../schema/blocks/playground.js"; + createBlockDefinition, +} from "../../schema/index.js"; const config = createBlockConfig(() => ({ type: "paragraph" as const, @@ -13,7 +13,7 @@ const config = createBlockConfig(() => ({ content: "inline", })); -export const definition = createBlockSpec(config).implementation( +export const definition = createBlockDefinition(config).implementation( () => ({ parse: (e) => { const paragraph = e.querySelector("p"); diff --git a/packages/core/src/blks/Quote/definition.ts b/packages/core/src/blks/Quote/definition.ts index 8a6725c71d..d8481e51a7 100644 --- a/packages/core/src/blks/Quote/definition.ts +++ b/packages/core/src/blks/Quote/definition.ts @@ -4,8 +4,8 @@ import { defaultProps } from "../../blocks/defaultProps.js"; import { createBlockConfig, createBlockNoteExtension, - createBlockSpec, -} from "../../schema/blocks/playground.js"; + createBlockDefinition, +} from "../../schema/index.js"; const config = createBlockConfig(() => ({ type: "quote" as const, @@ -13,7 +13,7 @@ const config = createBlockConfig(() => ({ content: "inline", })); -export const definition = createBlockSpec(config).implementation( +export const definition = createBlockDefinition(config).implementation( () => ({ parse(element) { if (element.querySelector("blockquote")) { diff --git a/packages/core/src/blks/ToggleListItem/definition.ts b/packages/core/src/blks/ToggleListItem/definition.ts index 1025758f6e..1058f3b0f2 100644 --- a/packages/core/src/blks/ToggleListItem/definition.ts +++ b/packages/core/src/blks/ToggleListItem/definition.ts @@ -5,8 +5,8 @@ import { createToggleWrapper } from "../../blocks/ToggleWrapper/createToggleWrap import { createBlockConfig, createBlockNoteExtension, - createBlockSpec, -} from "../../schema/blocks/playground.js"; + createBlockDefinition, +} from "../../schema/index.js"; import { handleEnter } from "../utils/listItemEnterHandler.js"; const config = createBlockConfig(() => ({ @@ -17,7 +17,7 @@ const config = createBlockConfig(() => ({ content: "inline", })); -export const definition = createBlockSpec(config).implementation( +export const definition = createBlockDefinition(config).implementation( () => ({ render(block, editor) { const paragraphEl = document.createElement("p"); diff --git a/packages/core/src/blks/Video/definition.ts b/packages/core/src/blks/Video/definition.ts index 88cb6aff56..fe681a58cd 100644 --- a/packages/core/src/blks/Video/definition.ts +++ b/packages/core/src/blks/Video/definition.ts @@ -6,8 +6,8 @@ import { createLinkWithCaption } from "../../blocks/FileBlockContent/helpers/toE import { parseVideoElement } from "../../blocks/VideoBlockContent/parseVideoElement.js"; import { createBlockConfig, - createBlockSpec, -} from "../../schema/blocks/playground.js"; + createBlockDefinition, +} from "../../schema/index.js"; export const FILE_VIDEO_ICON_SVG = ''; @@ -32,98 +32,100 @@ const config = createBlockConfig((_ctx: VideoOptions) => ({ }, })); -export const definition = createBlockSpec(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); - } +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; + } - if (element.tagName === "FIGURE") { - const parsedFigure = parseFigureElement(element, "video"); - if (!parsedFigure) { - return undefined; + return parseVideoElement(element as HTMLVideoElement); } - const { targetElement, caption } = parsedFigure; + if (element.tagName === "FIGURE") { + const parsedFigure = parseFigureElement(element, "video"); + if (!parsedFigure) { + return undefined; + } - return { - ...parseVideoElement(targetElement as HTMLVideoElement), - caption, - }; - } + const { targetElement, caption } = parsedFigure; - return undefined; - }, - render: (block, editor) => { - const icon = document.createElement("div"); - icon.innerHTML = config.icon ?? FILE_VIDEO_ICON_SVG; + return { + ...parseVideoElement(targetElement as HTMLVideoElement), + caption, + }; + } - const videoWrapper = document.createElement("div"); - videoWrapper.className = "bn-visual-media-wrapper"; + return undefined; + }, + render: (block, editor) => { + const icon = document.createElement("div"); + icon.innerHTML = config.icon ?? FILE_VIDEO_ICON_SVG; - 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); + const videoWrapper = document.createElement("div"); + videoWrapper.className = "bn-visual-media-wrapper"; - 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"; + 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 { - dom: div, - }; - } + 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"; - let video; - if (block.props.showPreview) { - video = document.createElement("video"); - video.src = block.props.url; - if (block.props.previewWidth) { - video.width = block.props.previewWidth; + return { + dom: div, + }; } - } else { - video = document.createElement("a"); - video.href = block.props.url; - video.textContent = block.props.name || block.props.url; - } - if (block.props.caption) { + let video; if (block.props.showPreview) { - return createFigureWithCaption(video, block.props.caption); + video = document.createElement("video"); + video.src = block.props.url; + if (block.props.previewWidth) { + video.width = block.props.previewWidth; + } } else { - return createLinkWithCaption(video, block.props.caption); + video = document.createElement("a"); + video.href = block.props.url; + video.textContent = block.props.name || block.props.url; } - } - return { - dom: video, - }; - }, - runsBefore: ["file"], -})); + 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/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts index e2788871ec..4fbdc2e99b 100644 --- a/packages/core/src/blocks/defaultBlockTypeGuards.ts +++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts @@ -4,7 +4,6 @@ import { BlockConfig, BlockFromConfig, BlockSchema, - FileBlockConfig, InlineContentConfig, InlineContentSchema, StyleSchema, @@ -13,7 +12,7 @@ import { Block, DefaultBlockSchema, DefaultInlineContentSchema, - defaultBlockSchema, + defaultBlockSpecs, defaultInlineContentSchema, } from "./defaultBlocks.js"; import { defaultProps } from "./defaultProps.js"; @@ -32,8 +31,7 @@ export function checkDefaultBlockTypeInSchema< S > { return ( - blockType in editor.schema.blockSchema //&& - // editor.schema.blockSchema[blockType] === defaultBlockSchema[blockType] + blockType in editor.schema.blockSchema && blockType in defaultBlockSpecs ); } diff --git a/packages/core/src/blocks/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts index 5c1c74da3e..81e32dd5ce 100644 --- a/packages/core/src/blocks/defaultBlocks.ts +++ b/packages/core/src/blocks/defaultBlocks.ts @@ -3,58 +3,63 @@ 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, + quoteBlock, + toggleListItem, + video, +} from "../blks/index.js"; import { BackgroundColor } from "../extensions/BackgroundColor/BackgroundColorMark.js"; import { TextColor } from "../extensions/TextColor/TextColorMark.js"; import { + BlockDefinition, BlockNoDefaults, BlockSchema, - BlockSpecs, + ExtractBlockConfig, 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(), + quoteBlock: quoteBlock.definition(), + toggleListItem: toggleListItem.definition(), + file: file.definition(), + image: image.definition(), + video: video.definition(), + table: Table as unknown as BlockDefinition, +} 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 = { + [K in keyof typeof defaultBlockSpecs]: ExtractBlockConfig< + (typeof defaultBlockSpecs)[K] + >; +}; export type DefaultBlockSchema = _DefaultBlockSchema; export const defaultStyleSpecs = { diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 0765754905..096f15edee 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -100,7 +100,6 @@ import { en } from "../i18n/locales/index.js"; import { redo, undo } from "@tiptap/pm/history"; import { - Selection, TextSelection, type Command, type Plugin, @@ -868,11 +867,6 @@ export class BlockNoteEditor< range.from, range.to, ); - // tr.replaceRange( - // range.from, - // range.to, - // Slice.empty, - // ); return undefined; } } diff --git a/packages/core/src/editor/BlockNoteExtension.ts b/packages/core/src/editor/BlockNoteExtension.ts index 92a94ed7f7..737f7e3fd1 100644 --- a/packages/core/src/editor/BlockNoteExtension.ts +++ b/packages/core/src/editor/BlockNoteExtension.ts @@ -2,6 +2,7 @@ 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, @@ -63,13 +64,5 @@ export type InputRule = { * The editor instance */ editor: BlockNoteEditor; - }) => - | undefined - | { - // TODO types - type: string; - props: Partial>; - children?: any[]; - content?: any[]; - }; + }) => undefined | PartialBlockNoDefaults; }; diff --git a/packages/core/src/editor/BlockNoteSchema.ts b/packages/core/src/editor/BlockNoteSchema.ts index 1fe6e5fb17..e190239b75 100644 --- a/packages/core/src/editor/BlockNoteSchema.ts +++ b/packages/core/src/editor/BlockNoteSchema.ts @@ -3,63 +3,47 @@ import { defaultInlineContentSpecs, defaultStyleSpecs, } from "../blocks/defaultBlocks.js"; -import type { - BlockNoDefaults, - PartialBlockNoDefaults, -} from "../schema/blocks/types.js"; import { - BlockSchema, - BlockSchemaFromSpecs, - BlockSpecs, + BlockDefinition, + BlockNoDefaults, InlineContentSchema, InlineContentSchemaFromSpecs, InlineContentSpecs, + PartialBlockNoDefaults, + PropSchema, 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 { CustomBlockNoteSchema } from "./CustomSchema.js"; export class BlockNoteSchema< - BSchema extends BlockSchema, + BSpecs extends { + [key in string]: BlockDefinition; + }, 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 { + [key in string]: BlockDefinition; + } = typeof defaultBlockSpecs, ISpecs extends InlineContentSpecs = typeof defaultInlineContentSpecs, SSpecs extends StyleSpecs = typeof defaultStyleSpecs, >(options?: { @@ -77,34 +61,15 @@ export class BlockNoteSchema< styleSpecs?: SSpecs; }) { return new BlockNoteSchema< - BlockSchemaFromSpecs, + Record>, 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 unknown as BSpecs), + inlineContentSpecs: + options?.inlineContentSpecs ?? defaultInlineContentSpecs, + styleSpecs: options?.styleSpecs ?? defaultStyleSpecs, + }); } } - -// const s = BlockNoteSchema.create(); -// s.blockSchema.audio. diff --git a/packages/core/src/editor/CustomSchema.ts b/packages/core/src/editor/CustomSchema.ts index 292b458a65..17d5b624e4 100644 --- a/packages/core/src/editor/CustomSchema.ts +++ b/packages/core/src/editor/CustomSchema.ts @@ -1,5 +1,5 @@ -import { BlockDefinition } from "../schema/blocks/playground.js"; import { + BlockDefinition, BlockSpecs, InlineContentSchema, InlineContentSpecs, diff --git a/packages/core/src/editor/playground.ts b/packages/core/src/editor/playground.ts index 1bd25f7b5e..ce358a44c7 100644 --- a/packages/core/src/editor/playground.ts +++ b/packages/core/src/editor/playground.ts @@ -17,8 +17,8 @@ import { defaultInlineContentSpecs, defaultStyleSpecs, } from "../blocks/defaultBlocks.js"; -import { BlockDefinition } from "../schema/blocks/playground.js"; import { + BlockDefinition, InlineContentSchema, InlineContentSchemaFromSpecs, InlineContentSpecs, diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index c4d4c08ccf..fdf33d0c04 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -1,5 +1,5 @@ import { Editor } from "@tiptap/core"; -import { DOMParser, Fragment, 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"; @@ -14,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 & { @@ -29,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 @@ -43,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 @@ -122,7 +146,7 @@ export function getParseRules( ? (node, schema) => { if (customParseContentFunction) { return customParseContentFunction({ - el: node, + el: node as HTMLElement, schema, }); } @@ -222,7 +246,7 @@ export function createBlockSpec< blockConfig.type, {}, blockConfig.propSchema, - blockConfig.isFileBlock, + blockConfig.meta?.fileBlockAccept !== undefined, HTMLAttributes, ); }, @@ -244,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); } @@ -281,7 +305,7 @@ export function createBlockSpec< block.type, block.props, blockConfig.propSchema, - blockConfig.isFileBlock, + blockConfig.meta?.fileBlockAccept !== undefined, blockContentDOMAttributes, ); }, @@ -291,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, ); @@ -308,3 +337,58 @@ export function createBlockSpec< }, }); } + +/** + * 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"], +>(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"], +>( + callback: TCallback, +): { + implementation: ( + cb: (options?: TOptions) => BlockImplementation, + addExtensions?: (options?: TOptions) => BlockNoteExtension[], + ) => (options?: TOptions) => BlockDefinition; +} { + return { + implementation: (cb, addExtensions) => (options) => ({ + config: callback(options), + 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..2796aea6c8 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -16,10 +16,8 @@ import { PropSchema, Props } from "../propTypes.js"; import { StyleSchema } from "../styles/types.js"; import { BlockConfig, - BlockSchemaFromSpecs, BlockSchemaWithBlock, BlockSpec, - BlockSpecs, SpecificBlock, TiptapBlockImplementation, } from "./types.js"; @@ -145,7 +143,7 @@ export function wrapInBlockStructure< PSchema extends PropSchema, >( element: { - dom: HTMLElement; + dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; destroy?: () => void; }, @@ -258,11 +256,7 @@ 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, }, { @@ -274,9 +268,3 @@ export function createBlockSpecFromStronglyTypedTiptapNode< }, ); } - -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/playground.ts b/packages/core/src/schema/blocks/playground.ts deleted file mode 100644 index 411d06a237..0000000000 --- a/packages/core/src/schema/blocks/playground.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { Fragment, Schema } from "prosemirror-model"; -import { ViewMutationRecord } from "prosemirror-view"; -import { Block } from "../../blocks/defaultBlocks.js"; -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; -import type { Props, PropSchema } from "../../schema/index.js"; - -export type BlockDefs = Record>; - -export interface BlockConfigMeta { - /** - * Whether the block is selectable - */ - selectable?: boolean; - - /** - * The accept mime types for the file block - */ - fileBlockAccept?: string[]; - - /** - * 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, - TSchema extends PropSchema = PropSchema, -> { - /** - * 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: "inline" | "none"; - // TODO: how do you represent things that have nested content? - // e.g. tables, alerts (with title & content) - /** - * Metadata - */ - meta?: BlockConfigMeta; -} - -export interface BlockImplementation< - TName extends string, - TProps extends PropSchema, -> { - /** - * A function that converts the block into a DOM element - */ - render: ( - /** - * The custom block to render - */ - block: Block>>, - /** - * The BlockNote editor instance - */ - editor: BlockNoteEditor>>, - ) => { - 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: Block>>, - editor: BlockNoteEditor>>, - ) => - | { - 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; -} - -// input rules & keyboard shortcuts where do they fit into this? - -export type BlockDefinition< - TName extends string = string, - TProps extends PropSchema = PropSchema, -> = { - config: BlockConfig; - implementation: BlockImplementation, NoInfer>; - extensions?: BlockNoteExtension[]; -}; - -export function createBlockConfig< - TCallback extends ( - options: Partial>, - ) => BlockConfig, - TOptions extends Parameters[0], - TName extends ReturnType["type"], - TProps extends ReturnType["propSchema"], ->(callback: TCallback): (options: TOptions) => BlockConfig { - return callback; -} - -export type BlockConfigDefinition< - T extends (options: any) => BlockConfig = ( - options: any, - ) => BlockConfig, -> = T extends (options: any) => BlockConfig - ? BlockConfig - : never; - -export type ExtractOptions = T extends (options: infer TOptions) => any - ? TOptions - : never; - -export type ExtractBlockConfig = T extends ( - options: any, -) => BlockDefinition - ? BlockConfig - : never; - -export type ExtractBlockImplementation = T extends ( - options: any, -) => BlockDefinition - ? BlockImplementation - : never; - -export type ExtractBlock = T extends ( - options: any, -) => BlockDefinition - ? Block>> - : never; - -export function createBlockSpec< - TCallback extends (options: any) => BlockConfig, - TOptions extends Parameters[0], - TName extends ReturnType["type"], - TProps extends ReturnType["propSchema"], ->( - callback: TCallback, -): { - implementation: ( - cb: (options: TOptions) => BlockImplementation, - addExtensions?: (options: TOptions) => BlockNoteExtension[], - ) => (options: TOptions) => BlockDefinition; -} { - return { - implementation: (cb, addExtensions) => (options) => ({ - config: callback(options), - 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/types.ts b/packages/core/src/schema/blocks/types.ts index a5075fd6b5..dfbcd922a5 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -9,6 +9,9 @@ import type { } from "../inlineContent/types.js"; import type { PropSchema, Props } from "../propTypes.js"; import type { StyleSchema } from "../styles/types.js"; +import type { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; +import type { Fragment, Schema } from "prosemirror-model"; +import type { ViewMutationRecord } from "prosemirror-view"; export type BlockNoteDOMElement = | "editor" @@ -21,54 +24,56 @@ export type BlockNoteDOMAttributes = Partial<{ [DOMElement in BlockNoteDOMElement]: Record; }>; -// TODO we should remove FileBlockConfig, and only use BlockConfig -// Ideally something like this would be represented via `groups: ["file"]` or similar -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; + /** + * 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, +> { + /** + * 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: "inline" | "none"; + // TODO: how do you represent things that have nested content? + // e.g. tables, alerts (with title & content) + /** + * Metadata + */ + meta?: BlockConfigMeta; +} // Block implementation contains the "implementation" info about a Block // such as the functions / Nodes required to render and / or serialize it @@ -323,3 +328,73 @@ export type PartialBlockFromConfig< }; export type BlockIdentifier = { id: string } | string; + +export interface BlockImplementation< + TName extends string, + TProps extends PropSchema, +> { + /** + * A function that converts the block into a DOM element + */ + render: ( + /** + * The custom block to render + */ + block: BlockNoDefaults>, any, any>, + /** + * The BlockNote editor instance + */ + editor: BlockNoteEditor>>, + ) => { + 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>, any, any>, + editor: BlockNoteEditor>>, + ) => + | { + 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, +> = { + config: BlockConfig; + implementation: BlockImplementation, NoInfer>; + extensions?: BlockNoteExtension[]; +}; + +export type ExtractBlockConfig = T extends ( + options: any, +) => BlockDefinition + ? BlockConfig + : never; diff --git a/packages/core/src/util/topo-sort.ts b/packages/core/src/util/topo-sort.ts index d3dd9ff4f7..fb09fc2075 100644 --- a/packages/core/src/util/topo-sort.ts +++ b/packages/core/src/util/topo-sort.ts @@ -1,4 +1,10 @@ -// Based on https://github.com/n1ru4l/toposort/blob/main/src/toposort.ts (MIT) +/** + * 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; From 5751974f870cd764235fc977b8b1dd4ca699cd76 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 4 Aug 2025 18:11:15 +0200 Subject: [PATCH 09/19] fix: get tables working --- examples/01-basic/01-minimal/src/App.tsx | 10 +--------- packages/core/src/editor/CustomSchema.ts | 13 ++++++++----- packages/core/src/schema/blocks/createSpec.ts | 2 ++ 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/examples/01-basic/01-minimal/src/App.tsx b/examples/01-basic/01-minimal/src/App.tsx index f0a129c00c..a3b92bafd2 100644 --- a/examples/01-basic/01-minimal/src/App.tsx +++ b/examples/01-basic/01-minimal/src/App.tsx @@ -2,18 +2,10 @@ import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; -import { BlockNoteSchema2 } from "@blocknote/core"; -import { codeBlock } from "@blocknote/code-block"; - -const schema = BlockNoteSchema2.create(undefined, { - codeBlock, -}); export default function App() { // Creates a new editor instance. - const editor = useCreateBlockNote({ - schema: schema as any, - }); + const editor = useCreateBlockNote(); // Renders the editor instance using a React component. return ; diff --git a/packages/core/src/editor/CustomSchema.ts b/packages/core/src/editor/CustomSchema.ts index 17d5b624e4..5392ba63f7 100644 --- a/packages/core/src/editor/CustomSchema.ts +++ b/packages/core/src/editor/CustomSchema.ts @@ -98,11 +98,14 @@ export class CustomBlockNoteSchema< { extensions: blockDef.extensions, }, - createBlockSpec( - blockDef.config, - blockDef.implementation as any, - getPriority(key), - ), + // TODO annoying hack to get tables to work + blockDef.config.type === "table" + ? blockDef + : createBlockSpec( + blockDef.config, + blockDef.implementation as any, + getPriority(key), + ), ), ]; }, diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index fdf33d0c04..56a1959690 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -335,6 +335,8 @@ export function createBlockSpec< blockContentDOMAttributes, ); }, + // Only needed for tables right now, remove later + requiredExtensions: (blockImplementation as any).requiredExtensions, }); } From c51767aaea55e009a20d477cd01d547e2916b2b5 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 5 Aug 2025 22:43:25 +0200 Subject: [PATCH 10/19] Made type guards more generic --- .../src/FileReplaceButton.tsx | 4 +- .../fromClipboard/handleFileInsertion.ts | 9 +- packages/core/src/blks/Audio/definition.ts | 2 +- packages/core/src/blks/Image/definition.ts | 4 +- .../src/blks/NumberedListItem/definition.ts | 2 +- packages/core/src/blks/Video/definition.ts | 2 +- packages/core/src/blks/index.ts | 2 +- .../core/src/blocks/defaultBlockTypeGuards.ts | 291 +++++++++--------- packages/core/src/blocks/defaultBlocks.ts | 4 +- packages/core/src/editor/BlockNoteEditor.ts | 4 +- packages/core/src/editor/playground.ts | 4 +- .../getDefaultSlashMenuItems.ts | 52 +++- .../TableHandles/TableHandlesPlugin.ts | 4 +- .../react/src/components/Comments/schema.ts | 18 +- .../FilePanel/DefaultTabs/UploadTab.tsx | 7 +- .../DefaultButtons/FileCaptionButton.tsx | 16 +- .../DefaultButtons/FileDeleteButton.tsx | 13 +- .../DefaultButtons/FileDownloadButton.tsx | 9 +- .../DefaultButtons/FilePreviewButton.tsx | 27 +- .../DefaultButtons/FileRenameButton.tsx | 16 +- .../DefaultButtons/FileReplaceButton.tsx | 6 +- .../DefaultButtons/TextAlignButton.tsx | 20 +- .../DefaultItems/BlockColorsItem.tsx | 36 ++- 23 files changed, 283 insertions(+), 269 deletions(-) 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..c0442bb2f7 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, + blockHasProps, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -41,7 +41,7 @@ export const FileReplaceButton = () => { if ( block === undefined || - !checkBlockIsFileBlock(block, editor) || + !blockHasProps(block, { url: { default: "" } }) || !editor.isEditable ) { return null; 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 index eb36220e3a..c58b547671 100644 --- a/packages/core/src/blks/Audio/definition.ts +++ b/packages/core/src/blks/Audio/definition.ts @@ -72,7 +72,7 @@ export const definition = createBlockDefinition(config).implementation( }, render: (block, editor) => { const icon = document.createElement("div"); - icon.innerHTML = config.icon ?? FILE_AUDIO_ICON_SVG; + icon.innerHTML = config?.icon ?? FILE_AUDIO_ICON_SVG; const audio = document.createElement("audio"); audio.className = "bn-audio"; diff --git a/packages/core/src/blks/Image/definition.ts b/packages/core/src/blks/Image/definition.ts index af8c34e025..7e5d72400c 100644 --- a/packages/core/src/blks/Image/definition.ts +++ b/packages/core/src/blks/Image/definition.ts @@ -41,7 +41,7 @@ const config = createBlockConfig((_ctx: ImageOptions = {}) => ({ default: undefined, type: "number", }, - }, + } as const, content: "none" as const, meta: { fileBlockAccept: ["image/*"], @@ -78,7 +78,7 @@ export const definition = createBlockDefinition(config).implementation( }, render: (block, editor) => { const icon = document.createElement("div"); - icon.innerHTML = config.icon ?? FILE_IMAGE_ICON_SVG; + icon.innerHTML = config?.icon ?? FILE_IMAGE_ICON_SVG; const imageWrapper = document.createElement("div"); imageWrapper.className = "bn-visual-media-wrapper"; diff --git a/packages/core/src/blks/NumberedListItem/definition.ts b/packages/core/src/blks/NumberedListItem/definition.ts index 6e6f35476a..43962612fa 100644 --- a/packages/core/src/blks/NumberedListItem/definition.ts +++ b/packages/core/src/blks/NumberedListItem/definition.ts @@ -15,7 +15,7 @@ const config = createBlockConfig(() => ({ propSchema: { ...defaultProps, start: { default: undefined, type: "number" }, - }, + } as const, content: "inline", })); diff --git a/packages/core/src/blks/Video/definition.ts b/packages/core/src/blks/Video/definition.ts index fe681a58cd..d8aec2fcc2 100644 --- a/packages/core/src/blks/Video/definition.ts +++ b/packages/core/src/blks/Video/definition.ts @@ -62,7 +62,7 @@ export const definition = createBlockDefinition(config).implementation( }, render: (block, editor) => { const icon = document.createElement("div"); - icon.innerHTML = config.icon ?? FILE_VIDEO_ICON_SVG; + icon.innerHTML = config?.icon ?? FILE_VIDEO_ICON_SVG; const videoWrapper = document.createElement("div"); videoWrapper.className = "bn-visual-media-wrapper"; diff --git a/packages/core/src/blks/index.ts b/packages/core/src/blks/index.ts index a501019f03..97473296ef 100644 --- a/packages/core/src/blks/index.ts +++ b/packages/core/src/blks/index.ts @@ -6,7 +6,7 @@ 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 quoteBlock from "./Quote/definition.js"; +export * as quote from "./Quote/definition.js"; export * as toggleListItem from "./ToggleListItem/definition.js"; export * as file from "./File/definition.js"; diff --git a/packages/core/src/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts index 4fbdc2e99b..64731a8a06 100644 --- a/packages/core/src/blocks/defaultBlockTypeGuards.ts +++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts @@ -2,196 +2,185 @@ import { CellSelection } from "prosemirror-tables"; import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import { BlockConfig, - BlockFromConfig, BlockSchema, - InlineContentConfig, - InlineContentSchema, + PropSchema, + PropSpec, StyleSchema, } from "../schema/index.js"; import { Block, - DefaultBlockSchema, DefaultInlineContentSchema, - defaultBlockSpecs, defaultInlineContentSchema, } from "./defaultBlocks.js"; -import { defaultProps } from "./defaultProps.js"; import { Selection } from "prosemirror-state"; -export function checkDefaultBlockTypeInSchema< - BlockType extends keyof DefaultBlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, ->( - blockType: BlockType, - editor: BlockNoteEditor, +export function editorHasBlockWithType( + editor: BlockNoteEditor, + blockType: BType, ): editor is BlockNoteEditor< - { [K in BlockType]: DefaultBlockSchema[BlockType] }, - I, - S + { + [BT in BType]: BlockConfig< + BT, + { + [PN in string]: PropSpec; + } + >; + }, + any, + any > { - return ( - blockType in editor.schema.blockSchema && blockType in defaultBlockSpecs - ); -} + 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 (editor.schema.blockSpecs[blockType].config.type !== blockType) { + return false; + } + + return true; } -export function checkDefaultInlineContentTypeInSchema< - InlineContentType extends keyof DefaultInlineContentSchema, - B extends BlockSchema, - S extends StyleSchema, +export function editorHasBlockWithTypeAndProps< + BType extends string, + PSchema extends PropSchema, >( - inlineContentType: InlineContentType, - editor: BlockNoteEditor, + editor: BlockNoteEditor, + blockType: BType, + propSchema: PSchema, ): editor is BlockNoteEditor< - B, - { [K in InlineContentType]: DefaultInlineContentSchema[InlineContentType] }, - S + { + [BT in BType]: BlockConfig; + }, + any, + any > { - return ( - inlineContentType in editor.schema.inlineContentSchema && - editor.schema.inlineContentSchema[inlineContentType] === - defaultInlineContentSchema[inlineContentType] - ); -} + if (!editorHasBlockWithType(editor, blockType)) { + 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 - ); -} + for (const [propName, propSpec] of Object.entries(propSchema)) { + if (!(propName in editor.schema.blockSpecs[blockType].config.propSchema)) { + 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] + .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 ( + 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; + } + } + } + + 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; + } + } + } + + return true; } -export function checkBlockIsFileBlockWithPreview< - B extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, ->( - block: Block, - editor: BlockNoteEditor, -): block is BlockFromConfig< - FileBlockConfig & { - propSchema: Required; +export function blockHasType( + block: Block, + editor: BlockNoteEditor, + blockType: BType, +): block is Block< + { + [BT in string]: BlockConfig< + BT, + { + [PN in string]: PropSpec; + } + >; }, - I, - S + any, + any > { - return ( - (block.type in editor.schema.blockSchema && - editor.schema.blockSchema[block.type].isFileBlock && - "showPreview" in editor.schema.blockSchema[block.type].propSchema) || - 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 editorHasBlockWithType(editor, blockType) && block.type === blockType; } -export function checkBlockTypeHasDefaultProp< - Prop extends keyof typeof defaultProps, - I extends InlineContentSchema, - S extends StyleSchema, +export function blockHasTypeAndProps< + BType extends string, + PSchema extends PropSchema, >( - prop: Prop, - blockType: string, - editor: BlockNoteEditor, -): editor is BlockNoteEditor< + block: Block, + editor: BlockNoteEditor, + blockType: BType, + propSchema: PSchema, +): block is Block< { - [BT in string]: { - type: BT; - propSchema: { - [P in Prop]: (typeof defaultProps)[P]; - }; - content: "table" | "inline" | "none"; - }; + [BT in string]: 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] + editorHasBlockWithTypeAndProps(editor, blockType, propSchema) && + block.type === blockType ); } -export function checkBlockHasDefaultProp< - Prop extends keyof typeof defaultProps, - I extends InlineContentSchema, +// TODO: Only used in the emoji picker - is it even needed? If so, needs to be +// changed to be like the block type guards. +export function checkDefaultInlineContentTypeInSchema< + InlineContentType extends keyof DefaultInlineContentSchema, + B extends BlockSchema, 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, + inlineContentType: InlineContentType, + editor: BlockNoteEditor, +): editor is BlockNoteEditor< + B, + { [K in InlineContentType]: DefaultInlineContentSchema[InlineContentType] }, S > { - return checkBlockTypeHasDefaultProp(prop, block.type, editor); + return ( + inlineContentType in editor.schema.inlineContentSchema && + editor.schema.inlineContentSchema[inlineContentType] === + defaultInlineContentSchema[inlineContentType] + ); } export function isTableCellSelection( diff --git a/packages/core/src/blocks/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts index 81e32dd5ce..bc43c0c7a4 100644 --- a/packages/core/src/blocks/defaultBlocks.ts +++ b/packages/core/src/blocks/defaultBlocks.ts @@ -14,7 +14,7 @@ import { numberedListItem, pageBreak, paragraph, - quoteBlock, + quote, toggleListItem, video, } from "../blks/index.js"; @@ -45,7 +45,7 @@ export const defaultBlockSpecs = { heading: heading.definition(), numberedListItem: numberedListItem.definition(), pageBreak: pageBreak.definition(), - quoteBlock: quoteBlock.definition(), + quote: quote.definition(), toggleListItem: toggleListItem.definition(), file: file.definition(), image: image.definition(), diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 096f15edee..ae989e1e09 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -88,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, @@ -682,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, diff --git a/packages/core/src/editor/playground.ts b/packages/core/src/editor/playground.ts index ce358a44c7..86ffa125c4 100644 --- a/packages/core/src/editor/playground.ts +++ b/packages/core/src/editor/playground.ts @@ -9,7 +9,7 @@ import { numberedListItem, pageBreak, paragraph, - quoteBlock, + quote, toggleListItem, video, } from "../blks/index.js"; @@ -38,7 +38,7 @@ const defaultBlockSpecs = { heading: heading.definition, numberedListItem: numberedListItem.definition, pageBreak: pageBreak.definition, - quoteBlock: quoteBlock.definition, + quote: quote.definition, toggleListItem: toggleListItem.definition, file: file.definition, image: image.definition, diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts index bcc95c83a7..593deebd1d 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts @@ -1,7 +1,14 @@ -import { Block, PartialBlock } from "../../blocks/defaultBlocks.js"; +import { + Block, + defaultBlockSpecs, + PartialBlock, +} from "../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { checkDefaultBlockTypeInSchema } from "../../blocks/defaultBlockTypeGuards.js"; +import { + editorHasBlockWithType, + editorHasBlockWithTypeAndProps, +} from "../../blocks/defaultBlockTypeGuards.js"; import { BlockSchema, InlineContentSchema, @@ -87,7 +94,11 @@ export function getDefaultSlashMenuItems< >(editor: BlockNoteEditor) { const items: DefaultSuggestionItem[] = []; - if (checkDefaultBlockTypeInSchema("heading", editor)) { + if ( + editorHasBlockWithTypeAndProps(editor, "heading", { + level: defaultBlockSpecs["heading"].config.propSchema.level, + }) + ) { items.push( { onItemClick: () => { @@ -125,7 +136,7 @@ export function getDefaultSlashMenuItems< ); } - if (checkDefaultBlockTypeInSchema("quote", editor)) { + if (editorHasBlockWithType(editor, "quote")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -137,7 +148,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("toggleListItem", editor)) { + if (editorHasBlockWithType(editor, "toggleListItem")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -150,7 +161,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("numberedListItem", editor)) { + if (editorHasBlockWithType(editor, "numberedListItem")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -163,7 +174,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("bulletListItem", editor)) { + if (editorHasBlockWithType(editor, "bulletListItem")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -176,7 +187,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("checkListItem", editor)) { + if (editorHasBlockWithType(editor, "checkListItem")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -189,7 +200,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("paragraph", editor)) { + if (editorHasBlockWithType(editor, "paragraph")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -202,7 +213,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("codeBlock", editor)) { + if (editorHasBlockWithType(editor, "codeBlock")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -215,7 +226,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("table", editor)) { + if (editorHasBlockWithType(editor, "table")) { items.push({ onItemClick: () => { insertOrUpdateBlock(editor, { @@ -230,7 +241,7 @@ export function getDefaultSlashMenuItems< cells: ["", "", ""], }, ], - }, + } as any, }); }, badge: undefined, @@ -239,7 +250,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("image", editor)) { + if (editorHasBlockWithType(editor, "image")) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { @@ -258,7 +269,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("video", editor)) { + if (editorHasBlockWithType(editor, "video")) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { @@ -277,7 +288,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("audio", editor)) { + if (editorHasBlockWithType(editor, "audio")) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { @@ -296,7 +307,7 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("file", editor)) { + if (editorHasBlockWithType(editor, "file")) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { @@ -315,7 +326,14 @@ export function getDefaultSlashMenuItems< }); } - if (checkDefaultBlockTypeInSchema("heading", editor)) { + if ( + editorHasBlockWithTypeAndProps(editor, "heading", { + level: defaultBlockSpecs["heading"].config.propSchema.level, + isToggleable: { + default: true, + }, + }) + ) { 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/react/src/components/Comments/schema.ts b/packages/react/src/components/Comments/schema.ts index 576aa2c3ef..38b65ce88f 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"; // 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..48c6b90a31 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -1,7 +1,6 @@ import { + blockHasTypeAndProps, BlockSchema, - checkBlockIsFileBlock, - checkBlockIsFileBlockWithPlaceholder, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -41,7 +40,12 @@ export const FileCaptionButton = () => { const block = selectedBlocks[0]; - if (checkBlockIsFileBlock(block, editor)) { + if ( + blockHasTypeAndProps(block, editor, block.type, { + url: { default: "" }, + caption: { default: "" }, + }) + ) { setCurrentEditingCaption(block.props.caption); return block; } @@ -69,11 +73,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..6e1ab879a6 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx @@ -1,7 +1,6 @@ import { + blockHasTypeAndProps, BlockSchema, - checkBlockIsFileBlock, - checkBlockIsFileBlockWithPlaceholder, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -33,7 +32,9 @@ export const FileDeleteButton = () => { const block = selectedBlocks[0]; - if (checkBlockIsFileBlock(block, editor)) { + if ( + blockHasTypeAndProps(block, editor, block.type, { url: { default: "" } }) + ) { return block; } @@ -45,11 +46,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..ce0896cda8 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx @@ -1,7 +1,6 @@ import { + blockHasTypeAndProps, BlockSchema, - checkBlockIsFileBlock, - checkBlockIsFileBlockWithPlaceholder, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -34,7 +33,9 @@ export const FileDownloadButton = () => { const block = selectedBlocks[0]; - if (checkBlockIsFileBlock(block, editor)) { + if ( + blockHasTypeAndProps(block, editor, block.type, { url: { default: "" } }) + ) { return block; } @@ -57,7 +58,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..1439d779db 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx @@ -1,7 +1,7 @@ import { + blockHasTypeAndProps, BlockSchema, - checkBlockIsFileBlockWithPlaceholder, - checkBlockIsFileBlockWithPreview, + editorHasBlockWithTypeAndProps, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -33,7 +33,12 @@ export const FilePreviewButton = () => { const block = selectedBlocks[0]; - if (checkBlockIsFileBlockWithPreview(block, editor)) { + if ( + blockHasTypeAndProps(block, editor, block.type, { + url: { default: "" }, + showPreview: { default: true }, + }) + ) { return block; } @@ -41,20 +46,22 @@ export const FilePreviewButton = () => { }, [editor, selectedBlocks]); const onClick = useCallback(() => { - if (fileBlock) { + if ( + fileBlock && + editorHasBlockWithTypeAndProps(editor, fileBlock.type, { + url: { default: "" }, + showPreview: { default: true }, + }) + ) { 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..f1c7c92a4f 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx @@ -1,7 +1,6 @@ import { + blockHasTypeAndProps, BlockSchema, - checkBlockIsFileBlock, - checkBlockIsFileBlockWithPlaceholder, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -41,7 +40,12 @@ export const FileRenameButton = () => { const block = selectedBlocks[0]; - if (checkBlockIsFileBlock(block, editor)) { + if ( + blockHasTypeAndProps(block, editor, block.type, { + url: { default: "" }, + name: { default: "" }, + }) + ) { setCurrentEditingName(block.props.name); return block; } @@ -69,11 +73,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..22fd3d439b 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx @@ -1,6 +1,6 @@ import { + blockHasTypeAndProps, BlockSchema, - checkBlockIsFileBlock, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -35,7 +35,9 @@ export const FileReplaceButton = () => { if ( block === undefined || - !checkBlockIsFileBlock(block, editor) || + !blockHasTypeAndProps(block, editor, block.type, { + url: { default: "" }, + }) || !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..bbe14def1d 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx @@ -1,8 +1,9 @@ import { + blockHasTypeAndProps, BlockSchema, - checkBlockHasDefaultProp, - checkBlockTypeHasDefaultProp, + defaultProps, DefaultProps, + editorHasBlockWithTypeAndProps, 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 ( + blockHasTypeAndProps(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 ( + blockHasTypeAndProps(block, editor, block.type, { + textAlignment: defaultProps.textAlignment, + }) && + editorHasBlockWithTypeAndProps(editor, block.type, { + textAlignment: defaultProps.textAlignment, + }) + ) { editor.updateBlock(block, { props: { textAlignment: textAlignment }, }); diff --git a/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx index 5e27e1eb21..56acea6fac 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 { + blockHasTypeAndProps, BlockSchema, - checkBlockHasDefaultProp, - checkBlockTypeHasDefaultProp, DefaultBlockSchema, DefaultInlineContentSchema, + defaultProps, DefaultStyleSchema, + editorHasBlockWithTypeAndProps, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -29,8 +30,12 @@ export const BlockColorsItem = < const editor = useBlockNoteEditor(); if ( - !checkBlockTypeHasDefaultProp("textColor", props.block.type, editor) && - !checkBlockTypeHasDefaultProp("backgroundColor", props.block.type, editor) + !blockHasTypeAndProps(props.block, editor, props.block.type, { + textColor: defaultProps.textColor, + }) || + !blockHasTypeAndProps(props.block, editor, props.block.type, { + backgroundColor: defaultProps.backgroundColor, + }) ) { return null; } @@ -53,11 +58,12 @@ export const BlockColorsItem = < @@ -69,12 +75,12 @@ export const BlockColorsItem = < : undefined } background={ - checkBlockTypeHasDefaultProp( - "backgroundColor", - props.block.type, - editor, - ) && - checkBlockHasDefaultProp("backgroundColor", props.block, editor) + blockHasTypeAndProps(props.block, editor, props.block.type, { + backgroundColor: defaultProps.backgroundColor, + }) && + editorHasBlockWithTypeAndProps(editor, props.block.type, { + backgroundColor: defaultProps.backgroundColor, + }) ? { color: props.block.props.backgroundColor, setColor: (color) => From 97295ceea74afafb3724cb226cf149eebcef41bd Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 6 Aug 2025 13:24:09 +0200 Subject: [PATCH 11/19] Updated type guard definitions --- .../src/FileReplaceButton.tsx | 4 +- .../core/src/blocks/defaultBlockTypeGuards.ts | 197 +++++++++--------- .../getDefaultSlashMenuItems.ts | 27 +-- .../DefaultButtons/FileCaptionButton.tsx | 19 +- .../DefaultButtons/FileDeleteButton.tsx | 6 +- .../DefaultButtons/FileDownloadButton.tsx | 6 +- .../DefaultButtons/FilePreviewButton.tsx | 15 +- .../DefaultButtons/FileRenameButton.tsx | 19 +- .../DefaultButtons/FileReplaceButton.tsx | 6 +- .../DefaultButtons/TextAlignButton.tsx | 16 +- .../DefaultItems/BlockColorsItem.tsx | 45 ++-- 11 files changed, 189 insertions(+), 171 deletions(-) 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 c0442bb2f7..f1c8c84293 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, - blockHasProps, + blockHasType, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -41,7 +41,7 @@ export const FileReplaceButton = () => { if ( block === undefined || - !blockHasProps(block, { url: { default: "" } }) || + !blockHasType(block, editor, ["url"]) || !editor.isEditable ) { return null; diff --git a/packages/core/src/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts index 64731a8a06..ffe59f73a6 100644 --- a/packages/core/src/blocks/defaultBlockTypeGuards.ts +++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts @@ -14,42 +14,31 @@ import { } from "./defaultBlocks.js"; import { Selection } from "prosemirror-state"; -export function editorHasBlockWithType( - editor: BlockNoteEditor, - blockType: BType, -): editor is BlockNoteEditor< - { - [BT in BType]: BlockConfig< - BT, - { - [PN in string]: PropSpec; - } - >; - }, - any, - any -> { - if (!(blockType in editor.schema.blockSpecs)) { - return false; - } - - if (editor.schema.blockSpecs[blockType].config.type !== blockType) { - return false; - } - - return true; -} - -export function editorHasBlockWithTypeAndProps< +export function editorHasBlockWithType< BType extends string, - PSchema extends PropSchema, + Props extends + | PropSchema + | Record + | undefined = undefined, >( editor: BlockNoteEditor, blockType: BType, - propSchema: PSchema, + props?: Props, ): editor is BlockNoteEditor< { - [BT in BType]: BlockConfig; + [BT in BType]: Props extends PropSchema + ? BlockConfig + : Props extends Record + ? BlockConfig< + BT, + { + [PN in keyof Props]: { + default: undefined; + type: Props[PN]; + }; + } + > + : BlockConfig; }, any, any @@ -57,113 +46,135 @@ export function editorHasBlockWithTypeAndProps< if (!editorHasBlockWithType(editor, blockType)) { return false; } + if (!props) { + return true; + } - for (const [propName, propSpec] of Object.entries(propSchema)) { + for (const [propName, propSpec] of Object.entries(props)) { if (!(propName in editor.schema.blockSpecs[blockType].config.propSchema)) { return false; } - if ( - editor.schema.blockSpecs[blockType].config.propSchema[propName] - .default !== propSpec.default - ) { - return false; - } - - if ( - typeof editor.schema.blockSpecs[blockType].config.propSchema[propName] - .values !== typeof propSpec.values - ) { - return false; - } + 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; + } - 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 + 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; } - for ( - let i = 0; - i < - editor.schema.blockSpecs[blockType].config.propSchema[propName].values - .length; - i++ + if ( + editor.schema.blockSpecs[blockType].config.propSchema[propName] + .default === undefined && + propSpec.default === undefined ) { if ( editor.schema.blockSpecs[blockType].config.propSchema[propName] - .values[i] !== propSpec.values[i] + .type !== propSpec.type ) { return 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 + 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; + } + } + } } } return true; } -export function blockHasType( - block: Block, - editor: BlockNoteEditor, - blockType: BType, -): block is Block< - { - [BT in string]: BlockConfig< - BT, - { - [PN in string]: PropSpec; - } - >; - }, - any, - any -> { - return editorHasBlockWithType(editor, blockType) && block.type === blockType; -} - -export function blockHasTypeAndProps< +export function blockHasType< BType extends string, - PSchema extends PropSchema, + Props extends + | PropSchema + | Record + | undefined = undefined, >( block: Block, editor: BlockNoteEditor, blockType: BType, - propSchema: PSchema, + props?: Props, ): block is Block< { - [BT in string]: BlockConfig; + [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; }, any, any > { return ( - editorHasBlockWithTypeAndProps(editor, blockType, propSchema) && - block.type === blockType + editorHasBlockWithType(editor, blockType, props) && block.type === blockType ); } -// TODO: Only used in the emoji picker - is it even needed? If so, needs to be -// changed to be like the block type guards. +// TODO: Only used once in the emoji picker - is it even needed? If so, should +// be changed to be like the block type guards. export function checkDefaultInlineContentTypeInSchema< InlineContentType extends keyof DefaultInlineContentSchema, B extends BlockSchema, diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts index 593deebd1d..b37d4be1da 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts @@ -5,10 +5,7 @@ import { } from "../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { - editorHasBlockWithType, - editorHasBlockWithTypeAndProps, -} from "../../blocks/defaultBlockTypeGuards.js"; +import { editorHasBlockWithType } from "../../blocks/defaultBlockTypeGuards.js"; import { BlockSchema, InlineContentSchema, @@ -94,11 +91,7 @@ export function getDefaultSlashMenuItems< >(editor: BlockNoteEditor) { const items: DefaultSuggestionItem[] = []; - if ( - editorHasBlockWithTypeAndProps(editor, "heading", { - level: defaultBlockSpecs["heading"].config.propSchema.level, - }) - ) { + if (editorHasBlockWithType(editor, "heading", { level: "number" })) { items.push( { onItemClick: () => { @@ -250,7 +243,7 @@ export function getDefaultSlashMenuItems< }); } - if (editorHasBlockWithType(editor, "image")) { + if (editorHasBlockWithType(editor, "image", { url: "string" })) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { @@ -269,7 +262,7 @@ export function getDefaultSlashMenuItems< }); } - if (editorHasBlockWithType(editor, "video")) { + if (editorHasBlockWithType(editor, "video", { url: "string" })) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { @@ -288,7 +281,7 @@ export function getDefaultSlashMenuItems< }); } - if (editorHasBlockWithType(editor, "audio")) { + if (editorHasBlockWithType(editor, "audio", { url: "string" })) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { @@ -307,7 +300,7 @@ export function getDefaultSlashMenuItems< }); } - if (editorHasBlockWithType(editor, "file")) { + if (editorHasBlockWithType(editor, "file", { url: "string" })) { items.push({ onItemClick: () => { const insertedBlock = insertOrUpdateBlock(editor, { @@ -327,11 +320,9 @@ export function getDefaultSlashMenuItems< } if ( - editorHasBlockWithTypeAndProps(editor, "heading", { - level: defaultBlockSpecs["heading"].config.propSchema.level, - isToggleable: { - default: true, - }, + editorHasBlockWithType(editor, "heading", { + level: "number", + isToggleable: "boolean", }) ) { items.push( diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index 48c6b90a31..38b1fef90c 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -1,6 +1,7 @@ import { - blockHasTypeAndProps, + blockHasType, BlockSchema, + editorHasBlockWithType, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -41,9 +42,9 @@ export const FileCaptionButton = () => { const block = selectedBlocks[0]; if ( - blockHasTypeAndProps(block, editor, block.type, { - url: { default: "" }, - caption: { default: "" }, + blockHasType(block, editor, block.type, { + url: "string", + caption: "string", }) ) { setCurrentEditingCaption(block.props.caption); @@ -55,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, }, }); } diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx index 6e1ab879a6..2ea096de36 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx @@ -1,5 +1,5 @@ import { - blockHasTypeAndProps, + blockHasType, BlockSchema, InlineContentSchema, StyleSchema, @@ -32,9 +32,7 @@ export const FileDeleteButton = () => { const block = selectedBlocks[0]; - if ( - blockHasTypeAndProps(block, editor, block.type, { url: { default: "" } }) - ) { + if (blockHasType(block, editor, block.type, { url: "string" })) { return block; } diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx index ce0896cda8..d19e202564 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx @@ -1,5 +1,5 @@ import { - blockHasTypeAndProps, + blockHasType, BlockSchema, InlineContentSchema, StyleSchema, @@ -33,9 +33,7 @@ export const FileDownloadButton = () => { const block = selectedBlocks[0]; - if ( - blockHasTypeAndProps(block, editor, block.type, { url: { default: "" } }) - ) { + if (blockHasType(block, editor, block.type, { url: "string" })) { return block; } diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx index 1439d779db..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 { - blockHasTypeAndProps, + blockHasType, BlockSchema, - editorHasBlockWithTypeAndProps, + editorHasBlockWithType, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -34,9 +34,9 @@ export const FilePreviewButton = () => { const block = selectedBlocks[0]; if ( - blockHasTypeAndProps(block, editor, block.type, { - url: { default: "" }, - showPreview: { default: true }, + blockHasType(block, editor, block.type, { + url: "string", + showPreview: "boolean", }) ) { return block; @@ -48,9 +48,8 @@ export const FilePreviewButton = () => { const onClick = useCallback(() => { if ( fileBlock && - editorHasBlockWithTypeAndProps(editor, fileBlock.type, { - url: { default: "" }, - showPreview: { default: true }, + editorHasBlockWithType(editor, fileBlock.type, { + showPreview: "boolean", }) ) { editor.updateBlock(fileBlock, { diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx index f1c7c92a4f..583494917f 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx @@ -1,6 +1,7 @@ import { - blockHasTypeAndProps, + blockHasType, BlockSchema, + editorHasBlockWithType, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -41,9 +42,9 @@ export const FileRenameButton = () => { const block = selectedBlocks[0]; if ( - blockHasTypeAndProps(block, editor, block.type, { - url: { default: "" }, - name: { default: "" }, + blockHasType(block, editor, block.type, { + url: "string", + name: "string", }) ) { setCurrentEditingName(block.props.name); @@ -55,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, }, }); } diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx index 22fd3d439b..ed68593186 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileReplaceButton.tsx @@ -1,5 +1,5 @@ import { - blockHasTypeAndProps, + blockHasType, BlockSchema, InlineContentSchema, StyleSchema, @@ -35,8 +35,8 @@ export const FileReplaceButton = () => { if ( block === undefined || - !blockHasTypeAndProps(block, editor, block.type, { - url: { default: "" }, + !blockHasType(block, editor, block.type, { + url: "string", }) || !editor.isEditable ) { diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx index bbe14def1d..05b38f6a7a 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx @@ -1,9 +1,9 @@ import { - blockHasTypeAndProps, + blockHasType, BlockSchema, defaultProps, DefaultProps, - editorHasBlockWithTypeAndProps, + editorHasBlockWithType, InlineContentSchema, mapTableCell, StyleSchema, @@ -48,7 +48,7 @@ export const TextAlignButton = (props: { textAlignment: TextAlignment }) => { const block = selectedBlocks[0]; if ( - blockHasTypeAndProps(block, editor, block.type, { + blockHasType(block, editor, block.type, { textAlignment: defaultProps.textAlignment, }) ) { @@ -81,10 +81,10 @@ export const TextAlignButton = (props: { textAlignment: TextAlignment }) => { for (const block of selectedBlocks) { if ( - blockHasTypeAndProps(block, editor, block.type, { + blockHasType(block, editor, block.type, { textAlignment: defaultProps.textAlignment, }) && - editorHasBlockWithTypeAndProps(editor, block.type, { + editorHasBlockWithType(editor, block.type, { textAlignment: defaultProps.textAlignment, }) ) { @@ -134,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 56acea6fac..c8733458d6 100644 --- a/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx +++ b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx @@ -1,11 +1,11 @@ import { - blockHasTypeAndProps, + Block, + blockHasType, BlockSchema, DefaultBlockSchema, DefaultInlineContentSchema, - defaultProps, DefaultStyleSchema, - editorHasBlockWithTypeAndProps, + editorHasBlockWithType, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -29,12 +29,17 @@ 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 ( - !blockHasTypeAndProps(props.block, editor, props.block.type, { - textColor: defaultProps.textColor, + !blockHasType(block, editor, block.type, { + textColor: "string", }) || - !blockHasTypeAndProps(props.block, editor, props.block.type, { - backgroundColor: defaultProps.backgroundColor, + !blockHasType(block, editor, block.type, { + backgroundColor: "string", }) ) { return null; @@ -58,33 +63,33 @@ export const BlockColorsItem = < - editor.updateBlock(props.block, { - type: props.block.type, + editor.updateBlock(block, { + type: block.type, props: { textColor: color }, }), } : undefined } background={ - blockHasTypeAndProps(props.block, editor, props.block.type, { - backgroundColor: defaultProps.backgroundColor, + blockHasType(block, editor, block.type, { + backgroundColor: "string", }) && - editorHasBlockWithTypeAndProps(editor, props.block.type, { - backgroundColor: defaultProps.backgroundColor, + 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 }, }), } From 1ff7474283abf1529aaddd3ad379cbcb0d89a773 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 6 Aug 2025 13:24:59 +0200 Subject: [PATCH 12/19] Small fix --- .../11-uppy-file-panel/src/FileReplaceButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f1c8c84293..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 @@ -41,7 +41,7 @@ export const FileReplaceButton = () => { if ( block === undefined || - !blockHasType(block, editor, ["url"]) || + !blockHasType(block, editor, { url: "string" }) || !editor.isEditable ) { return null; From 991851070e24d775652311a3ae2fe34fdb92b95f Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 6 Aug 2025 15:39:47 +0200 Subject: [PATCH 13/19] feat: getting somewhere --- packages/core/src/blks/Audio/definition.ts | 55 +++++------ .../src/blks/BulletListItem/definition.ts | 17 ++-- .../core/src/blks/CheckListItem/definition.ts | 19 ++-- packages/core/src/blks/Code/definition.ts | 29 +++--- packages/core/src/blks/File/definition.ts | 47 +++++----- packages/core/src/blks/Heading/definition.ts | 21 +++-- packages/core/src/blks/Image/definition.ts | 67 ++++++------- .../src/blks/NumberedListItem/definition.ts | 19 ++-- .../core/src/blks/PageBreak/definition.ts | 13 ++- .../core/src/blks/Paragraph/definition.ts | 13 ++- packages/core/src/blks/Quote/definition.ts | 13 ++- .../src/blks/ToggleListItem/definition.ts | 17 ++-- packages/core/src/blks/Video/definition.ts | 2 +- packages/core/src/blks/index.ts | 2 +- .../blocks/PageBreakBlockContent/schema.ts | 2 +- packages/core/src/blocks/defaultBlocks.ts | 36 +++++-- packages/core/src/editor/BlockNoteEditor.ts | 4 +- packages/core/src/editor/BlockNoteSchema.ts | 22 ++--- packages/core/src/editor/CustomSchema.ts | 24 +++-- packages/core/src/editor/playground.ts | 93 ------------------- packages/core/src/schema/blocks/createSpec.ts | 14 ++- packages/core/src/schema/blocks/types.ts | 37 ++++++-- packages/core/src/schema/propTypes.ts | 24 +++-- .../react/src/components/Comments/schema.ts | 4 +- 24 files changed, 295 insertions(+), 299 deletions(-) delete mode 100644 packages/core/src/editor/playground.ts diff --git a/packages/core/src/blks/Audio/definition.ts b/packages/core/src/blks/Audio/definition.ts index eb36220e3a..c25b30b090 100644 --- a/packages/core/src/blks/Audio/definition.ts +++ b/packages/core/src/blks/Audio/definition.ts @@ -15,35 +15,38 @@ 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, - }, +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" as const, - meta: { - fileBlockAccept: ["audio/*"], - }, -})); + showPreview: { + default: true, + }, + }, + content: "none", + meta: { + fileBlockAccept: ["audio/*"], + }, + }) as const, +); export const definition = createBlockDefinition(config).implementation( - (config) => ({ + (config = {}) => ({ parse: (element) => { if (element.tagName === "AUDIO") { // Ignore if parent figure has already been parsed. diff --git a/packages/core/src/blks/BulletListItem/definition.ts b/packages/core/src/blks/BulletListItem/definition.ts index e1c2e5b618..ba6e378f93 100644 --- a/packages/core/src/blks/BulletListItem/definition.ts +++ b/packages/core/src/blks/BulletListItem/definition.ts @@ -9,13 +9,16 @@ import { } from "../../schema/index.js"; import { handleEnter } from "../utils/listItemEnterHandler.js"; -const config = createBlockConfig(() => ({ - type: "bulletListItem" as const, - propSchema: { - ...defaultProps, - }, - content: "inline", -})); +const config = createBlockConfig( + () => + ({ + type: "bulletListItem" as const, + propSchema: { + ...defaultProps, + }, + content: "inline", + }) as const, +); export const definition = createBlockDefinition(config).implementation( () => ({ diff --git a/packages/core/src/blks/CheckListItem/definition.ts b/packages/core/src/blks/CheckListItem/definition.ts index 5f58c7ce5d..b88b167611 100644 --- a/packages/core/src/blks/CheckListItem/definition.ts +++ b/packages/core/src/blks/CheckListItem/definition.ts @@ -9,14 +9,17 @@ import { } 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", -})); +const config = createBlockConfig( + () => + ({ + type: "checkListItem" as const, + propSchema: { + ...defaultProps, + checked: { default: false, type: "boolean" }, + }, + content: "inline", + }) as const, +); export const definition = createBlockDefinition(config).implementation( () => ({ diff --git a/packages/core/src/blks/Code/definition.ts b/packages/core/src/blks/Code/definition.ts index da881d3962..4e1234b2d8 100644 --- a/packages/core/src/blks/Code/definition.ts +++ b/packages/core/src/blks/Code/definition.ts @@ -54,19 +54,20 @@ export type CodeBlockOptions = { }; const config = createBlockConfig( - ({ defaultLanguage = "text" }: CodeBlockOptions = {}) => ({ - type: "codeBlock" as const, - propSchema: { - language: { - default: defaultLanguage, + ({ defaultLanguage = "text" }: CodeBlockOptions = {}) => + ({ + type: "codeBlock" as const, + propSchema: { + language: { + default: defaultLanguage, + }, }, - }, - content: "inline", - meta: { - code: true, - defining: true, - }, - }), + content: "inline", + meta: { + code: true, + defining: true, + }, + }) as const, ); export const definition = createBlockDefinition(config).implementation( @@ -84,7 +85,7 @@ export const definition = createBlockDefinition(config).implementation( const wrapper = document.createDocumentFragment(); const pre = document.createElement("pre"); const code = document.createElement("code"); - code.textContent = block.content; + code.textContent = block.content as unknown as string; pre.appendChild(code); const select = document.createElement("select"); const selectWrapper = document.createElement("div"); @@ -123,7 +124,7 @@ export const definition = createBlockDefinition(config).implementation( pre.className = `language-${block.props.language}`; pre.dataset.language = block.props.language; const code = document.createElement("code"); - code.textContent = block.content; + code.textContent = block.content as unknown as string; pre.appendChild(code); return { dom: pre, diff --git a/packages/core/src/blks/File/definition.ts b/packages/core/src/blks/File/definition.ts index 5350505010..35f6d1f46a 100644 --- a/packages/core/src/blks/File/definition.ts +++ b/packages/core/src/blks/File/definition.ts @@ -8,28 +8,31 @@ import { 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: ["*/*"], - }, -})); +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) => { diff --git a/packages/core/src/blks/Heading/definition.ts b/packages/core/src/blks/Heading/definition.ts index 443c5672ea..2c9543100d 100644 --- a/packages/core/src/blks/Heading/definition.ts +++ b/packages/core/src/blks/Heading/definition.ts @@ -1,5 +1,6 @@ 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, @@ -21,14 +22,18 @@ const config = createBlockConfig( defaultLevel = 1, levels = HEADING_LEVELS, allowToggleHeadings = true, - }: HeadingOptions = {}) => ({ - type: "heading" as const, - propSchema: { - level: { default: defaultLevel, values: levels }, - ...(allowToggleHeadings ? { isToggleable: { default: false } } : {}), - }, - content: "inline", - }), + }: 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( diff --git a/packages/core/src/blks/Image/definition.ts b/packages/core/src/blks/Image/definition.ts index af8c34e025..aa918dce3f 100644 --- a/packages/core/src/blks/Image/definition.ts +++ b/packages/core/src/blks/Image/definition.ts @@ -15,38 +15,41 @@ 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", - }, - }, - content: "none" as const, - meta: { - fileBlockAccept: ["image/*"], - }, -})); +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 = {}) => ({ diff --git a/packages/core/src/blks/NumberedListItem/definition.ts b/packages/core/src/blks/NumberedListItem/definition.ts index 6e6f35476a..088f57bffd 100644 --- a/packages/core/src/blks/NumberedListItem/definition.ts +++ b/packages/core/src/blks/NumberedListItem/definition.ts @@ -10,14 +10,17 @@ import { 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" }, - }, - content: "inline", -})); +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( () => ({ diff --git a/packages/core/src/blks/PageBreak/definition.ts b/packages/core/src/blks/PageBreak/definition.ts index 506c7a1f26..90a2ad9c43 100644 --- a/packages/core/src/blks/PageBreak/definition.ts +++ b/packages/core/src/blks/PageBreak/definition.ts @@ -3,11 +3,14 @@ import { createBlockDefinition, } from "../../schema/index.js"; -const config = createBlockConfig(() => ({ - type: "pageBreak" as const, - propSchema: {}, - content: "none", -})); +const config = createBlockConfig( + () => + ({ + type: "pageBreak" as const, + propSchema: {}, + content: "none", + }) as const, +); export const definition = createBlockDefinition(config).implementation(() => ({ parse(element) { diff --git a/packages/core/src/blks/Paragraph/definition.ts b/packages/core/src/blks/Paragraph/definition.ts index 633e060d60..6f103840b6 100644 --- a/packages/core/src/blks/Paragraph/definition.ts +++ b/packages/core/src/blks/Paragraph/definition.ts @@ -7,11 +7,14 @@ import { createBlockDefinition, } from "../../schema/index.js"; -const config = createBlockConfig(() => ({ - type: "paragraph" as const, - propSchema: defaultProps, - content: "inline", -})); +const config = createBlockConfig( + () => + ({ + type: "paragraph" as const, + propSchema: defaultProps, + content: "inline" as const, + }) as const, +); export const definition = createBlockDefinition(config).implementation( () => ({ diff --git a/packages/core/src/blks/Quote/definition.ts b/packages/core/src/blks/Quote/definition.ts index d8481e51a7..267d37d391 100644 --- a/packages/core/src/blks/Quote/definition.ts +++ b/packages/core/src/blks/Quote/definition.ts @@ -7,11 +7,14 @@ import { createBlockDefinition, } from "../../schema/index.js"; -const config = createBlockConfig(() => ({ - type: "quote" as const, - propSchema: { ...defaultProps }, - content: "inline", -})); +const config = createBlockConfig( + () => + ({ + type: "quote" as const, + propSchema: { ...defaultProps }, + content: "inline" as const, + }) as const, +); export const definition = createBlockDefinition(config).implementation( () => ({ diff --git a/packages/core/src/blks/ToggleListItem/definition.ts b/packages/core/src/blks/ToggleListItem/definition.ts index 1058f3b0f2..59f304007c 100644 --- a/packages/core/src/blks/ToggleListItem/definition.ts +++ b/packages/core/src/blks/ToggleListItem/definition.ts @@ -9,13 +9,16 @@ import { } from "../../schema/index.js"; import { handleEnter } from "../utils/listItemEnterHandler.js"; -const config = createBlockConfig(() => ({ - type: "toggleListItem" as const, - propSchema: { - ...defaultProps, - }, - content: "inline", -})); +const config = createBlockConfig( + () => + ({ + type: "toggleListItem" as const, + propSchema: { + ...defaultProps, + }, + content: "inline" as const, + }) as const, +); export const definition = createBlockDefinition(config).implementation( () => ({ diff --git a/packages/core/src/blks/Video/definition.ts b/packages/core/src/blks/Video/definition.ts index fe681a58cd..7f4a101a60 100644 --- a/packages/core/src/blks/Video/definition.ts +++ b/packages/core/src/blks/Video/definition.ts @@ -33,7 +33,7 @@ const config = createBlockConfig((_ctx: VideoOptions) => ({ })); export const definition = createBlockDefinition(config).implementation( - (config) => ({ + (config = {}) => ({ parse: (element) => { if (element.tagName === "VIDEO") { // Ignore if parent figure has already been parsed. diff --git a/packages/core/src/blks/index.ts b/packages/core/src/blks/index.ts index a501019f03..97473296ef 100644 --- a/packages/core/src/blks/index.ts +++ b/packages/core/src/blks/index.ts @@ -6,7 +6,7 @@ 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 quoteBlock from "./Quote/definition.js"; +export * as quote from "./Quote/definition.js"; export * as toggleListItem from "./ToggleListItem/definition.js"; export * as file from "./File/definition.js"; 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/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts index 81e32dd5ce..9734548368 100644 --- a/packages/core/src/blocks/defaultBlocks.ts +++ b/packages/core/src/blocks/defaultBlocks.ts @@ -14,17 +14,17 @@ import { numberedListItem, pageBreak, paragraph, - quoteBlock, + 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, - ExtractBlockConfig, InlineContentSchema, InlineContentSpecs, PartialBlockNoDefaults, @@ -45,19 +45,41 @@ export const defaultBlockSpecs = { heading: heading.definition(), numberedListItem: numberedListItem.definition(), pageBreak: pageBreak.definition(), - quoteBlock: quoteBlock.definition(), + quote: quote.definition(), toggleListItem: toggleListItem.definition(), file: file.definition(), image: image.definition(), video: video.definition(), - table: Table as unknown as BlockDefinition, + 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 = { - [K in keyof typeof defaultBlockSpecs]: ExtractBlockConfig< - (typeof defaultBlockSpecs)[K] +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; diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 096f15edee..77edc0f19e 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -667,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, diff --git a/packages/core/src/editor/BlockNoteSchema.ts b/packages/core/src/editor/BlockNoteSchema.ts index e190239b75..5dfac1a79b 100644 --- a/packages/core/src/editor/BlockNoteSchema.ts +++ b/packages/core/src/editor/BlockNoteSchema.ts @@ -4,25 +4,22 @@ import { defaultStyleSpecs, } from "../blocks/defaultBlocks.js"; import { - BlockDefinition, BlockNoDefaults, + BlockSchema, InlineContentSchema, InlineContentSchemaFromSpecs, InlineContentSpecs, PartialBlockNoDefaults, - PropSchema, StyleSchema, StyleSchemaFromSpecs, StyleSpecs, } from "../schema/index.js"; import { BlockNoteEditor } from "./BlockNoteEditor.js"; -import { CustomBlockNoteSchema } from "./CustomSchema.js"; +import { BlockSpecOf, CustomBlockNoteSchema } from "./CustomSchema.js"; export class BlockNoteSchema< - BSpecs extends { - [key in string]: BlockDefinition; - }, + BSpecs extends BlockSchema, ISchema extends InlineContentSchema, SSchema extends StyleSchema, > extends CustomBlockNoteSchema { @@ -41,16 +38,16 @@ export class BlockNoteSchema< > = "only for types" as any; public static create< - BSpecs extends { - [key in string]: BlockDefinition; - } = 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. */ @@ -61,12 +58,11 @@ export class BlockNoteSchema< styleSpecs?: SSpecs; }) { return new BlockNoteSchema< - Record>, + BSpecs, InlineContentSchemaFromSpecs, StyleSchemaFromSpecs >({ - blockSpecs: - options?.blockSpecs ?? (defaultBlockSpecs as unknown as BSpecs), + 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 index 5392ba63f7..5d1ee04b22 100644 --- a/packages/core/src/editor/CustomSchema.ts +++ b/packages/core/src/editor/CustomSchema.ts @@ -1,6 +1,6 @@ import { BlockDefinition, - BlockSpecs, + BlockSchema, InlineContentSchema, InlineContentSpecs, PropSchema, @@ -21,23 +21,27 @@ function removeUndefined | undefined>(obj: T): T { ) as T; } +export type BlockSpecOf = { + [key in keyof BSpecs]: key extends string + ? BlockDefinition + : never; +}; + export class CustomBlockNoteSchema< - BSpecs extends { - [key in string]: BlockDefinition; - }, + BSpecs extends BlockSchema, ISchema extends InlineContentSchema, SSchema extends StyleSchema, > { public readonly inlineContentSpecs: InlineContentSpecs; public readonly styleSpecs: StyleSpecs; - public readonly blockSpecs: BlockSpecs; + public readonly blockSpecs: BlockSpecOf; - public readonly blockSchema: Record; + public readonly blockSchema: BSpecs; public readonly inlineContentSchema: ISchema; public readonly styleSchema: SSchema; constructor(opts: { - blockSpecs: BSpecs; + blockSpecs: BlockSpecOf; inlineContentSpecs: InlineContentSpecs; styleSpecs: StyleSpecs; }) { @@ -56,7 +60,7 @@ export class CustomBlockNoteSchema< this.styleSchema = getStyleSchemaFromSpecs(this.styleSpecs) as any; } - private initBlockSpecs(specs: BSpecs) { + private initBlockSpecs(specs: BlockSpecOf): BlockSpecOf { const dag = createDependencyGraph(); const defaultSet = new Set(); dag.set("default", defaultSet); @@ -102,7 +106,7 @@ export class CustomBlockNoteSchema< blockDef.config.type === "table" ? blockDef : createBlockSpec( - blockDef.config, + blockDef.config as any, blockDef.implementation as any, getPriority(key), ), @@ -110,6 +114,6 @@ export class CustomBlockNoteSchema< ]; }, ), - ); + ) as unknown as BlockSpecOf; } } diff --git a/packages/core/src/editor/playground.ts b/packages/core/src/editor/playground.ts deleted file mode 100644 index ce358a44c7..0000000000 --- a/packages/core/src/editor/playground.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - audio, - bulletListItem, - checkListItem, - codeBlock, - file, - heading, - image, - numberedListItem, - pageBreak, - paragraph, - quoteBlock, - toggleListItem, - video, -} from "../blks/index.js"; -import { - defaultInlineContentSpecs, - defaultStyleSpecs, -} from "../blocks/defaultBlocks.js"; -import { - BlockDefinition, - InlineContentSchema, - InlineContentSchemaFromSpecs, - InlineContentSpecs, - PropSchema, - StyleSchema, - StyleSchemaFromSpecs, - StyleSpecs, -} from "../schema/index.js"; -import { CustomBlockNoteSchema } from "./CustomSchema.js"; - -const 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, - quoteBlock: quoteBlock.definition, - toggleListItem: toggleListItem.definition, - file: file.definition, - image: image.definition, - video: video.definition, -}; - -export class BlockNoteSchema2< - BSpecs extends { - [key in string]: BlockDefinition; - }, - ISchema extends InlineContentSchema, - SSchema extends StyleSchema, -> extends CustomBlockNoteSchema { - public static create< - BSpecs extends { - [key in string]: BlockDefinition; - }, - 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; - /** - * A list of custom InlineContent types that should be available in the editor. - */ - inlineContentSpecs?: ISpecs; - /** - * A list of custom Styles that should be available in the editor. - */ - styleSpecs?: SSpecs; - }) { - return new BlockNoteSchema2< - Record>, - InlineContentSchemaFromSpecs, - StyleSchemaFromSpecs - >({ - blockSpecs: - options?.blockSpecs ?? - (Object.fromEntries( - Object.entries(defaultBlockSpecs).map(([key, value]) => [ - key, - value(({ ...options }[key] ?? {}) as never), - ]), - ) as any), - inlineContentSpecs: - options?.inlineContentSpecs ?? defaultInlineContentSpecs, - styleSpecs: options?.styleSpecs ?? defaultStyleSpecs, - }); - } -} diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 56a1959690..43ce3bdb09 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -346,11 +346,14 @@ export function createBlockSpec< export function createBlockConfig< TCallback extends ( options: Partial>, - ) => BlockConfig, + ) => BlockConfig, TOptions extends Parameters[0], TName extends ReturnType["type"], TProps extends ReturnType["propSchema"], ->(callback: TCallback): (options: TOptions) => BlockConfig { + TContent extends ReturnType["content"], +>( + callback: TCallback, +): (options: TOptions) => BlockConfig { return callback; } @@ -362,17 +365,18 @@ export function createBlockDefinition< TOptions extends Parameters[0], TName extends ReturnType["type"], TProps extends ReturnType["propSchema"], + TContent extends ReturnType["content"], >( callback: TCallback, ): { implementation: ( - cb: (options?: TOptions) => BlockImplementation, + cb: (options?: TOptions) => BlockImplementation, addExtensions?: (options?: TOptions) => BlockNoteExtension[], - ) => (options?: TOptions) => BlockDefinition; + ) => (options?: TOptions) => BlockDefinition; } { return { implementation: (cb, addExtensions) => (options) => ({ - config: callback(options), + config: callback(options) as any, implementation: cb(options), extensions: addExtensions?.(options), }), diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index dfbcd922a5..048c18db96 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -53,6 +53,7 @@ export interface BlockConfigMeta { 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) @@ -66,7 +67,7 @@ export interface BlockConfig< /** * The content that the block supports */ - content: "inline" | "none"; + content: TContent; // TODO: how do you represent things that have nested content? // e.g. tables, alerts (with title & content) /** @@ -332,6 +333,7 @@ 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 @@ -340,11 +342,17 @@ export interface BlockImplementation< /** * The custom block to render */ - block: BlockNoDefaults>, any, any>, + block: BlockNoDefaults< + Record>, + any, + any + >, /** * The BlockNote editor instance */ - editor: BlockNoteEditor>>, + editor: BlockNoteEditor< + Record> + >, ) => { dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; @@ -357,8 +365,14 @@ export interface BlockImplementation< * as `render(...).dom`. */ toExternalHTML?: ( - block: BlockNoDefaults>, any, any>, - editor: BlockNoteEditor>>, + block: BlockNoDefaults< + Record>, + any, + any + >, + editor: BlockNoteEditor< + Record> + >, ) => | { dom: HTMLElement; @@ -387,14 +401,19 @@ export interface BlockImplementation< export type BlockDefinition< TName extends string = string, TProps extends PropSchema = PropSchema, + TContent extends "inline" | "none" | "table" = "inline" | "none" | "table", > = { - config: BlockConfig; - implementation: BlockImplementation, NoInfer>; + config: BlockConfig; + implementation: BlockImplementation< + NoInfer, + NoInfer, + TContent + >; extensions?: BlockNoteExtension[]; }; export type ExtractBlockConfig = T extends ( options: any, -) => BlockDefinition - ? BlockConfig +) => BlockDefinition + ? BlockConfig : never; diff --git a/packages/core/src/schema/propTypes.ts b/packages/core/src/schema/propTypes.ts index 76a8df2769..00e6bcf147 100644 --- a/packages/core/src/schema/propTypes.ts +++ b/packages/core/src/schema/propTypes.ts @@ -34,17 +34,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/react/src/components/Comments/schema.ts b/packages/react/src/components/Comments/schema.ts index 576aa2c3ef..9d055513d3 100644 --- a/packages/react/src/components/Comments/schema.ts +++ b/packages/react/src/components/Comments/schema.ts @@ -3,6 +3,7 @@ import { createBlockSpecFromStronglyTypedTiptapNode, createStronglyTypedTiptapNode, defaultBlockSpecs, + DefaultBlockSchema, defaultStyleSpecs, } from "@blocknote/core"; @@ -10,7 +11,8 @@ import { // it easier to extend / customize the default blocks const paragraph = createBlockSpecFromStronglyTypedTiptapNode( createStronglyTypedTiptapNode<"paragraph", "inline*">( - defaultBlockSpecs.paragraph.implementation.node.config as any, + // TODO this is a mess + {} as any, ), // disable default props on paragraph (such as textalignment and colors) {}, From fc9e4520611e641220c5b24e02bf5ab47da3f94f Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 6 Aug 2025 17:13:30 +0200 Subject: [PATCH 14/19] Removed `checkDefaultInlineContentTypeInSchema` --- .../core/src/blocks/defaultBlockTypeGuards.ts | 35 ++----------------- .../getDefaultEmojiPickerItems.ts | 8 +++-- 2 files changed, 8 insertions(+), 35 deletions(-) diff --git a/packages/core/src/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts index ffe59f73a6..82b35f24b0 100644 --- a/packages/core/src/blocks/defaultBlockTypeGuards.ts +++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts @@ -1,17 +1,7 @@ import { CellSelection } from "prosemirror-tables"; import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; -import { - BlockConfig, - BlockSchema, - PropSchema, - PropSpec, - StyleSchema, -} from "../schema/index.js"; -import { - Block, - DefaultInlineContentSchema, - defaultInlineContentSchema, -} from "./defaultBlocks.js"; +import { BlockConfig, PropSchema, PropSpec } from "../schema/index.js"; +import { Block } from "./defaultBlocks.js"; import { Selection } from "prosemirror-state"; export function editorHasBlockWithType< @@ -173,27 +163,6 @@ export function blockHasType< ); } -// TODO: Only used once in the emoji picker - is it even needed? If so, should -// be changed to be like the block type guards. -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] - ); -} - export function isTableCellSelection( selection: Selection, ): selection is CellSelection { 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 []; } From a0fc6e2f2185b4c955335669a3d2d99c291f8bb5 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 6 Aug 2025 17:20:58 +0200 Subject: [PATCH 15/19] Small fixes --- packages/core/src/blks/Heading/definition.ts | 2 ++ packages/core/src/blocks/defaultBlockTypeGuards.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/blks/Heading/definition.ts b/packages/core/src/blks/Heading/definition.ts index 443c5672ea..0a365b7c51 100644 --- a/packages/core/src/blks/Heading/definition.ts +++ b/packages/core/src/blks/Heading/definition.ts @@ -1,5 +1,6 @@ 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, @@ -24,6 +25,7 @@ const config = createBlockConfig( }: HeadingOptions = {}) => ({ type: "heading" as const, propSchema: { + ...defaultProps, level: { default: defaultLevel, values: levels }, ...(allowToggleHeadings ? { isToggleable: { default: false } } : {}), }, diff --git a/packages/core/src/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts index 82b35f24b0..c3db69fdaf 100644 --- a/packages/core/src/blocks/defaultBlockTypeGuards.ts +++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts @@ -33,9 +33,10 @@ export function editorHasBlockWithType< any, any > { - if (!editorHasBlockWithType(editor, blockType)) { + if (!(blockType in editor.schema.blockSpecs)) { return false; } + if (!props) { return true; } From cda8eb6858bfe977dccc4f3e6c7231b36d6e345b Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 7 Aug 2025 16:30:28 +0200 Subject: [PATCH 16/19] fix: better parsing for headings --- packages/core/src/blks/Heading/definition.ts | 29 ++++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/core/src/blks/Heading/definition.ts b/packages/core/src/blks/Heading/definition.ts index 2c9543100d..e0cc5a1602 100644 --- a/packages/core/src/blks/Heading/definition.ts +++ b/packages/core/src/blks/Heading/definition.ts @@ -39,15 +39,32 @@ const config = createBlockConfig( export const definition = createBlockDefinition(config).implementation( ({ allowToggleHeadings = true }: HeadingOptions = {}) => ({ parse(e) { - const heading = e.querySelector("h1, h2, h3, h4, h5, h6"); - if (!heading) { - return undefined; + 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; } - const level = heading.tagName.slice(1); - return { - level: parseInt(level), + level, }; }, render(block, editor) { From 55957cdd5fa8baf5b36d5d47982750c5377b3534 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 8 Aug 2025 13:43:16 +0200 Subject: [PATCH 17/19] chore: minor fix --- packages/core/src/blocks/defaultBlockTypeGuards.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/blocks/defaultBlockTypeGuards.ts b/packages/core/src/blocks/defaultBlockTypeGuards.ts index ffe59f73a6..e2c409633b 100644 --- a/packages/core/src/blocks/defaultBlockTypeGuards.ts +++ b/packages/core/src/blocks/defaultBlockTypeGuards.ts @@ -43,9 +43,10 @@ export function editorHasBlockWithType< any, any > { - if (!editorHasBlockWithType(editor, blockType)) { + if (!(blockType in editor.schema.blockSpecs)) { return false; } + if (!props) { return true; } From dba7321d71d34bdc24a630e2a998a8aa81e4717a Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 8 Aug 2025 13:43:34 +0200 Subject: [PATCH 18/19] fix: widen type? --- packages/core/src/schema/blocks/types.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 048c18db96..b74bb11154 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -201,7 +201,7 @@ export type BlockFromConfigNoChildren< ? TableContent : B["content"] extends "none" ? undefined - : never; + : undefined | never; }; export type BlockFromConfig< @@ -283,7 +283,7 @@ type PartialBlockFromConfigNoChildren< ? PartialInlineContent : B["content"] extends "table" ? PartialTableContent - : undefined; + : undefined | never; }; type PartialBlocksWithoutChildren< @@ -405,9 +405,9 @@ export type BlockDefinition< > = { config: BlockConfig; implementation: BlockImplementation< - NoInfer, - NoInfer, - TContent + string, + PropSchema, + "inline" | "none" | "table" >; extensions?: BlockNoteExtension[]; }; From f934d260f1d6cbd076bd754050fe8b9cd013bdcb Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 8 Aug 2025 16:08:19 +0200 Subject: [PATCH 19/19] fix: remove tiptapblockimplementation --- packages/core/src/editor/CustomSchema.ts | 2 +- packages/core/src/schema/blocks/createSpec.ts | 2 +- packages/core/src/schema/blocks/internal.ts | 21 ++++---- packages/core/src/schema/blocks/types.ts | 53 +++---------------- 4 files changed, 18 insertions(+), 60 deletions(-) diff --git a/packages/core/src/editor/CustomSchema.ts b/packages/core/src/editor/CustomSchema.ts index 5d1ee04b22..295e7b047b 100644 --- a/packages/core/src/editor/CustomSchema.ts +++ b/packages/core/src/editor/CustomSchema.ts @@ -23,7 +23,7 @@ function removeUndefined | undefined>(obj: T): T { export type BlockSpecOf = { [key in keyof BSpecs]: key extends string - ? BlockDefinition + ? BlockDefinition : never; }; diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 43ce3bdb09..289687e39b 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -294,7 +294,7 @@ export function createBlockSpec< return createInternalBlockSpec(blockConfig, { node, - toInternalHTML: (block, editor) => { + render: (block, editor) => { const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index 2796aea6c8..b8e33ce798 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -16,10 +16,10 @@ import { PropSchema, Props } from "../propTypes.js"; import { StyleSchema } from "../styles/types.js"; import { BlockConfig, + BlockDefinition, + BlockImplementation, BlockSchemaWithBlock, - BlockSpec, SpecificBlock, - TiptapBlockImplementation, } from "./types.js"; // Function that uses the 'propSchema' of a blockConfig to create a TipTap @@ -232,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< @@ -262,9 +260,8 @@ export function createBlockSpecFromStronglyTypedTiptapNode< { node, requiredExtensions, - toInternalHTML: defaultBlockToHTML, + render: defaultBlockToHTML, toExternalHTML: defaultBlockToHTML, - // parse: () => undefined, // parse rules are in node already }, ); } diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index b74bb11154..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, @@ -9,9 +11,6 @@ import type { } from "../inlineContent/types.js"; import type { PropSchema, Props } from "../propTypes.js"; import type { StyleSchema } from "../styles/types.js"; -import type { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; -import type { Fragment, Schema } from "prosemirror-model"; -import type { ViewMutationRecord } from "prosemirror-view"; export type BlockNoteDOMElement = | "editor" @@ -76,45 +75,10 @@ export interface BlockConfig< meta?: BlockConfigMeta; } -// 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; - }; -}; - // 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 @@ -133,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 = {