diff --git a/docs/content/docs/features/blocks/code-blocks.mdx b/docs/content/docs/features/blocks/code-blocks.mdx index 8f5d1816b3..5a94af0498 100644 --- a/docs/content/docs/features/blocks/code-blocks.mdx +++ b/docs/content/docs/features/blocks/code-blocks.mdx @@ -34,7 +34,6 @@ type CodeBlockOptions = { aliases?: string[]; } >; - createHighlighter?: () => Promise>; }; ``` @@ -44,15 +43,38 @@ type CodeBlockOptions = { `supportedLanguages:` The syntax highlighting languages supported by the code block, which is an empty array by default. -`createHighlighter:` The [Shiki highliter](https://shiki.style/guide/load-theme) to use for syntax highlighting. +**Syntax Highlighting** -BlockNote also provides a generic set of options for syntax highlighting in the `@blocknote/code-block` package, which support a wide range of languages: +Syntax highlighting is handled by a separate editor extension, configured at the editor level via the `syntaxHighlighting` option (not on the code block itself), so it can highlight any block's content: ```ts -import { createCodeBlockSpec } from "@blocknote/core"; -import { codeBlockOptions } from "@blocknote/code-block"; +type SyntaxHighlightingOptions = { + createHighlighter?: () => Promise>; + highlightBlock?: (block: { + type: string; + props: Record; + }) => string | undefined; +}; +``` + +`createHighlighter:` The [Shiki highlighter](https://shiki.style/guide/load-theme) to use for syntax highlighting. -const codeBlock = createCodeBlockSpec(codeBlockOptions); +`highlightBlock:` Picks the language to highlight a block's content as (return the language key, or `undefined` to leave it un-highlighted). This is how you enable highlighting for specific blocks. Defaults to the block's `language` prop (`(block) => block.props.language`), which covers the code block. For a block with a fixed language, return it directly — e.g. for a math block: `(block) => (block.type === "math" ? "latex" : block.props.language)`. + +BlockNote provides a generic, ready-to-use set of these in the `@blocknote/code-block` package, which supports a wide range of languages. The code block options and the highlighter are exported separately: + +```ts +import { createCodeBlockSpec } from "@blocknote/core"; +import { codeBlockOptions, createHighlighter } from "@blocknote/code-block"; + +const editor = useCreateBlockNote({ + syntaxHighlighting: { createHighlighter }, + schema: BlockNoteSchema.create().extend({ + blockSpecs: { + codeBlock: createCodeBlockSpec(codeBlockOptions), + }, + }), +}); ``` See [this example](/examples/theming/code-block) to see it in action. @@ -92,6 +114,15 @@ import { createHighlighter } from "./shiki.bundle.js"; export default function App() { const editor = useCreateBlockNote({ + // The highlighter is configured at the editor level, separately from the + // code block's own options. + syntaxHighlighting: { + createHighlighter: () => + createHighlighter({ + themes: ["light-plus", "dark-plus"], + langs: [], + }), + }, schema: BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec({ @@ -103,11 +134,6 @@ export default function App() { aliases: ["ts"], }, }, - createHighlighter: () => - createHighlighter({ - themes: ["light-plus", "dark-plus"], - langs: [], - }), }), }, }), diff --git a/docs/package.json b/docs/package.json index 5797d7488a..ff2e637b2e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -96,7 +96,8 @@ "tailwind-merge": "^3.4.0", "y-partykit": "^0.0.25", "yjs": "^13.6.27", - "zod": "^4.3.5" + "zod": "^4.3.5", + "@blocknote/math-block": "workspace:*" }, "devDependencies": { "@blocknote/code-block": "workspace:*", diff --git a/examples/04-theming/06-code-block/src/App.tsx b/examples/04-theming/06-code-block/src/App.tsx index 82d10bae9e..a757bada0d 100644 --- a/examples/04-theming/06-code-block/src/App.tsx +++ b/examples/04-theming/06-code-block/src/App.tsx @@ -4,11 +4,14 @@ import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; // This packages some of the most used languages in on-demand bundle -import { codeBlockOptions } from "@blocknote/code-block"; +import { codeBlockOptions, createHighlighter } from "@blocknote/code-block"; export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ + // The Shiki highlighter is configured at the editor level, separately from + // the code block's own options (default language & language menu). + syntaxHighlighting: { createHighlighter }, schema: BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec(codeBlockOptions), diff --git a/examples/04-theming/07-custom-code-block/src/App.tsx b/examples/04-theming/07-custom-code-block/src/App.tsx index 8a9c74eac1..dbeb84b367 100644 --- a/examples/04-theming/07-custom-code-block/src/App.tsx +++ b/examples/04-theming/07-custom-code-block/src/App.tsx @@ -9,6 +9,16 @@ import { createHighlighter } from "./shiki.bundle"; export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ + // The Shiki highlighter is configured at the editor level, separately from + // the code block's own options (default language & language menu). + syntaxHighlighting: { + // This creates a highlighter, it can be asynchronous to load it afterwards + createHighlighter: () => + createHighlighter({ + themes: ["dark-plus", "light-plus"], + langs: [], + }), + }, schema: BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec({ @@ -27,12 +37,6 @@ export default function App() { name: "Vue", }, }, - // This creates a highlighter, it can be asynchronous to load it afterwards - createHighlighter: () => - createHighlighter({ - themes: ["dark-plus", "light-plus"], - langs: [], - }), }), }, }), diff --git a/examples/06-custom-schema/09-math-block/.bnexample.json b/examples/06-custom-schema/09-math-block/.bnexample.json new file mode 100644 index 0000000000..e5aa3d0d6c --- /dev/null +++ b/examples/06-custom-schema/09-math-block/.bnexample.json @@ -0,0 +1,10 @@ +{ + "playground": true, + "docs": true, + "author": "matthewlipski", + "tags": ["Intermediate", "Blocks", "Custom Schemas"], + "dependencies": { + "@blocknote/code-block": "latest", + "@blocknote/math-block": "latest" + } +} diff --git a/examples/06-custom-schema/09-math-block/README.md b/examples/06-custom-schema/09-math-block/README.md new file mode 100644 index 0000000000..9f2b15c570 --- /dev/null +++ b/examples/06-custom-schema/09-math-block/README.md @@ -0,0 +1,10 @@ +# Math Block + +In this example, we register the `@blocknote/math-block` block in a custom schema. The math block renders LaTeX as MathML (using Temml) for the browser to display natively, and reveals an editable LaTeX source popup when selected. Exporting to HTML produces a MathML `` element, and pasting MathML back in is converted to LaTeX. + +**Try it out:** Click a formula to edit its LaTeX! + +**Relevant Docs:** + +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/09-math-block/index.html b/examples/06-custom-schema/09-math-block/index.html new file mode 100644 index 0000000000..034154dbcf --- /dev/null +++ b/examples/06-custom-schema/09-math-block/index.html @@ -0,0 +1,14 @@ + + + + + Math Block + + + +
+ + + diff --git a/examples/06-custom-schema/09-math-block/main.tsx b/examples/06-custom-schema/09-math-block/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/09-math-block/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/06-custom-schema/09-math-block/package.json b/examples/06-custom-schema/09-math-block/package.json new file mode 100644 index 0000000000..4c28818956 --- /dev/null +++ b/examples/06-custom-schema/09-math-block/package.json @@ -0,0 +1,32 @@ +{ + "name": "@blocknote/example-custom-schema-math-block", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vp dev", + "dev": "vp dev", + "build:prod": "tsc && vp build", + "preview": "vp preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "@blocknote/code-block": "latest", + "@blocknote/math-block": "latest" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite-plus": "catalog:" + } +} diff --git a/examples/06-custom-schema/09-math-block/src/App.tsx b/examples/06-custom-schema/09-math-block/src/App.tsx new file mode 100644 index 0000000000..8e980b4217 --- /dev/null +++ b/examples/06-custom-schema/09-math-block/src/App.tsx @@ -0,0 +1,46 @@ +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteSchema } from "@blocknote/core"; +import { createHighlighter } from "@blocknote/code-block"; +import { createMathBlockSpec } from "@blocknote/math-block"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; + +export default function App() { + // The math block isn't a default block, so we register it in a custom schema. + const editor = useCreateBlockNote({ + // The Shiki highlighter (from @blocknote/code-block) syntax-highlights the + // math block's editable LaTeX source popup. `highlightBlock` enables it for + // the math block and highlights it as LaTeX. + syntaxHighlighting: { + createHighlighter, + highlightBlock: (block) => + block.type === "math" ? "latex" : block.props.language, + }, + schema: BlockNoteSchema.create().extend({ + blockSpecs: { + math: createMathBlockSpec(), + }, + }), + initialContent: [ + { + type: "paragraph", + content: "Click a formula to edit its LaTeX source:", + }, + { + type: "math", + content: "a^2 = \\sqrt{b^2 + c^2}", + }, + { + type: "math", + content: "\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}", + }, + { + type: "paragraph", + }, + ], + }); + + // Renders the editor instance using a React component. + return ; +} diff --git a/examples/06-custom-schema/09-math-block/tsconfig.json b/examples/06-custom-schema/09-math-block/tsconfig.json new file mode 100644 index 0000000000..93fa81bee8 --- /dev/null +++ b/examples/06-custom-schema/09-math-block/tsconfig.json @@ -0,0 +1,29 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/06-custom-schema/09-math-block/vite.config.ts b/examples/06-custom-schema/09-math-block/vite.config.ts new file mode 100644 index 0000000000..0133a6da9e --- /dev/null +++ b/examples/06-custom-schema/09-math-block/vite.config.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/packages/code-block/package.json b/packages/code-block/package.json index 3da6a3199c..871f6ef4db 100644 --- a/packages/code-block/package.json +++ b/packages/code-block/package.json @@ -52,12 +52,14 @@ "@shikijs/core": "^4", "@shikijs/engine-javascript": "^4", "@shikijs/langs-precompiled": "^4", - "@shikijs/themes": "^4" + "@shikijs/themes": "^4", + "katex": "^0.16.11" }, "optionalDependencies": { "@shikijs/types": "^4" }, "devDependencies": { + "@types/katex": "^0.16.7", "rimraf": "^5.0.10", "rollup-plugin-webpack-stats": "^0.2.6", "typescript": "^5.9.3", diff --git a/packages/code-block/src/index.test.ts b/packages/code-block/src/index.test.ts index f8a87bbdf4..5eef47b172 100644 --- a/packages/code-block/src/index.test.ts +++ b/packages/code-block/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { codeBlockOptions } from "./index.js"; +import { codeBlockOptions, createHighlighter } from "./index.js"; describe("codeBlock", () => { it("should exist", () => { @@ -11,7 +11,7 @@ describe("codeBlock", () => { it("should have supportedLanguages", () => { expect(codeBlockOptions.supportedLanguages).toBeDefined(); }); - it("should have createHighlighter", () => { - expect(codeBlockOptions.createHighlighter).toBeDefined(); + it("exports a separate createHighlighter", () => { + expect(createHighlighter).toBeDefined(); }); }); diff --git a/packages/code-block/src/index.ts b/packages/code-block/src/index.ts index 2cb588092d..1b5db6d952 100644 --- a/packages/code-block/src/index.ts +++ b/packages/code-block/src/index.ts @@ -1,6 +1,13 @@ import type { CodeBlockOptions } from "@blocknote/core"; -import { createHighlighter } from "./shiki.bundle.js"; +import { createHighlighter as createShikiHighlighter } from "./shiki.bundle.js"; +export const createHighlighter = () => + createShikiHighlighter({ + themes: ["github-dark", "github-light"], + langs: [], + }); + +// TODO: Should this be here or in the core code block? export const codeBlockOptions = { defaultLanguage: "javascript", supportedLanguages: { @@ -197,9 +204,4 @@ export const codeBlockOptions = { aliases: ["objective-c", "objc"], }, }, - createHighlighter: () => - createHighlighter({ - themes: ["github-dark", "github-light"], - langs: [], - }), } satisfies CodeBlockOptions; diff --git a/packages/core/package.json b/packages/core/package.json index 72b58d02c3..a5f6087d78 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -90,6 +90,7 @@ }, "dependencies": { "@emoji-mart/data": "^1.2.1", + "@floating-ui/dom": "^1.7.6", "@handlewithcare/prosemirror-inputrules": "^0.1.4", "@shikijs/types": "^4", "@tanstack/store": "^0.7.7", diff --git a/packages/core/src/blocks/Code/CodeBlockOptions.ts b/packages/core/src/blocks/Code/CodeBlockOptions.ts new file mode 100644 index 0000000000..a74766c961 --- /dev/null +++ b/packages/core/src/blocks/Code/CodeBlockOptions.ts @@ -0,0 +1,88 @@ +import type { ViewMutationRecord } from "prosemirror-view"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { BlockFromConfig } from "../../schema/index.js"; + +/** + * Renders a preview of a code block's content (e.g. rendered LaTeX). Takes the + * same parameters as a block's `render` function and returns the same type, + * minus `contentDOM` - as a preview never holds the block's editable content. + * + * A `renderPreview` function is only responsible for the preview itself. It has + * no opinion on when, where, or how the preview is displayed - that's up to the + * code block's `render` function. + */ +export type CodeBlockPreview = ( + block: BlockFromConfig, + editor: BlockNoteEditor, +) => { + dom: HTMLElement; + // TODO: This is for showing any syntax errors found while rendering the preview, not sure if it + // should be here. + error?: string | null; + ignoreMutation?: (mutation: ViewMutationRecord) => boolean; + destroy?: () => void; +}; + +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[]; + /** + * Renders a preview of the result of the code (e.g. rendered LaTeX). When + * defined, the code block displays this preview instead of the raw source + * by default, and shows the editable source in a popup when selected. + */ + createPreview?: CodeBlockPreview; + } + >; +}; + +export function getLanguageId( + options: CodeBlockOptions, + languageName: string, +): string | undefined { + const normalizedLanguage = languageName.trim().toLowerCase(); + return Object.entries(options.supportedLanguages ?? {}).find( + ([id, { aliases }]) => { + return ( + id.toLowerCase() === normalizedLanguage || + aliases?.some((alias) => alias.toLowerCase() === normalizedLanguage) + ); + }, + )?.[0]; +} diff --git a/packages/core/src/blocks/Code/block.test.ts b/packages/core/src/blocks/Code/block.test.ts index edc26da8b7..fd208c4cf0 100644 --- a/packages/core/src/blocks/Code/block.test.ts +++ b/packages/core/src/blocks/Code/block.test.ts @@ -8,7 +8,7 @@ import { } from "vite-plus/test"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { PartialBlock } from "../defaultBlocks.js"; -import { getLanguageId, type CodeBlockOptions } from "./block.js"; +import { getLanguageId, type CodeBlockOptions } from "./CodeBlockOptions.js"; /** * @vitest-environment jsdom diff --git a/packages/core/src/blocks/Code/block.ts b/packages/core/src/blocks/Code/block.ts index dbb7fc33a9..ae13c70ce6 100644 --- a/packages/core/src/blocks/Code/block.ts +++ b/packages/core/src/blocks/Code/block.ts @@ -1,55 +1,12 @@ -import type { HighlighterGeneric } from "@shikijs/types"; -import { createExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, createBlockSpec } from "../../schema/index.js"; -import { lazyShikiPlugin } from "./shiki.js"; -import { DOMParser } from "@tiptap/pm/model"; - -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>; -}; +import { + parsePreCode, + parsePreCodeContent, +} from "./helpers/parse/parsePreCode.js"; +import { createCodeBlockWrapper } from "./helpers/render/createCodeBlockWrapper.js"; +import { createPreCode } from "./helpers/toExternalHTML/createPreCode.js"; +import { createCodeKeyboardShortcutsExtension } from "./helpers/extensions/createCodeKeyboardShortcutsExtension.js"; +import { CodeBlockOptions } from "./CodeBlockOptions.js"; export type CodeBlockConfig = ReturnType; @@ -74,235 +31,17 @@ export const createCodeBlockSpec = createBlockSpec( defining: true, isolating: false, }, - parse: (e) => { - if (e.tagName !== "PRE") { - return undefined; - } - - if ( - e.childElementCount !== 1 || - e.firstElementChild?.tagName !== "CODE" - ) { - return undefined; - } - - const code = e.firstElementChild!; - const language = - code.getAttribute("data-language") || - code.className - .split(" ") - .find((name) => name.includes("language-")) - ?.replace("language-", ""); - - return { language }; - }, - - parseContent: ({ el, schema }) => { - const parser = DOMParser.fromSchema(schema); - const code = el.firstElementChild!; - - return parser.parse(code, { - preserveWhitespace: "full", - topNode: schema.nodes["codeBlock"].create(), - }).content; - }, - - render(block, editor) { - const wrapper = document.createDocumentFragment(); - const pre = document.createElement("pre"); - const code = document.createElement("code"); - pre.appendChild(code); - - let removeSelectChangeListener = undefined; - - if (options.supportedLanguages) { - const select = document.createElement("select"); - - Object.entries(options.supportedLanguages ?? {}).forEach( - ([id, { name }]) => { - const option = document.createElement("option"); - - option.value = id; - option.text = name; - select.appendChild(option); - }, - ); - select.value = - block.props.language || options.defaultLanguage || "text"; - - if (editor.isEditable) { - const handleLanguageChange = (event: Event) => { - const language = (event.target as HTMLSelectElement).value; - - editor.updateBlock(block.id, { props: { language } }); - }; - select.addEventListener("change", handleLanguageChange); - removeSelectChangeListener = () => - select.removeEventListener("change", handleLanguageChange); - } else { - select.disabled = true; - } - - const selectWrapper = document.createElement("div"); - selectWrapper.contentEditable = "false"; - - selectWrapper.appendChild(select); - wrapper.appendChild(selectWrapper); - } - wrapper.appendChild(pre); - - return { - dom: wrapper, - contentDOM: code, - destroy: () => { - removeSelectChangeListener?.(); - }, - }; - }, - toExternalHTML(block) { - const pre = document.createElement("pre"); - const code = document.createElement("code"); - code.className = `language-${block.props.language}`; - code.dataset.language = block.props.language; - pre.appendChild(code); - return { - dom: pre, - contentDOM: code, - }; - }, + parse: (el) => parsePreCode(el), + parseContent: (opts) => parsePreCodeContent(opts, "codeBlock"), + render: (block, editor) => createCodeBlockWrapper(options)(block, editor), + toExternalHTML: (block) => createPreCode(block), }), (options) => { return [ - createExtension({ - key: "code-block-highlighter", - prosemirrorPlugins: [lazyShikiPlugin(options)], - }), - createExtension({ - 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: [], - }; - }, - }, - ], - }), + createCodeKeyboardShortcutsExtension(options)( + "code-block-keyboard-shortcuts", + "codeBlock", + ), ]; }, ); - -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/blocks/Code/helpers/extensions/createCodeKeyboardShortcutsExtension.ts b/packages/core/src/blocks/Code/helpers/extensions/createCodeKeyboardShortcutsExtension.ts new file mode 100644 index 0000000000..71c20216cb --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/extensions/createCodeKeyboardShortcutsExtension.ts @@ -0,0 +1,120 @@ +import { createExtension } from "../../../../editor/BlockNoteExtension.js"; +import { CodeBlockOptions, getLanguageId } from "../../CodeBlockOptions.js"; + +export const createCodeKeyboardShortcutsExtension = + (options: CodeBlockOptions) => (key: string, blockType: string) => + createExtension({ + key, + keyboardShortcuts: { + Delete: ({ editor }) => { + return editor.transact((tr) => { + const { block } = editor.getTextCursorPosition(); + if (block.type !== blockType) { + 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 === blockType) { + // 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 !== blockType) { + 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 !== blockType) { + 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: blockType, + props: { + language: attributes.language, + }, + content: [], + }; + }, + }, + ], + }); diff --git a/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceNavigationExtension.ts b/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceNavigationExtension.ts new file mode 100644 index 0000000000..37e22df26f --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceNavigationExtension.ts @@ -0,0 +1,224 @@ +import type { Node } from "prosemirror-model"; +import { NodeSelection, Selection, TextSelection } from "prosemirror-state"; +import { cellAround, nextCell } from "prosemirror-tables"; +import type { EditorView } from "prosemirror-view"; +import { + getNextBlockInfo, + getPrevBlockInfo, +} from "../../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js"; +import { + type BlockInfo, + getBlockInfoFromResolvedPos, + getBlockInfoFromSelection, + getBlockInfoFromTransaction, +} from "../../../../api/getBlockInfoFromPos.js"; +import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import { createExtension } from "../../../../editor/BlockNoteExtension.js"; + +type Direction = "left" | "right" | "up" | "down"; + +// Checks whether moving the text cursor in a given direction should move it out of the block. +const endOfBlock = (view: EditorView, direction: Direction): boolean => { + const { selection } = view.state; + + const blockInfo = getBlockInfoFromSelection(view.state); + + // Always moves selection to previous/next block when whole block is selected. + if ( + selection instanceof NodeSelection && + selection.node.type.spec.group === "blockContent" + ) { + return true; + } + + // Left/right arrows always collapse selection to it's start/end (default behaviour) - never move + // selection out of the block. + if (!selection.empty && (direction === "left" || direction === "right")) { + return false; + } + + // Navigating within text content never moves the selection outside the block. + if (!view.endOfTextblock(direction)) { + return false; + } + + // If there is a cell to move into for the given direction, the selection moves into it. + // Otherwise, it moves out of the block. + if (blockInfo.isBlockContainer && blockInfo.blockNoteType === "table") { + const cell = cellAround(selection.$head); + + if ( + !cell || + nextCell( + cell, + direction === "up" || direction === "down" ? "vert" : "horiz", + direction === "down" || direction === "right" ? 1 : -1, + ) + ) { + return false; + } + } + + return true; +}; + +// Gets the block info of the first or last `blockContainer` in a `column`/`columnList`. +const getEdgeBlockContainerInfo = ( + doc: Node, + blockInfo: BlockInfo, + forward: boolean, +): BlockInfo => { + while (!blockInfo.isBlockContainer) { + const group = blockInfo.childContainer.node; + const childPos = doc + .resolve(blockInfo.childContainer.beforePos + 1) + .posAtIndex(forward ? 0 : group.childCount - 1); + blockInfo = getBlockInfoFromResolvedPos(doc.resolve(childPos)); + } + + return blockInfo; +}; + +// Handles arrow key presses. +const createArrowHandler = + (blockType: string, direction: Direction) => + ({ editor }: { editor: BlockNoteEditor }) => { + const view = editor.prosemirrorView; + + return editor.transact((tr) => { + if (!endOfBlock(view, direction)) { + return false; + } + + const forward = direction === "right" || direction === "down"; + const vertical = direction === "up" || direction === "down"; + + const blockInfo = getBlockInfoFromTransaction(tr); + if (!blockInfo.isBlockContainer) { + return false; + } + + let adjacentBlockInfo = forward + ? getNextBlockInfo(tr.doc, blockInfo.bnBlock.beforePos) + : getPrevBlockInfo(tr.doc, blockInfo.bnBlock.beforePos); + + if (adjacentBlockInfo && !adjacentBlockInfo.isBlockContainer) { + // Edge case for when the adjacent block is a `column`/`columnList` - use the first or last + // `blockContainer` in it depending on direction. + adjacentBlockInfo = getEdgeBlockContainerInfo( + tr.doc, + adjacentBlockInfo, + forward, + ); + } + + // Use default handling when no adjacent block exists. + if (!adjacentBlockInfo || !adjacentBlockInfo.isBlockContainer) { + return false; + } + + // Navigating onto a preview-source block selects the whole node. + if (adjacentBlockInfo.blockNoteType === blockType) { + tr.setSelection( + NodeSelection.create( + tr.doc, + adjacentBlockInfo.blockContent.beforePos, + ), + ).scrollIntoView(); + + return true; + } + + // Leaving a preview-source block via a vertical arrow emulates the behavior of a horizontal + // arrow press at the block's boundary. This is because vertical arrow presses move selection + // based on DOM layout, which causes slightly weird UX when done from the source popup. + if (vertical && blockInfo.blockNoteType === blockType) { + const target = Selection.findFrom( + tr.doc.resolve( + forward + ? adjacentBlockInfo.bnBlock.beforePos + : adjacentBlockInfo.bnBlock.afterPos, + ), + forward ? 1 : -1, + false, + ); + + if (target) { + tr.setSelection(target).scrollIntoView(); + + return true; + } + } + + return false; + }); + }; + +/** + * This extension is necessary for graceful keyboard navigation around blocks which use + * `createPreviewWithSourcePopup` to render their content. It's important to have this context as + * the source code popup with the block's inline content only becomes visible when the selection is + * moved somewhere into this inline content. This means we cannot rely of default keyboard + * navigation as while the block has content, that content is hidden while the selection is outside + * of it, so the default handling skips it. + */ +export const createPreviewSourceNavigationExtension = ( + key: string, + blockType: string, +) => + createExtension({ + key, + keyboardShortcuts: { + // Toggles between opening and closing the source code popup by setting the selection on the + // whole block content node (hiding popup) or at the start of the inline content node + // (showing popup). + Enter: ({ editor }) => + editor.transact((tr) => { + const blockInfo = getBlockInfoFromTransaction(tr); + if ( + !blockInfo.isBlockContainer || + blockInfo.blockNoteType !== blockType + ) { + return false; + } + + if (tr.selection instanceof NodeSelection) { + tr.setSelection( + TextSelection.create( + tr.doc, + blockInfo.blockContent.beforePos + 1, + ), + ); + } else { + tr.setSelection( + NodeSelection.create(tr.doc, blockInfo.blockContent.beforePos), + ); + } + + return true; + }), + // Closes the source code popup by setting the selection on the whole block content node. + Escape: ({ editor }) => + editor.transact((tr) => { + const blockInfo = getBlockInfoFromTransaction(tr); + + if ( + !blockInfo.isBlockContainer || + blockInfo.blockNoteType !== blockType || + tr.selection instanceof NodeSelection + ) { + return false; + } + + tr.setSelection( + NodeSelection.create(tr.doc, blockInfo.blockContent.beforePos), + ); + + return true; + }), + ArrowRight: createArrowHandler(blockType, "right"), + ArrowDown: createArrowHandler(blockType, "down"), + ArrowLeft: createArrowHandler(blockType, "left"), + ArrowUp: createArrowHandler(blockType, "up"), + }, + }); diff --git a/packages/core/src/blocks/Code/helpers/parse/parsePreCode.ts b/packages/core/src/blocks/Code/helpers/parse/parsePreCode.ts new file mode 100644 index 0000000000..237462fdb6 --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/parse/parsePreCode.ts @@ -0,0 +1,45 @@ +import { DOMParser, Schema } from "@tiptap/pm/model"; + +export const parsePreCode = (el: HTMLElement) => { + { + if (el.tagName !== "PRE") { + return undefined; + } + + if ( + el.childElementCount !== 1 || + el.firstElementChild?.tagName !== "CODE" + ) { + return undefined; + } + + const code = el.firstElementChild!; + const language = + code.getAttribute("data-language") || + code.className + .split(" ") + .find((name) => name.startsWith("language-")) + ?.replace("language-", ""); + + return { language }; + } +}; + +export const parsePreCodeContent = ( + { + el, + schema, + }: { + el: HTMLElement; + schema: Schema; + }, + blockType: string, +) => { + const parser = DOMParser.fromSchema(schema); + const code = el.firstElementChild!; + + return parser.parse(code, { + preserveWhitespace: "full", + topNode: schema.nodes[blockType].create(), + }).content; +}; diff --git a/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts b/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts new file mode 100644 index 0000000000..7576dc0898 --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts @@ -0,0 +1,23 @@ +import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import type { BlockFromConfig } from "../../../../schema/index.js"; +import { + getLanguageId, + type CodeBlockOptions, +} from "../../CodeBlockOptions.js"; +import { createPreviewWithSourcePopup } from "./createPreviewWithSourcePopup.js"; +import { createSourceBlock } from "./createSourceBlock.js"; + +export const createCodeBlockWrapper = + (options: CodeBlockOptions) => + (block: BlockFromConfig, editor: BlockNoteEditor) => { + const language = block.props.language || options.defaultLanguage || "text"; + const resolvedLanguage = getLanguageId(options, language) ?? language; + const renderPreview = + options.supportedLanguages?.[resolvedLanguage]?.createPreview; + + // Languages with a preview show said preview by default, with the editable source in a popup. + // Other languages just show the source. + return renderPreview + ? createPreviewWithSourcePopup(options)(block, editor, renderPreview) + : createSourceBlock(options)(block, editor); + }; diff --git a/packages/core/src/blocks/Code/helpers/render/createPreviewWithSourcePopup.ts b/packages/core/src/blocks/Code/helpers/render/createPreviewWithSourcePopup.ts new file mode 100644 index 0000000000..393bc5ff25 --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/render/createPreviewWithSourcePopup.ts @@ -0,0 +1,224 @@ +import { + autoUpdate, + computePosition, + flip, + offset, + shift, + size, +} from "@floating-ui/dom"; +import type { Node as ProsemirrorNode } from "prosemirror-model"; +import { TextSelection } from "prosemirror-state"; +import type { ViewMutationRecord } from "prosemirror-view"; +import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import type { BlockFromConfig } from "../../../../schema/index.js"; +import type { CodeBlockConfig } from "../../block.js"; +import type { + CodeBlockOptions, + CodeBlockPreview, +} from "../../CodeBlockOptions.js"; +import { createSourceBlock } from "./createSourceBlock.js"; + +const getCodeBlockText = (block: BlockFromConfig): string => { + const content = block.content; + + if (!Array.isArray(content)) { + return ""; + } + + return content.map((node) => ("text" in node ? node.text : "")).join(""); +}; + +export const createPreviewWithSourcePopup = + (options: CodeBlockOptions) => + ( + block: BlockFromConfig, + editor: BlockNoteEditor, + createPreview: CodeBlockPreview, + ) => { + const dom = document.createElement("div"); + dom.className = "bn-code-block-with-preview"; + + // Shows the preview. Always visible & non-editable. + const previewContainer = document.createElement("div"); + previewContainer.className = "bn-code-block-preview"; + previewContainer.contentEditable = "false"; + dom.appendChild(previewContainer); + + let preview = createPreview(block, editor); + previewContainer.appendChild(preview.dom); + + // Holds the inline content with source code, shown in a popup below the preview while the + // selection is within the inline content. + const source = createSourceBlock(options)(block, editor); + const sourcePopup = document.createElement("div"); + sourcePopup.className = "bn-code-block-source-popup"; + sourcePopup.style.display = "none"; + sourcePopup.appendChild(source.dom); + + // Shows the preview's error message (e.g. a LaTeX syntax error) below the editable source. + // Hidden while there's no error. + const sourceError = document.createElement("div"); + sourceError.className = "bn-code-block-source-error"; + sourceError.contentEditable = "false"; + sourceError.style.display = "none"; + sourcePopup.appendChild(sourceError); + + dom.appendChild(sourcePopup); + + // Reflects the latest preview's error in the source popup's error section. + const applyPreviewError = (error?: string | null) => { + sourceError.textContent = error ?? ""; + sourceError.style.display = error ? "block" : "none"; + }; + applyPreviewError(preview.error); + + // Tracks the current source so the preview is only re-rendered when the source actually + // changes (see `update` below). + let currentSource = getCodeBlockText(block); + + // Positions the source popup below the preview using FloatingUI, keeping it in place as the + // preview moves/resizes while visible. + let cleanupAutoUpdate: (() => void) | undefined; + const showSourcePopup = () => { + if (sourcePopup.style.display === "block") { + return; + } + sourcePopup.style.display = "block"; + cleanupAutoUpdate = autoUpdate(previewContainer, sourcePopup, () => { + computePosition(previewContainer, sourcePopup, { + placement: "bottom-start", + middleware: [ + offset(4), + flip(), + shift({ padding: 4 }), + // Match the popup's width to the block. The preview shrink-wraps + // its rendered content, so we measure the full-width block content + // element rather than the preview itself. + size({ + apply({ rects, elements }) { + const blockContent = + previewContainer.closest(".bn-block-content"); + const width = + blockContent?.getBoundingClientRect().width ?? + rects.reference.width; + elements.floating.style.width = `${width}px`; + }, + }), + ], + }).then(({ x, y }) => { + sourcePopup.style.left = `${x}px`; + sourcePopup.style.top = `${y}px`; + }); + }); + }; + const hideSourcePopup = () => { + if (sourcePopup.style.display === "none") { + return; + } + sourcePopup.style.display = "none"; + cleanupAutoUpdate?.(); + cleanupAutoUpdate = undefined; + }; + + // Reflects the current selection in the block's UI on every selection + // change. Two distinct states: + // - "Editing" (a text cursor inside the content) opens the source popup. A + // whole-node selection or a gap cursor beside the block keeps it closed. + // - "Selected" (the selection is anywhere within the block - the whole node + // selected *or* editing) gets the `ProseMirror-selectednode` class, so the + // preview shows the standard selected styling in both states. ProseMirror + // only adds that class for a whole-node selection, and even strips it once + // editing begins (the inner selection isn't a node selection), so we manage + // it here. `onSelectionChange` runs after ProseMirror's `deselectNode`, so + // the class reliably sticks. + const updateSelectionState = () => { + let isSelected = false; + let isEditing = false; + try { + const { selection } = editor.prosemirrorState; + isSelected = editor.getTextCursorPosition().block.id === block.id; + isEditing = isSelected && selection instanceof TextSelection; + } catch { + isSelected = false; + isEditing = false; + } + + if (editor.isEditable && isEditing) { + showSourcePopup(); + } else { + hideSourcePopup(); + } + + dom + .closest(".bn-block-content") + ?.classList.toggle("ProseMirror-selectednode", isSelected); + }; + const removeSelectionChangeListener = + editor.onSelectionChange(updateSelectionState); + updateSelectionState(); + + // The source is hidden inside the popup, so clicking the preview can't + // place the text cursor in the block on its own. We do it manually, placing + // the cursor at the content start, which reveals the popup via the selection + // listener. + const handlePreviewMouseDown = (event: MouseEvent) => { + if (!editor.isEditable) { + return; + } + event.preventDefault(); + showSourcePopup(); + editor.setTextCursorPosition(block.id, "start"); + editor.focus(); + }; + previewContainer.addEventListener("mousedown", handlePreviewMouseDown); + + return { + dom, + contentDOM: source.contentDOM, + // Ignores mutations outside the editable source (e.g. preview + // re-renders), so ProseMirror doesn't try to read them as content. + ignoreMutation: (mutation: ViewMutationRecord) => + !source.contentDOM.contains(mutation.target as Node), + // Re-renders the preview in place whenever this block's source changes, + // keeping it in sync without recreating the whole view. ProseMirror + // only calls this for changes to this block's node, so unlike a global + // change listener it does no work while other blocks are edited. + update: (node: ProsemirrorNode) => { + // The preview layout depends on the language, so let ProseMirror + // recreate the view (via `render`) when it changes. + if (node.attrs.language !== block.props.language) { + return false; + } + + const text = node.textContent; + if (text !== currentSource) { + currentSource = text; + + preview.destroy?.(); + previewContainer.innerHTML = ""; + preview = createPreview( + editor.getBlock(block.id) as BlockFromConfig< + CodeBlockConfig, + any, + any + >, + editor, + ); + previewContainer.appendChild(preview.dom); + applyPreviewError(preview.error); + } + + return true; + }, + destroy: () => { + source.destroy(); + removeSelectionChangeListener(); + cleanupAutoUpdate?.(); + preview.destroy?.(); + previewContainer.removeEventListener( + "mousedown", + handlePreviewMouseDown, + ); + }, + }; + }; diff --git a/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts new file mode 100644 index 0000000000..4293c9c63d --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts @@ -0,0 +1,59 @@ +import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import type { BlockFromConfig } from "../../../../schema/index.js"; +import { + getLanguageId, + type CodeBlockOptions, +} from "../../CodeBlockOptions.js"; + +export const createSourceBlock = + (options: CodeBlockOptions) => + (block: BlockFromConfig, editor: BlockNoteEditor) => { + const language = block.props.language || options.defaultLanguage || "text"; + const resolvedLanguage = getLanguageId(options, language) ?? language; + + const pre = document.createElement("pre"); + const code = document.createElement("code"); + pre.appendChild(code); + + const dom = document.createDocumentFragment(); + + let removeSelectChangeListener: (() => void) | undefined; + if (options.supportedLanguages) { + const select = document.createElement("select"); + Object.entries(options.supportedLanguages).forEach(([id, { name }]) => { + const option = document.createElement("option"); + option.value = id; + option.text = name; + select.appendChild(option); + }); + select.value = resolvedLanguage; + + if (editor.isEditable) { + const handleLanguageChange = (event: Event) => { + editor.updateBlock(block.id, { + props: { language: (event.target as HTMLSelectElement).value }, + }); + }; + select.addEventListener("change", handleLanguageChange); + removeSelectChangeListener = () => + select.removeEventListener("change", handleLanguageChange); + } else { + select.disabled = true; + } + + const selectWrapper = document.createElement("div"); + selectWrapper.contentEditable = "false"; + selectWrapper.appendChild(select); + dom.appendChild(selectWrapper); + } + + dom.appendChild(pre); + + return { + dom, + contentDOM: code, + destroy: () => { + removeSelectChangeListener?.(); + }, + }; + }; diff --git a/packages/core/src/blocks/Code/helpers/toExternalHTML/createPreCode.ts b/packages/core/src/blocks/Code/helpers/toExternalHTML/createPreCode.ts new file mode 100644 index 0000000000..1b53828585 --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/toExternalHTML/createPreCode.ts @@ -0,0 +1,14 @@ +import type { BlockFromConfig } from "../../../../schema/index.js"; + +export const createPreCode = (block: BlockFromConfig) => { + const pre = document.createElement("pre"); + const code = document.createElement("code"); + code.className = `language-${block.props.language}`; + code.dataset.language = block.props.language; + pre.appendChild(code); + + return { + dom: pre, + contentDOM: code, + }; +}; diff --git a/packages/core/src/blocks/Code/shiki.ts b/packages/core/src/blocks/Code/shiki.ts deleted file mode 100644 index 1298007a58..0000000000 --- a/packages/core/src/blocks/Code/shiki.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { HighlighterGeneric } from "@shikijs/types"; -import { Parser, createHighlightPlugin } from "prosemirror-highlight"; -import { createParser } from "prosemirror-highlight/shiki"; -import { CodeBlockOptions, getLanguageId } from "./block.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 `createCodeBlockSpec({ 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/blocks/index.ts b/packages/core/src/blocks/index.ts index 56f4c6de3c..459999c3ba 100644 --- a/packages/core/src/blocks/index.ts +++ b/packages/core/src/blocks/index.ts @@ -16,6 +16,13 @@ export * from "./Table/block.js"; export * from "./Video/block.js"; export { EMPTY_CELL_HEIGHT, EMPTY_CELL_WIDTH } from "./Table/TableExtension.js"; +export * from "./Code/helpers/extensions/createCodeKeyboardShortcutsExtension.js"; +export * from "./Code/helpers/extensions/createPreviewSourceNavigationExtension.js"; +export * from "./Code/helpers/parse/parsePreCode.js"; +export * from "./Code/helpers/render/createCodeBlockWrapper.js"; +export * from "./Code/helpers/render/createPreviewWithSourcePopup.js"; +export * from "./Code/helpers/render/createSourceBlock.js"; +export * from "./Code/helpers/toExternalHTML/createPreCode.js"; export * from "./ToggleWrapper/createToggleWrapper.js"; export * from "./File/helpers/uploadToTmpFilesDotOrg_DEV_ONLY.js"; export * from "./PageBreak/getPageBreakSlashMenuItems.js"; diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 547e009d6f..1804f9b6fd 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -453,6 +453,71 @@ NESTED BLOCKS transition-delay: 0.1s; } +/* CODE BLOCK PREVIEW */ +.bn-block-content[data-content-type="codeBlock"]:has( + .bn-code-block-with-preview + ) { + background-color: transparent; + color: inherit; +} + +.bn-code-block-with-preview { + position: relative; + flex: 1; + min-width: 0; +} + +.bn-code-block-preview { + padding: 12px; + min-height: 1.5em; + cursor: text; +} + +.bn-code-block-source-popup { + position: absolute; + z-index: 1; + background-color: var(--bn-colors-menu-background); + color: var(--bn-colors-menu-text); + border: var(--bn-border); + border-radius: var(--bn-border-radius-medium); + box-shadow: var(--bn-shadow-medium); +} + +/* The source popup reuses the default source rendering (language select + +`
`), so it gets the same "code editor" styling as a regular code block. */
+.bn-code-block-source-popup > div > select {
+  outline: none !important;
+  appearance: none;
+  user-select: none;
+  border: none;
+  cursor: pointer;
+  background-color: transparent;
+  font-size: 0.8em;
+  color: var(--bn-colors-menu-text);
+  padding: 8px 16px 0;
+}
+
+.bn-code-block-source-popup > div > select > option {
+  color: black;
+}
+
+.bn-code-block-source-popup > pre {
+  white-space: pre;
+  overflow-x: auto;
+  margin: 0;
+  width: 100%;
+  tab-size: 2;
+  padding: 16px;
+}
+
+.bn-code-block-source-error {
+  border-top: var(--bn-border);
+  color: var(--bn-colors-highlights-red-text);
+  font-size: 0.8em;
+  padding: 8px 16px;
+  white-space: pre-wrap;
+}
+
 /* PAGE BREAK */
 .bn-block-content[data-content-type="pageBreak"] > div {
   width: 100%;
diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts
index 13d65ad83d..9395c8ef70 100644
--- a/packages/core/src/editor/BlockNoteEditor.ts
+++ b/packages/core/src/editor/BlockNoteEditor.ts
@@ -17,6 +17,7 @@ import {
   DefaultStyleSchema,
   PartialBlock,
 } from "../blocks/index.js";
+import type { SyntaxHighlightingOptions } from "../extensions/SyntaxHighlighting/SyntaxHighlighting.js";
 import {
   BlockChangeExtension,
   DropCursorOptions,
@@ -253,6 +254,13 @@ export interface BlockNoteEditorOptions<
    */
   setIdAttribute?: boolean;
 
+  /**
+   * Options for syntax highlighting block content: the Shiki highlighter to use,
+   * and a `highlightBlock` function picking which blocks to highlight and as
+   * which language.
+   */
+  syntaxHighlighting?: SyntaxHighlightingOptions;
+
   /**
    * Determines behavior when pressing Tab (or Shift-Tab) while multiple blocks are selected and a toolbar is open.
    * - `"prefer-navigate-ui"`: Changes focus to the toolbar. User must press Escape to close toolbar before indenting blocks. Better for keyboard accessibility.
diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
index 2bd6f0b34b..1c13f15c3b 100644
--- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts
+++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
@@ -23,6 +23,7 @@ import {
   ShowSelectionExtension,
   SideMenuExtension,
   SuggestionMenu,
+  SyntaxHighlightingExtension,
   TableHandlesExtension,
   TrailingNodeExtension,
 } from "../../../extensions/index.js";
@@ -179,6 +180,10 @@ export function getDefaultExtensions(
     ...(options.trailingBlock !== false ? [TrailingNodeExtension()] : []),
   ] as ExtensionFactoryInstance[];
 
+  if (options.syntaxHighlighting) {
+    extensions.push(SyntaxHighlightingExtension(options.syntaxHighlighting));
+  }
+
   if ("table" in editor.schema.blockSpecs) {
     extensions.push(TableHandlesExtension(options));
   }
diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts
new file mode 100644
index 0000000000..36fd862b3a
--- /dev/null
+++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vite-plus/test";
+import { SyntaxHighlightingExtension } from "./SyntaxHighlighting.js";
+
+/**
+ * @vitest-environment jsdom
+ */
+
+describe("SyntaxHighlightingExtension", () => {
+  // The extension only reads `editor.schema.blockSpecs`, so a minimal stub is
+  // enough.
+  const fakeEditor = () =>
+    ({
+      schema: {
+        blockSpecs: {
+          paragraph: { config: { type: "paragraph", content: "inline" } },
+          codeBlock: { config: { type: "codeBlock", content: "inline" } },
+          image: { config: { type: "image", content: "none" } },
+        },
+      },
+    }) as any;
+
+  const pluginsFor = (options: any) =>
+    SyntaxHighlightingExtension(options)({ editor: fakeEditor() })
+      .prosemirrorPlugins;
+
+  // Whether highlighting is enabled at all is decided by the editor (it only
+  // instantiates this extension when the `syntaxHighlighting` option is set), so
+  // the extension itself always installs the plugin once created.
+  it("installs a highlight plugin when a highlighter is configured", () => {
+    const plugins = pluginsFor({ createHighlighter: async () => ({}) as any });
+
+    expect(plugins).toHaveLength(1);
+  });
+
+  it("installs the plugin even without a highlighter (it no-ops at parse time)", () => {
+    expect(pluginsFor({})).toHaveLength(1);
+  });
+});
diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts
new file mode 100644
index 0000000000..fab55bcbec
--- /dev/null
+++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts
@@ -0,0 +1,57 @@
+import type { HighlighterGeneric } from "@shikijs/types";
+import type { Block } from "../../blocks/defaultBlocks.js";
+import {
+  createExtension,
+  ExtensionOptions,
+} from "../../editor/BlockNoteExtension.js";
+import { lazyShikiPlugin } from "./shiki.js";
+
+export type SyntaxHighlightingOptions = {
+  /**
+   * Creates the Shiki highlighter used for syntax highlighting. Can be
+   * asynchronous, so the highlighter is only loaded once it's first needed.
+   *
+   * When omitted, content renders without syntax highlighting.
+   */
+  createHighlighter?: () => Promise>;
+  /**
+   * Picks the language to highlight a block's content as - return the language
+   * key, or `undefined` to leave the block un-highlighted. This is where you
+   * enable highlighting for specific blocks.
+   *
+   * Defaults to the block's `language` prop (`(block) => block.props.language`),
+   * which covers the code block. Provide a custom function for blocks with a
+   * fixed language, e.g. for the math block:
+   * `(block) => (block.type === "math" ? "latex" : block.props.language)`.
+   */
+  highlightBlock?: (block: Block) => string | undefined;
+};
+
+/** Highlights a block as its `language` prop (covers the code block). */
+export const defaultHighlightBlock = (block: Block) =>
+  block.props.language as string | undefined;
+
+/**
+ * A single editor-wide extension that syntax-highlights block content. Which
+ * blocks get highlighted (and as which language) is decided by the
+ * `highlightBlock` option, so individual blocks don't configure it themselves.
+ *
+ * Highlighting is opt-in: this extension is only instantiated when the
+ * `syntaxHighlighting` option is configured (see `getDefaultExtensions`).
+ */
+export const SyntaxHighlightingExtension = createExtension(
+  ({ editor, options }: ExtensionOptions) => {
+    const highlightBlock = options.highlightBlock ?? defaultHighlightBlock;
+
+    // Every block with inline (text) content is a candidate; `highlightBlock`
+    // decides per-block whether and how to highlight it.
+    const nodeTypes = Object.values(editor.schema.blockSpecs)
+      .filter((blockSpec) => blockSpec.config.content === "inline")
+      .map((blockSpec) => blockSpec.config.type);
+
+    return {
+      key: "syntaxHighlighting",
+      prosemirrorPlugins: [lazyShikiPlugin(options, nodeTypes, highlightBlock)],
+    };
+  },
+);
diff --git a/packages/core/src/extensions/SyntaxHighlighting/shiki.ts b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts
new file mode 100644
index 0000000000..a7a9a0ffb5
--- /dev/null
+++ b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts
@@ -0,0 +1,94 @@
+import type { HighlighterGeneric } from "@shikijs/types";
+import { Parser, createHighlightPlugin } from "prosemirror-highlight";
+import { createParser } from "prosemirror-highlight/shiki";
+import type { Block } from "../../blocks/defaultBlocks.js";
+import type { SyntaxHighlightingOptions } from "./SyntaxHighlighting.js";
+
+export const shikiParserSymbol = Symbol.for("blocknote.shikiParser");
+export const shikiHighlighterPromiseSymbol = Symbol.for(
+  "blocknote.shikiHighlighterPromise",
+);
+
+// Languages that represent "no highlighting" - skipped without asking Shiki to
+// load a grammar for them.
+const PLAIN_TEXT_LANGUAGES = ["text", "none", "plaintext", "txt"];
+
+/**
+ * Creates the syntax highlighting plugin for the given block types, lazily
+ * loading the highlighter on first use.
+ *
+ * `highlightBlock` resolves each block to a language, which is passed straight
+ * to Shiki - it resolves aliases and loads the grammar from its bundle, so any
+ * language the provided highlighter bundles can be highlighted.
+ */
+export function lazyShikiPlugin(
+  options: SyntaxHighlightingOptions,
+  nodeTypes: string[],
+  highlightBlock: (block: Block) => string | undefined,
+) {
+  const globalThisForShiki = globalThis as {
+    [shikiHighlighterPromiseSymbol]?: Promise>;
+    [shikiParserSymbol]?: Parser;
+  };
+
+  let highlighter: HighlighterGeneric | undefined;
+  let parser: Parser | undefined;
+  // Languages the highlighter failed to load (e.g. not in its bundle). Tracked
+  // so we don't keep retrying - and re-triggering re-highlights - forever.
+  const unsupportedLanguages = new Set();
+  const lazyParser: Parser = (parserOptions) => {
+    if (!options.createHighlighter) {
+      return [];
+    }
+    if (!highlighter) {
+      globalThisForShiki[shikiHighlighterPromiseSymbol] =
+        globalThisForShiki[shikiHighlighterPromiseSymbol] ||
+        options.createHighlighter();
+
+      return globalThisForShiki[shikiHighlighterPromiseSymbol].then(
+        (createdHighlighter) => {
+          highlighter = createdHighlighter;
+        },
+      );
+    }
+    const language = parserOptions.language;
+
+    if (
+      !language ||
+      PLAIN_TEXT_LANGUAGES.includes(language) ||
+      unsupportedLanguages.has(language)
+    ) {
+      return [];
+    }
+
+    if (!highlighter.getLoadedLanguages().includes(language)) {
+      return highlighter.loadLanguage(language as any).catch(() => {
+        // The highlighter doesn't bundle this language - give up on it so we
+        // don't loop trying to load it on every re-highlight.
+        unsupportedLanguages.add(language);
+      });
+    }
+
+    if (!parser) {
+      parser =
+        globalThisForShiki[shikiParserSymbol] ||
+        createParser(highlighter as any);
+      globalThisForShiki[shikiParserSymbol] = parser;
+    }
+
+    return parser(parserOptions);
+  };
+
+  return createHighlightPlugin({
+    parser: lazyParser,
+    // The highlight plugin only gives us the block content node, so we can only
+    // reconstruct the block's `type` and `props` (which is all `highlightBlock`
+    // needs to pick a language).
+    languageExtractor: (node) =>
+      highlightBlock({
+        type: node.type.name,
+        props: node.attrs,
+      } as Block),
+    nodeTypes,
+  });
+}
diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts
index e568462a13..12c3f85d02 100644
--- a/packages/core/src/extensions/index.ts
+++ b/packages/core/src/extensions/index.ts
@@ -12,6 +12,7 @@ export * from "./PreviousBlockType/PreviousBlockType.js";
 export * from "./ShowSelection/ShowSelection.js";
 export * from "./SideMenu/SideMenu.js";
 export * from "./SuggestionMenu/DefaultGridSuggestionItem.js";
+export * from "./SyntaxHighlighting/SyntaxHighlighting.js";
 export * from "./SuggestionMenu/DefaultSuggestionItem.js";
 export * from "./SuggestionMenu/getDefaultEmojiPickerItems.js";
 export * from "./SuggestionMenu/getDefaultSlashMenuItems.js";
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 4d59e79ab8..1c8608a99c 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -25,7 +25,10 @@ export * from "./util/string.js";
 export * from "./util/table.js";
 export * from "./util/typescript.js";
 
-export type { CodeBlockOptions } from "./blocks/Code/block.js";
+export type {
+  CodeBlockOptions,
+  CodeBlockPreview,
+} from "./blocks/Code/CodeBlockOptions.js";
 export { assertEmpty, UnreachableCaseError } from "./util/typescript.js";
 
 export * from "./util/EventEmitter.js";
diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts
index 6df3e68aa4..410567b509 100644
--- a/packages/core/src/schema/blocks/createSpec.ts
+++ b/packages/core/src/schema/blocks/createSpec.ts
@@ -219,10 +219,14 @@ export function addNodeAndExtensionsToSpec<
             applyNonSelectableBlockFix(typedNodeView, this.editor);
           }
 
-          // See explanation for why `update` is not implemented for NodeViews
+          // We don't add a default `update` method to the node view - when a
+          // block doesn't provide one, ProseMirror keeps the node view and
+          // reconciles its `contentDOM` in place as long as the node type stays
+          // the same. Blocks that build custom DOM which needs to stay in sync
+          // with the node (e.g. the code block's preview) can return an `update`
+          // function from `render` to handle updates in place.
           // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464
-          // TODO: in a future version, we might want to implement updates so that
-          // vanilla blocks don't always re-render entirely (https://github.com/TypeCellOS/BlockNote/issues/220)
+          // https://github.com/TypeCellOS/BlockNote/issues/220
           return typedNodeView;
         };
       },
diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts
index 97550ee331..18b0404baa 100644
--- a/packages/core/src/schema/blocks/types.ts
+++ b/packages/core/src/schema/blocks/types.ts
@@ -1,7 +1,11 @@
 /** Define the main block types **/
 // import { Extension, Node } from "@tiptap/core";
 import type { Node, NodeViewRendererProps } from "@tiptap/core";
-import type { Fragment, Schema } from "prosemirror-model";
+import type {
+  Fragment,
+  Node as ProsemirrorNode,
+  Schema,
+} from "prosemirror-model";
 import type { ViewMutationRecord } from "prosemirror-view";
 import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
 import type {
@@ -188,6 +192,7 @@ export type LooseBlockSpec<
       dom: HTMLElement | DocumentFragment;
       contentDOM?: HTMLElement;
       ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
+      update?: (node: ProsemirrorNode) => boolean;
       destroy?: () => void;
     };
     toExternalHTML?: (
@@ -246,6 +251,7 @@ export type BlockSpecs = {
         dom: HTMLElement | DocumentFragment;
         contentDOM?: HTMLElement;
         ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
+        update?: (node: ProsemirrorNode) => boolean;
         destroy?: () => void;
       };
       toExternalHTML?: (
@@ -510,6 +516,17 @@ export type BlockImplementation<
     dom: HTMLElement | DocumentFragment;
     contentDOM?: HTMLElement;
     ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
+    /**
+     * Called by ProseMirror when this block's node is updated (e.g. its content
+     * or props change). Return `true` to handle the update in place - keeping
+     * the existing DOM - or `false` to have the node view recreated via
+     * `render`. When omitted, ProseMirror keeps the node view and reconciles its
+     * `contentDOM` in place as long as the node type stays the same.
+     *
+     * Useful for blocks whose `render` builds custom DOM that needs to stay in
+     * sync with the node (e.g. a code block rendering a preview of its content).
+     */
+    update?: (node: ProsemirrorNode) => boolean;
     destroy?: () => void;
   };
 
diff --git a/packages/math-block/.gitignore b/packages/math-block/.gitignore
new file mode 100644
index 0000000000..58f115c8dc
--- /dev/null
+++ b/packages/math-block/.gitignore
@@ -0,0 +1,23 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/packages/math-block/LICENSE b/packages/math-block/LICENSE
new file mode 100644
index 0000000000..fa0086a952
--- /dev/null
+++ b/packages/math-block/LICENSE
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  This Source Code Form is subject to the terms of the Mozilla Public
+  License, v. 2.0. If a copy of the MPL was not distributed with this
+  file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.
\ No newline at end of file
diff --git a/packages/math-block/package.json b/packages/math-block/package.json
new file mode 100644
index 0000000000..398083e3c7
--- /dev/null
+++ b/packages/math-block/package.json
@@ -0,0 +1,73 @@
+{
+  "name": "@blocknote/math-block",
+  "homepage": "https://github.com/TypeCellOS/BlockNote",
+  "private": false,
+  "sideEffects": [
+    "*.css"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/TypeCellOS/BlockNote.git",
+    "directory": "packages/math-block"
+  },
+  "license": "MPL-2.0",
+  "version": "0.51.4",
+  "files": [
+    "dist",
+    "types",
+    "src"
+  ],
+  "keywords": [
+    "react",
+    "javascript",
+    "editor",
+    "typescript",
+    "prosemirror",
+    "wysiwyg",
+    "rich-text-editor",
+    "notion",
+    "yjs",
+    "block-based",
+    "tiptap",
+    "math",
+    "latex",
+    "mathml"
+  ],
+  "description": "A \"Notion-style\" block-based extensible text editor built on top of Prosemirror and Tiptap.",
+  "type": "module",
+  "source": "src/index.ts",
+  "types": "./types/src/index.d.ts",
+  "main": "./dist/blocknote-math-block.cjs",
+  "module": "./dist/blocknote-math-block.js",
+  "exports": {
+    ".": {
+      "types": "./types/src/index.d.ts",
+      "import": "./dist/blocknote-math-block.js",
+      "require": "./dist/blocknote-math-block.cjs"
+    }
+  },
+  "scripts": {
+    "dev": "vp dev",
+    "lint": "vp lint src",
+    "test": "vp test --run",
+    "test-watch": "vp test watch",
+    "clean": "rimraf dist && rimraf types"
+  },
+  "dependencies": {
+    "katex": "^0.16.11",
+    "mathml-to-latex": "^1.8.0",
+    "prosemirror-model": "^1.25.4",
+    "prosemirror-state": "^1.4.4"
+  },
+  "devDependencies": {
+    "@blocknote/xl-multi-column": "workspace:^",
+    "@types/katex": "^0.16.7",
+    "rimraf": "^5.0.10",
+    "rollup-plugin-webpack-stats": "^0.2.6",
+    "typescript": "^5.9.3",
+    "vite-plus": "catalog:"
+  },
+  "peerDependencies": {
+    "@blocknote/core": "workspace:^"
+  }
+}
diff --git a/packages/math-block/src/block.test.ts b/packages/math-block/src/block.test.ts
new file mode 100644
index 0000000000..68eb2f769e
--- /dev/null
+++ b/packages/math-block/src/block.test.ts
@@ -0,0 +1,791 @@
+import {
+  BlockNoteEditor,
+  BlockNoteSchema,
+  createInlineContentSpec,
+  defaultInlineContentSpecs,
+  FormattingToolbarExtension,
+} from "@blocknote/core";
+import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column";
+import { NodeSelection, TextSelection } from "prosemirror-state";
+import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
+import { createMathBlockSpec } from "./block.js";
+
+/**
+ * @vitest-environment jsdom
+ */
+
+// The math block isn't a default block, so register it in a custom schema.
+const schema = BlockNoteSchema.create().extend({
+  blockSpecs: { math: createMathBlockSpec() },
+});
+
+function pressKey(editor: BlockNoteEditor, key: string) {
+  const view = editor.prosemirrorView;
+  const event = new KeyboardEvent("keydown", { key });
+  return view.someProp("handleKeyDown", (f) => f(view, event)) === true;
+}
+
+/** Selects a block's content node as a NodeSelection (e.g. an image, math). */
+function selectBlockNode(
+  editor: BlockNoteEditor,
+  blockId: string,
+) {
+  const view = editor.prosemirrorView;
+  let nodePos: number | undefined;
+  view.state.doc.descendants((node, pos) => {
+    if (node.attrs.id === blockId) {
+      // The blockContent node sits just inside the blockContainer.
+      nodePos = pos + 1;
+      return false;
+    }
+    return true;
+  });
+  view.dispatch(
+    view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos!)),
+  );
+}
+
+/** Asserts the whole math node is selected (a NodeSelection on it). */
+function expectMathNodeSelected(editor: BlockNoteEditor) {
+  const { selection } = editor.prosemirrorView.state;
+  expect("node" in selection).toBe(true);
+  expect((selection as NodeSelection).node.type.name).toBe("math");
+}
+
+describe("Math block keyboard navigation", () => {
+  let editor: BlockNoteEditor;
+  const div = document.createElement("div");
+
+  beforeEach(() => {
+    editor = BlockNoteEditor.create({ schema });
+    editor.mount(div);
+  });
+
+  afterEach(() => {
+    editor._tiptapEditor.destroy();
+    editor = undefined as any;
+  });
+
+  function setup(blocks: any[]) {
+    editor.replaceBlocks(editor.document, blocks);
+  }
+
+  describe("from an inline content block (paragraph)", () => {
+    beforeEach(() => {
+      setup([
+        { id: "before", type: "paragraph", content: "before" },
+        { id: "math", type: "math", content: "a^2" },
+        { id: "after", type: "paragraph", content: "after" },
+      ]);
+    });
+
+    it.each(["ArrowRight", "ArrowDown"])(
+      "%s at the end of the previous block selects the whole math node",
+      (key) => {
+        editor.setTextCursorPosition("before", "end");
+        // jsdom can't compute layout, so endOfTextblock is stubbed (single-line
+        // block => on the last visual line).
+        editor.prosemirrorView.endOfTextblock = () => true;
+
+        expect(pressKey(editor, key)).toBe(true);
+        expectMathNodeSelected(editor);
+      },
+    );
+
+    it.each(["ArrowLeft", "ArrowUp"])(
+      "%s at the start of the next block selects the whole math node",
+      (key) => {
+        editor.setTextCursorPosition("after", "start");
+        editor.prosemirrorView.endOfTextblock = () => true;
+
+        expect(pressKey(editor, key)).toBe(true);
+        expectMathNodeSelected(editor);
+      },
+    );
+
+    it("ArrowDown selects the math node from anywhere on the previous block's last line", () => {
+      // Cursor in the *middle* of a single-line paragraph - down should still
+      // reach the math block, not just from the very end.
+      editor.setTextCursorPosition("before", "start");
+      editor.prosemirrorView.endOfTextblock = () => true;
+
+      expect(pressKey(editor, "ArrowDown")).toBe(true);
+      expectMathNodeSelected(editor);
+    });
+
+    it("ArrowDown does not select the math node from an earlier line of the previous block", () => {
+      editor.setTextCursorPosition("before", "start");
+      // Not on the last visual line yet.
+      editor.prosemirrorView.endOfTextblock = () => false;
+
+      expect(pressKey(editor, "ArrowDown")).toBe(false);
+      expect(editor.getTextCursorPosition().block.type).toBe("paragraph");
+    });
+
+    it("ArrowDown with a non-empty selection ending on the last line selects the math node", () => {
+      // A ranged (non-empty) selection only collapses for horizontal arrows;
+      // a vertical arrow from its last line still moves to the next block, which
+      // would otherwise skip the hidden math source.
+      const view = editor.prosemirrorView;
+      editor.setTextCursorPosition("before", "start");
+      const from = view.state.selection.from;
+      editor.setTextCursorPosition("before", "end");
+      const to = view.state.selection.from;
+      view.dispatch(
+        view.state.tr.setSelection(
+          TextSelection.create(view.state.doc, from, to),
+        ),
+      );
+      view.endOfTextblock = () => true;
+
+      expect(pressKey(editor, "ArrowDown")).toBe(true);
+      expectMathNodeSelected(editor);
+    });
+
+    it("ArrowRight with a non-empty selection defers to the default (collapses)", () => {
+      const view = editor.prosemirrorView;
+      editor.setTextCursorPosition("before", "start");
+      const from = view.state.selection.from;
+      editor.setTextCursorPosition("before", "end");
+      const to = view.state.selection.from;
+      view.dispatch(
+        view.state.tr.setSelection(
+          TextSelection.create(view.state.doc, from, to),
+        ),
+      );
+
+      expect(pressKey(editor, "ArrowRight")).toBe(false);
+      expect(editor.getTextCursorPosition().block.id).toBe("before");
+    });
+
+    it("does not hijack navigation away from the block boundary", () => {
+      editor.setTextCursorPosition("before", "start");
+
+      expect(pressKey(editor, "ArrowRight")).toBe(false);
+      expect(editor.getTextCursorPosition().block.type).toBe("paragraph");
+    });
+
+    it("defers to the default when leaving a selected math node for a non-math block", () => {
+      selectBlockNode(editor, "math");
+
+      // The next block is a normal, visible paragraph, so leaving is the default
+      // behaviour - the extension doesn't handle it.
+      expect(pressKey(editor, "ArrowRight")).toBe(false);
+    });
+  });
+
+  describe("state transitions", () => {
+    beforeEach(() => {
+      setup([
+        { id: "before", type: "paragraph", content: "before" },
+        { id: "math", type: "math", content: "a^2" },
+        { id: "after", type: "paragraph", content: "after" },
+      ]);
+    });
+
+    it("Enter on the selected math node starts editing at its content start", () => {
+      selectBlockNode(editor, "math");
+
+      expect(pressKey(editor, "Enter")).toBe(true);
+
+      const { selection } = editor.prosemirrorView.state;
+      expect("node" in selection).toBe(false);
+      expect(editor.getTextCursorPosition().block.type).toBe("math");
+      expect(selection.$from.parentOffset).toBe(0);
+    });
+
+    it.each(["Enter", "Escape"])(
+      "%s while editing the content selects the whole math node",
+      (key) => {
+        editor.setTextCursorPosition("math", "start");
+
+        expect(pressKey(editor, key)).toBe(true);
+        expectMathNodeSelected(editor);
+      },
+    );
+
+    it("ArrowRight at the end of the content defers to the default for a non-math next block", () => {
+      editor.setTextCursorPosition("math", "end");
+
+      // The next block is a normal, visible paragraph, so leaving is the default
+      // behaviour - the extension doesn't handle it.
+      expect(pressKey(editor, "ArrowRight")).toBe(false);
+    });
+
+    it("ArrowLeft at the start of the content defers to the default for a non-math previous block", () => {
+      editor.setTextCursorPosition("math", "start");
+
+      expect(pressKey(editor, "ArrowLeft")).toBe(false);
+    });
+
+    it("ArrowDown at the bottom of the content moves to the start of the next block", () => {
+      // Vertical leaving is handled explicitly (default navigation out of the
+      // source popup is unreliable), landing where ArrowRight would.
+      editor.setTextCursorPosition("math", "end");
+      editor.prosemirrorView.endOfTextblock = () => true;
+
+      expect(pressKey(editor, "ArrowDown")).toBe(true);
+      const { block } = editor.getTextCursorPosition();
+      expect(block.id).toBe("after");
+      expect(editor.prosemirrorView.state.selection.$from.parentOffset).toBe(0);
+    });
+
+    it("ArrowUp at the top of the content moves to the end of the previous block", () => {
+      editor.setTextCursorPosition("math", "start");
+      editor.prosemirrorView.endOfTextblock = () => true;
+
+      expect(pressKey(editor, "ArrowUp")).toBe(true);
+      const { block } = editor.getTextCursorPosition();
+      expect(block.id).toBe("before");
+      const { $from } = editor.prosemirrorView.state.selection;
+      expect($from.parentOffset).toBe($from.parent.content.size);
+    });
+
+    it("ArrowRight in the middle of the content stays in the math block", () => {
+      editor.setTextCursorPosition("math", "start");
+
+      expect(pressKey(editor, "ArrowRight")).toBe(false);
+      expect(editor.getTextCursorPosition().block.type).toBe("math");
+    });
+  });
+
+  describe("between adjacent math blocks", () => {
+    beforeEach(() => {
+      setup([
+        { id: "m1", type: "math", content: "a^2" },
+        { id: "m2", type: "math", content: "b^2" },
+      ]);
+    });
+
+    /** The id of the math block whose node is currently selected. */
+    function selectedMathId() {
+      const { selection } = editor.prosemirrorView.state;
+      expect("node" in selection).toBe(true);
+      return editor.getTextCursorPosition().block.id;
+    }
+
+    it.each(["ArrowDown", "ArrowRight"])(
+      "%s from the selected first math node selects the second as a whole node",
+      (key) => {
+        selectBlockNode(editor, "m1");
+
+        expect(pressKey(editor, key)).toBe(true);
+        expect(selectedMathId()).toBe("m2");
+      },
+    );
+
+    it.each(["ArrowUp", "ArrowLeft"])(
+      "%s from the selected second math node selects the first as a whole node",
+      (key) => {
+        selectBlockNode(editor, "m2");
+
+        expect(pressKey(editor, key)).toBe(true);
+        expect(selectedMathId()).toBe("m1");
+      },
+    );
+
+    it.each(["ArrowDown", "ArrowRight"])(
+      "%s from the end of the first block's content selects the second as a whole node",
+      (key) => {
+        editor.setTextCursorPosition("m1", "end");
+        // jsdom can't compute layout, so stub the vertical edge check (a
+        // single-line content => at the bottom visual line). Horizontal edges
+        // are derived from the model and don't need it.
+        editor.prosemirrorView.endOfTextblock = () => true;
+
+        expect(pressKey(editor, key)).toBe(true);
+        expect(selectedMathId()).toBe("m2");
+      },
+    );
+
+    it.each(["ArrowUp", "ArrowLeft"])(
+      "%s from the start of the second block's content selects the first as a whole node",
+      (key) => {
+        editor.setTextCursorPosition("m2", "start");
+        editor.prosemirrorView.endOfTextblock = () => true;
+
+        expect(pressKey(editor, key)).toBe(true);
+        expect(selectedMathId()).toBe("m1");
+      },
+    );
+
+    it("an arrow in the middle of the content stays in the block (no edge)", () => {
+      editor.setTextCursorPosition("m1", "start");
+      // Not at the bottom visual line, and not at the right edge of the content.
+      editor.prosemirrorView.endOfTextblock = () => false;
+
+      expect(pressKey(editor, "ArrowDown")).toBe(false);
+      expect(editor.getTextCursorPosition().block.id).toBe("m1");
+    });
+  });
+
+  describe("from a no-content block (image)", () => {
+    it("forward keys from a selected image before the math block select it", () => {
+      setup([
+        { id: "img", type: "image" },
+        { id: "math", type: "math", content: "a^2" },
+      ]);
+      selectBlockNode(editor, "img");
+
+      expect(pressKey(editor, "ArrowRight")).toBe(true);
+      expectMathNodeSelected(editor);
+    });
+
+    it("does not skip a no-content block to reach a math block beyond it", () => {
+      // With an image between the paragraph and the math block, ArrowRight from
+      // the paragraph should fall through to the default (selecting the image),
+      // not jump over the image into the math block.
+      setup([
+        { id: "before", type: "paragraph", content: "before" },
+        { id: "img", type: "image" },
+        { id: "math", type: "math", content: "a^2" },
+      ]);
+      editor.setTextCursorPosition("before", "end");
+
+      expect(pressKey(editor, "ArrowRight")).toBe(false);
+      expect(editor.getTextCursorPosition().block.type).not.toBe("math");
+    });
+
+    it("backward keys from a selected image after the math block select it", () => {
+      setup([
+        { id: "math", type: "math", content: "a^2" },
+        { id: "img", type: "image" },
+      ]);
+      selectBlockNode(editor, "img");
+
+      expect(pressKey(editor, "ArrowUp")).toBe(true);
+      expectMathNodeSelected(editor);
+    });
+  });
+
+  describe("from a table", () => {
+    function makeTable(rows: number, cols: number) {
+      return {
+        type: "tableContent" as const,
+        rows: Array.from({ length: rows }, () => ({
+          cells: Array.from({ length: cols }, () => "x"),
+        })),
+      };
+    }
+
+    it("forward keys from the last cell select the following math block", () => {
+      setup([
+        { id: "table", type: "table", content: makeTable(2, 2) },
+        { id: "math", type: "math", content: "a^2" },
+      ]);
+      // Place the cursor in the last cell (bottom-right).
+      const view = editor.prosemirrorView;
+      let lastCellEnd = 0;
+      view.state.doc.descendants((node, pos) => {
+        if (node.type.name === "tableParagraph") {
+          lastCellEnd = pos + node.nodeSize - 1;
+        }
+        return true;
+      });
+      view.dispatch(
+        view.state.tr.setSelection(
+          TextSelection.create(view.state.doc, lastCellEnd),
+        ),
+      );
+
+      expect(pressKey(editor, "ArrowRight")).toBe(true);
+      expectMathNodeSelected(editor);
+    });
+
+    it("ArrowDown from a bottom-row, non-corner cell selects the following math block", () => {
+      setup([
+        { id: "table", type: "table", content: makeTable(2, 2) },
+        { id: "math", type: "math", content: "a^2" },
+      ]);
+      // Cursor in the bottom-LEFT cell (bottom row, but not the document-order
+      // corner), so only the table vertical-edge path can catch it.
+      const view = editor.prosemirrorView;
+      const cellStarts: number[] = [];
+      view.state.doc.descendants((node, pos) => {
+        if (node.type.name === "tableParagraph") {
+          cellStarts.push(pos + 1);
+        }
+        return true;
+      });
+      // 2x2 table: cells are [TL, TR, BL, BR]; bottom-left is index 2.
+      view.dispatch(
+        view.state.tr.setSelection(
+          TextSelection.create(view.state.doc, cellStarts[2]),
+        ),
+      );
+      // jsdom can't compute layout, so endOfTextblock is stubbed (single-line
+      // cell => at the bottom visual line).
+      view.endOfTextblock = () => true;
+
+      expect(pressKey(editor, "ArrowDown")).toBe(true);
+      expectMathNodeSelected(editor);
+    });
+
+    it("backward keys from the first cell select the preceding math block", () => {
+      setup([
+        { id: "math", type: "math", content: "a^2" },
+        { id: "table", type: "table", content: makeTable(2, 2) },
+      ]);
+      const view = editor.prosemirrorView;
+      let firstCellStart: number | undefined;
+      view.state.doc.descendants((node, pos) => {
+        if (
+          node.type.name === "tableParagraph" &&
+          firstCellStart === undefined
+        ) {
+          firstCellStart = pos + 1;
+        }
+        return true;
+      });
+      view.dispatch(
+        view.state.tr.setSelection(
+          TextSelection.create(view.state.doc, firstCellStart!),
+        ),
+      );
+
+      expect(pressKey(editor, "ArrowLeft")).toBe(true);
+      expectMathNodeSelected(editor);
+    });
+
+    it("ArrowDown from a non-bottom row stays in the table", () => {
+      setup([
+        { id: "table", type: "table", content: makeTable(2, 2) },
+        { id: "math", type: "math", content: "a^2" },
+      ]);
+      const view = editor.prosemirrorView;
+      const cellStarts: number[] = [];
+      view.state.doc.descendants((node, pos) => {
+        if (node.type.name === "tableParagraph") {
+          cellStarts.push(pos + 1);
+        }
+        return true;
+      });
+      // Top-left cell (index 0) - a single-line cell reports endOfTextblock,
+      // but it isn't the bottom row, so it must not exit the table (the table's
+      // own handling moves to the row below instead).
+      view.dispatch(
+        view.state.tr.setSelection(
+          TextSelection.create(view.state.doc, cellStarts[0]),
+        ),
+      );
+      view.endOfTextblock = () => true;
+
+      pressKey(editor, "ArrowDown");
+      expect(editor.getTextCursorPosition().block.type).toBe("table");
+    });
+
+    it("ArrowRight from a non-last cell stays in the table", () => {
+      setup([
+        { id: "table", type: "table", content: makeTable(2, 2) },
+        { id: "math", type: "math", content: "a^2" },
+      ]);
+      const view = editor.prosemirrorView;
+      // End of the top-left cell: at the cell's right edge, but not the
+      // document-order last cell, so it must not exit the table.
+      let firstCellEnd: number | undefined;
+      view.state.doc.descendants((node, pos) => {
+        if (node.type.name === "tableParagraph" && firstCellEnd === undefined) {
+          firstCellEnd = pos + node.nodeSize - 1;
+        }
+        return true;
+      });
+      view.dispatch(
+        view.state.tr.setSelection(
+          TextSelection.create(view.state.doc, firstCellEnd!),
+        ),
+      );
+
+      pressKey(editor, "ArrowRight");
+      expect(editor.getTextCursorPosition().block.type).toBe("table");
+    });
+  });
+
+  describe("selection decoration", () => {
+    /** The element carrying the standard "selected node" class, if any. */
+    function selectedPreviewEl() {
+      return div.querySelector(".ProseMirror-selectednode");
+    }
+
+    beforeEach(() => {
+      setup([
+        { id: "before", type: "paragraph", content: "before" },
+        { id: "math", type: "math", content: "a^2" },
+      ]);
+    });
+
+    it("adds the class to the block while editing its content", () => {
+      editor.setTextCursorPosition("math", "start");
+
+      const el = selectedPreviewEl();
+      expect(el).not.toBeNull();
+      // The class lands on the block content wrapper, with the preview inside.
+      expect(el!.querySelector(".bn-code-block-preview")).not.toBeNull();
+    });
+
+    it("keeps the class when moving from the whole node into editing its content", () => {
+      // Reproduces the regression where ProseMirror's `deselectNode` strips the
+      // class on the node-selection -> text-selection transition: select the
+      // whole node, then Enter to start editing.
+      selectBlockNode(editor, "math");
+      expect(selectedPreviewEl()).not.toBeNull();
+
+      expect(pressKey(editor, "Enter")).toBe(true);
+      expect(selectedPreviewEl()).not.toBeNull();
+    });
+
+    it("does not add the class while the selection is in another block", () => {
+      editor.setTextCursorPosition("before", "end");
+
+      expect(selectedPreviewEl()).toBeNull();
+    });
+
+    it("removes the class when the selection leaves the block", () => {
+      editor.setTextCursorPosition("math", "start");
+      expect(selectedPreviewEl()).not.toBeNull();
+
+      editor.setTextCursorPosition("before", "end");
+      expect(selectedPreviewEl()).toBeNull();
+    });
+  });
+
+  describe("formatting toolbar suppression", () => {
+    const toolbarShown = () =>
+      editor.getExtension(FormattingToolbarExtension)!.store.state;
+
+    beforeEach(() => {
+      setup([
+        { id: "before", type: "paragraph", content: "before" },
+        { id: "math", type: "math", content: "a^2+b^2" },
+      ]);
+    });
+
+    it("shows for a non-empty selection in a normal block", () => {
+      const view = editor.prosemirrorView;
+      view.dispatch(
+        view.state.tr.setSelection(TextSelection.create(view.state.doc, 2, 5)),
+      );
+
+      expect(toolbarShown()).toBe(true);
+    });
+
+    it("stays hidden while the whole math node is selected", () => {
+      selectBlockNode(editor, "math");
+
+      expect(toolbarShown()).toBe(false);
+    });
+
+    it("stays hidden while text is selected inside the math content", () => {
+      const view = editor.prosemirrorView;
+      let start: number | undefined;
+      let end: number | undefined;
+      view.state.doc.descendants((node, pos) => {
+        if (node.type.name === "math") {
+          start = pos + 1;
+          end = pos + node.nodeSize - 1;
+          return false;
+        }
+        return true;
+      });
+      view.dispatch(
+        view.state.tr.setSelection(
+          TextSelection.create(view.state.doc, start!, end!),
+        ),
+      );
+
+      expect(toolbarShown()).toBe(false);
+    });
+  });
+});
+
+describe("Math block nested navigation", () => {
+  // Columns aren't a core block, so register them alongside the math block.
+  const nestedSchema = BlockNoteSchema.create().extend({
+    blockSpecs: {
+      math: createMathBlockSpec(),
+      column: ColumnBlock,
+      columnList: ColumnListBlock,
+    },
+  });
+
+  let editor: BlockNoteEditor;
+  const div = document.createElement("div");
+
+  beforeEach(() => {
+    editor = BlockNoteEditor.create({ schema: nestedSchema });
+    editor.mount(div);
+  });
+
+  afterEach(() => {
+    editor._tiptapEditor.destroy();
+    editor = undefined as any;
+  });
+
+  it.each(["ArrowDown", "ArrowRight"])(
+    "%s into a column selects a math block nested as its first block",
+    (key) => {
+      editor.replaceBlocks(editor.document, [
+        { id: "before", type: "paragraph", content: "before" },
+        {
+          type: "columnList",
+          children: [
+            {
+              type: "column",
+              children: [
+                { id: "nested-math", type: "math", content: "a^2" },
+                { type: "paragraph", content: "x" },
+              ],
+            },
+            { type: "column", children: [{ type: "paragraph", content: "y" }] },
+          ],
+        },
+      ] as any);
+      editor.setTextCursorPosition("before", "end");
+      // jsdom can't compute layout (needed for the vertical edge check).
+      editor.prosemirrorView.endOfTextblock = () => true;
+
+      expect(pressKey(editor, key)).toBe(true);
+      const { selection } = editor.prosemirrorView.state;
+      expect("node" in selection).toBe(true);
+      expect((selection as NodeSelection).node.type.name).toBe("math");
+      expect(editor.getTextCursorPosition().block.id).toBe("nested-math");
+    },
+  );
+});
+
+describe("Math block navigation from selected inline content", () => {
+  // A no-content inline node (like a mention) can be selected as a node, which
+  // is distinct from selecting the whole block.
+  const mention = createInlineContentSpec(
+    { type: "mention", propSchema: { user: { default: "" } }, content: "none" },
+    {
+      render: (ic) => {
+        const dom = document.createElement("span");
+        dom.textContent = `@${ic.props.user}`;
+        return { dom };
+      },
+    },
+  );
+  const inlineSchema = BlockNoteSchema.create({
+    inlineContentSpecs: { mention, ...defaultInlineContentSpecs },
+  }).extend({ blockSpecs: { math: createMathBlockSpec() } });
+
+  let editor: BlockNoteEditor;
+  const div = document.createElement("div");
+
+  beforeEach(() => {
+    editor = BlockNoteEditor.create({ schema: inlineSchema });
+    editor.mount(div);
+    editor.replaceBlocks(editor.document, [
+      {
+        id: "p",
+        type: "paragraph",
+        content: [
+          "hi ",
+          { type: "mention", props: { user: "M" }, content: undefined } as any,
+        ],
+      },
+      { id: "math", type: "math", content: "a^2" },
+    ]);
+  });
+
+  afterEach(() => {
+    editor._tiptapEditor.destroy();
+    editor = undefined as any;
+  });
+
+  /** Selects the inline mention node (distinct from selecting the block). */
+  function selectMention() {
+    const view = editor.prosemirrorView;
+    let pos: number | undefined;
+    view.state.doc.descendants((node, p) => {
+      if (node.type.name === "mention") {
+        pos = p;
+        return false;
+      }
+      return true;
+    });
+    view.dispatch(
+      view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos!)),
+    );
+  }
+
+  it("ArrowDown from a node-selected inline node on the last line selects the next math block", () => {
+    selectMention();
+    editor.prosemirrorView.endOfTextblock = () => true;
+
+    expect(pressKey(editor, "ArrowDown")).toBe(true);
+    const { selection } = editor.prosemirrorView.state;
+    expect("node" in selection).toBe(true);
+    expect((selection as NodeSelection).node.type.name).toBe("math");
+  });
+
+  it("ArrowRight from a node-selected inline node defers to the default (stays in the block)", () => {
+    selectMention();
+
+    // A node selection isn't the whole block, so horizontal arrows must not jump
+    // to the math block - they move within the block by default.
+    expect(pressKey(editor, "ArrowRight")).toBe(false);
+    expect(editor.getTextCursorPosition().block.id).toBe("p");
+  });
+});
+
+describe("Math block MathML interchange", () => {
+  let editor: BlockNoteEditor;
+  const div = document.createElement("div");
+
+  beforeEach(() => {
+    editor = BlockNoteEditor.create({ schema });
+    editor.mount(div);
+  });
+
+  afterEach(() => {
+    editor._tiptapEditor.destroy();
+    editor = undefined as any;
+  });
+
+  // Parses HTML and returns the LaTeX source of the first math block.
+  const parseMathLatex = (html: string) => {
+    const blocks = editor.tryParseHTMLToBlocks(html);
+    const mathBlock = blocks.find((block) => block.type === "math");
+    if (!mathBlock) {
+      throw new Error(`No math block parsed from: ${html}`);
+    }
+    return (mathBlock.content as any[]).map((node) => node.text ?? "").join("");
+  };
+
+  it("exports a math block to a  (MathML) element", () => {
+    expect(
+      editor.blocksToHTMLLossy([
+        { type: "math", content: "a^2 + b^2 = c^2" } as any,
+      ]),
+    ).toMatchInlineSnapshot(
+      `"a2+b2=c2a^2 + b^2 = c^2"`,
+    );
+  });
+
+  it("parses a plain  element into LaTeX", () => {
+    expect(
+      parseMathLatex("a2"),
+    ).toMatchInlineSnapshot(`"a^{2}"`);
+  });
+
+  it("parses a  element using its LaTeX annotation when present", () => {
+    expect(
+      parseMathLatex(
+        'a\\frac{a}{b}',
+      ),
+    ).toMatchInlineSnapshot(`"\\frac{a}{b}"`);
+  });
+
+  it("round-trips LaTeX through MathML export and back", () => {
+    const latex = "a^2 + b^2 = c^2";
+
+    const html = editor.blocksToHTMLLossy([
+      { type: "math", content: latex } as any,
+    ]);
+
+    // The exported MathML is annotated with the original TeX, so it round-trips
+    // back to exactly the same LaTeX.
+    expect(parseMathLatex(html)).toBe(latex);
+  });
+});
diff --git a/packages/math-block/src/block.ts b/packages/math-block/src/block.ts
new file mode 100644
index 0000000000..ba937729cc
--- /dev/null
+++ b/packages/math-block/src/block.ts
@@ -0,0 +1,40 @@
+import {
+  createBlockConfig,
+  createBlockSpec,
+  createPreviewSourceNavigationExtension,
+  createPreviewWithSourcePopup,
+} from "@blocknote/core";
+import {
+  parseMathML,
+  parseMathMLContent,
+} from "./helpers/parse/parseMathML.js";
+import { createMathPreview } from "./helpers/render/createMathPreview.js";
+import { createMathML } from "./helpers/toExternalHTML/createMathML.js";
+
+export type MathBlockConfig = ReturnType;
+
+export const createMathBlockConfig = createBlockConfig(
+  () =>
+    ({
+      type: "math" as const,
+      propSchema: {},
+      content: "inline" as const,
+    }) as const,
+);
+
+export const createMathBlockSpec = createBlockSpec(
+  createMathBlockConfig,
+  {
+    meta: {
+      code: true,
+      defining: true,
+      isolating: false,
+    },
+    parse: (el) => parseMathML(el),
+    parseContent: ({ el, schema }) => parseMathMLContent({ el, schema }),
+    render: (block, editor) =>
+      createPreviewWithSourcePopup({})(block, editor, createMathPreview),
+    toExternalHTML: (block) => createMathML(block),
+  },
+  [createPreviewSourceNavigationExtension("math-block-navigation", "math")],
+);
diff --git a/packages/math-block/src/helpers/getMathSource.ts b/packages/math-block/src/helpers/getMathSource.ts
new file mode 100644
index 0000000000..9cc1fee0e4
--- /dev/null
+++ b/packages/math-block/src/helpers/getMathSource.ts
@@ -0,0 +1,14 @@
+/** The block's LaTeX source - its plain text content. */
+export const getMathSource = (block: { content: unknown }): string => {
+  // Partial blocks (e.g. when exporting) carry their content as a plain string,
+  // while editor blocks carry it as an array of inline content nodes.
+  if (typeof block.content === "string") {
+    return block.content;
+  }
+  if (Array.isArray(block.content)) {
+    return block.content
+      .map((node) => ("text" in node ? node.text : ""))
+      .join("");
+  }
+  return "";
+};
diff --git a/packages/math-block/src/helpers/parse/parseMathML.ts b/packages/math-block/src/helpers/parse/parseMathML.ts
new file mode 100644
index 0000000000..d9d35dfafe
--- /dev/null
+++ b/packages/math-block/src/helpers/parse/parseMathML.ts
@@ -0,0 +1,33 @@
+import { MathMLToLaTeX } from "mathml-to-latex";
+import type { Schema } from "prosemirror-model";
+
+const mathMLElementToLaTeX = (el: HTMLElement): string => {
+  const annotations = Array.from(el.getElementsByTagName("annotation"));
+  const texAnnotation = annotations.find(
+    (annotation) => annotation.getAttribute("encoding") === "application/x-tex",
+  );
+  if (texAnnotation?.textContent) {
+    return texAnnotation.textContent.trim();
+  }
+
+  try {
+    return MathMLToLaTeX.convert(el.outerHTML).trim();
+  } catch {
+    return "";
+  }
+};
+
+export const parseMathML = (el: HTMLElement) =>
+  el.nodeName.toLowerCase() === "math" ? {} : undefined;
+
+export const parseMathMLContent = ({
+  el,
+  schema,
+}: {
+  el: HTMLElement;
+  schema: Schema;
+}) => {
+  const source = mathMLElementToLaTeX(el);
+  return schema.nodes["math"].create(null, source ? schema.text(source) : null)
+    .content;
+};
diff --git a/packages/math-block/src/helpers/render/createMathPreview.ts b/packages/math-block/src/helpers/render/createMathPreview.ts
new file mode 100644
index 0000000000..54b728f8ed
--- /dev/null
+++ b/packages/math-block/src/helpers/render/createMathPreview.ts
@@ -0,0 +1,30 @@
+import type { CodeBlockPreview } from "@blocknote/core";
+import katex from "katex";
+import "katex/dist/katex.min.css";
+import { getMathSource } from "../getMathSource.js";
+
+export const createMathPreview: CodeBlockPreview = (block) => {
+  const source = getMathSource(block);
+
+  // Render with `throwOnError: true` first so we can check for syntax errors.
+  let html: string;
+  let error: string | null = null;
+  try {
+    html = katex.renderToString(source, {
+      throwOnError: true,
+      displayMode: true,
+    });
+  } catch (e) {
+    error = e instanceof Error ? e.message : String(e);
+    html = katex.renderToString(source, {
+      throwOnError: false,
+      displayMode: true,
+    });
+  }
+
+  const template = document.createElement("template");
+  template.innerHTML = html;
+  const dom = template.content.firstElementChild as HTMLElement;
+
+  return { dom, error };
+};
diff --git a/packages/math-block/src/helpers/toExternalHTML/createMathML.ts b/packages/math-block/src/helpers/toExternalHTML/createMathML.ts
new file mode 100644
index 0000000000..23b13de1ab
--- /dev/null
+++ b/packages/math-block/src/helpers/toExternalHTML/createMathML.ts
@@ -0,0 +1,20 @@
+import type { BlockFromConfig } from "@blocknote/core";
+import katex from "katex";
+import { getMathSource } from "../getMathSource.js";
+
+export const createMathML = (block: BlockFromConfig) => {
+  const mathml = katex.renderToString(getMathSource(block), {
+    displayMode: true,
+    output: "mathml",
+    throwOnError: false,
+  });
+
+  const wrapper = document.createElement("div");
+  wrapper.innerHTML = mathml;
+
+  // KaTeX wraps its MathML in a ``; export the bare
+  // `` element as the top-level node.
+  const math = wrapper.querySelector("math");
+
+  return { dom: (math ?? wrapper.firstElementChild) as HTMLElement };
+};
diff --git a/packages/math-block/src/index.ts b/packages/math-block/src/index.ts
new file mode 100644
index 0000000000..a8ec7c51f9
--- /dev/null
+++ b/packages/math-block/src/index.ts
@@ -0,0 +1,5 @@
+export * from "./block.js";
+export * from "./helpers/getMathSource.js";
+export * from "./helpers/parse/parseMathML.js";
+export * from "./helpers/render/createMathPreview.js";
+export * from "./helpers/toExternalHTML/createMathML.js";
diff --git a/packages/math-block/src/vite-env.d.ts b/packages/math-block/src/vite-env.d.ts
new file mode 100644
index 0000000000..bc2d8a36f3
--- /dev/null
+++ b/packages/math-block/src/vite-env.d.ts
@@ -0,0 +1 @@
+/// 
diff --git a/packages/math-block/tsconfig.json b/packages/math-block/tsconfig.json
new file mode 100644
index 0000000000..c74ac34642
--- /dev/null
+++ b/packages/math-block/tsconfig.json
@@ -0,0 +1,25 @@
+{
+  "compilerOptions": {
+    "target": "ESNext",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ESNext", "DOM"],
+    "moduleResolution": "bundler",
+    "jsx": "react-jsx",
+    "strict": true,
+    "sourceMap": true,
+    "resolveJsonModule": true,
+    "esModuleInterop": true,
+    "noEmit": false,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noImplicitReturns": true,
+    "outDir": "dist",
+    "declaration": true,
+    "declarationDir": "types",
+    "composite": true,
+    "skipLibCheck": true,
+    "emitDeclarationOnly": true
+  },
+  "include": ["src"]
+}
diff --git a/packages/math-block/vite.config.ts b/packages/math-block/vite.config.ts
new file mode 100644
index 0000000000..99bdfd9d91
--- /dev/null
+++ b/packages/math-block/vite.config.ts
@@ -0,0 +1,77 @@
+import * as path from "path";
+import { webpackStats } from "rollup-plugin-webpack-stats";
+import { defineConfig, type UserConfig } from "vite-plus";
+import pkg from "./package.json";
+
+// https://vitejs.dev/config/
+export default defineConfig(
+  (conf) =>
+    ({
+      run: {
+        tasks: {
+          build: {
+            command: "tsc && vp build",
+            input: [
+              { auto: true },
+              { pattern: "!**/*.tsbuildinfo", base: "workspace" },
+            ],
+          },
+        },
+      },
+      test: {
+        setupFiles: ["./vitestSetup.ts"],
+      },
+      plugins: [webpackStats() as any],
+      // used so that vitest resolves the core package from the sources instead of the built version
+      resolve: {
+        alias:
+          conf.command === "build"
+            ? ({} as Record)
+            : ({
+                // load live from sources with live reload working
+                "@blocknote/core": path.resolve(__dirname, "../core/src/"),
+                "@blocknote/react": path.resolve(__dirname, "../react/src/"),
+              } as Record),
+      },
+      build: {
+        sourcemap: true,
+        lib: {
+          entry: {
+            "blocknote-math-block": path.resolve(__dirname, "src/index.ts"),
+          },
+          name: "blocknote-math-block",
+          formats: ["es", "cjs"],
+          fileName: (format, entryName) =>
+            format === "es" ? `${entryName}.js` : `${entryName}.cjs`,
+        },
+        rollupOptions: {
+          // make sure to externalize deps that shouldn't be bundled
+          // into your library
+          external: (source) => {
+            if (
+              Object.keys({
+                ...pkg.dependencies,
+                ...((pkg as any).peerDependencies || {}),
+                ...pkg.devDependencies,
+              }).some((dep) => source === dep || source.startsWith(dep + "/"))
+            ) {
+              return true;
+            }
+            return (
+              source.startsWith("react/") ||
+              source.startsWith("react-dom/") ||
+              source.startsWith("prosemirror-") ||
+              source.startsWith("@tiptap/") ||
+              source.startsWith("@blocknote/") ||
+              source.startsWith("node:")
+            );
+          },
+          output: {
+            // Provide global variables to use in the UMD build
+            // for externalized deps
+            globals: {},
+          },
+        },
+      },
+    }) as UserConfig,
+);
diff --git a/packages/math-block/vitestSetup.ts b/packages/math-block/vitestSetup.ts
new file mode 100644
index 0000000000..dbcf3eb39c
--- /dev/null
+++ b/packages/math-block/vitestSetup.ts
@@ -0,0 +1,10 @@
+import { afterEach, beforeEach } from "vite-plus/test";
+
+beforeEach(() => {
+  globalThis.window = globalThis.window || ({} as any);
+  (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {};
+});
+
+afterEach(() => {
+  delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
+});
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index eb6b499d53..3518018abd 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -1445,6 +1445,28 @@ export const examples = {
         readme:
           "In this example, we create a custom block which renders a simple HTML paragraph with placeholder text. The block has no editable content.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)",
       },
+      {
+        projectSlug: "math-block",
+        fullSlug: "custom-schema/math-block",
+        pathFromRoot: "examples/06-custom-schema/09-math-block",
+        config: {
+          playground: true,
+          docs: true,
+          author: "matthewlipski",
+          tags: ["Intermediate", "Blocks", "Custom Schemas"],
+          dependencies: {
+            "@blocknote/code-block": "latest",
+            "@blocknote/math-block": "latest",
+          } as any,
+        },
+        title: "Math Block",
+        group: {
+          pathFromRoot: "examples/06-custom-schema",
+          slug: "custom-schema",
+        },
+        readme:
+          "In this example, we register the `@blocknote/math-block` block in a custom schema. The math block renders LaTeX as MathML (using Temml) for the browser to display natively, and reveals an editable LaTeX source popup when selected. Exporting to HTML produces a MathML `` element, and pasting MathML back in is converted to LaTeX.\n\n**Try it out:** Click a formula to edit its LaTeX!\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)",
+      },
       {
         projectSlug: "draggable-inline-content",
         fullSlug: "custom-schema/draggable-inline-content",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 234764f35c..d14acfd5c2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -73,6 +73,9 @@ importers:
       '@blocknote/mantine':
         specifier: workspace:*
         version: link:../packages/mantine
+      '@blocknote/math-block':
+        specifier: workspace:*
+        version: link:../packages/math-block
       '@blocknote/react':
         specifier: workspace:*
         version: link:../packages/react
@@ -3358,6 +3361,55 @@ importers:
         specifier: 'catalog:'
         version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0))
 
+  examples/06-custom-schema/09-math-block:
+    dependencies:
+      '@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
+      '@blocknote/mantine':
+        specifier: latest
+        version: link:../../../packages/mantine
+      '@blocknote/math-block':
+        specifier: latest
+        version: link:../../../packages/math-block
+      '@blocknote/react':
+        specifier: latest
+        version: link:../../../packages/react
+      '@blocknote/shadcn':
+        specifier: latest
+        version: link:../../../packages/shadcn
+      '@mantine/core':
+        specifier: ^9.0.2
+        version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+      '@mantine/hooks':
+        specifier: ^9.0.2
+        version: 9.1.1(react@19.2.5)
+      react:
+        specifier: ^19.2.3
+        version: 19.2.5
+      react-dom:
+        specifier: ^19.2.3
+        version: 19.2.5(react@19.2.5)
+    devDependencies:
+      '@types/react':
+        specifier: ^19.2.3
+        version: 19.2.14
+      '@types/react-dom':
+        specifier: ^19.2.3
+        version: 19.2.3(@types/react@19.2.14)
+      '@vitejs/plugin-react':
+        specifier: ^6.0.1
+        version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0))
+      vite-plus:
+        specifier: 'catalog:'
+        version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0))
+
   examples/06-custom-schema/draggable-inline-content:
     dependencies:
       '@blocknote/ariakit':
@@ -4564,7 +4616,13 @@ importers:
       '@shikijs/themes':
         specifier: ^4
         version: 4.0.2
+      katex:
+        specifier: ^0.16.11
+        version: 0.16.47
     devDependencies:
+      '@types/katex':
+        specifier: ^0.16.7
+        version: 0.16.8
       rimraf:
         specifier: ^5.0.10
         version: 5.0.10
@@ -4587,6 +4645,9 @@ importers:
       '@emoji-mart/data':
         specifier: ^1.2.1
         version: 1.2.1
+      '@floating-ui/dom':
+        specifier: ^1.7.6
+        version: 1.7.6
       '@handlewithcare/prosemirror-inputrules':
         specifier: ^0.1.4
         version: 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)
@@ -4758,6 +4819,43 @@ importers:
         specifier: 'catalog:'
         version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0))
 
+  packages/math-block:
+    dependencies:
+      '@blocknote/core':
+        specifier: workspace:^
+        version: link:../core
+      katex:
+        specifier: ^0.16.11
+        version: 0.16.47
+      mathml-to-latex:
+        specifier: ^1.8.0
+        version: 1.8.0
+      prosemirror-model:
+        specifier: ^1.25.4
+        version: 1.25.4
+      prosemirror-state:
+        specifier: ^1.4.4
+        version: 1.4.4
+    devDependencies:
+      '@blocknote/xl-multi-column':
+        specifier: workspace:^
+        version: link:../xl-multi-column
+      '@types/katex':
+        specifier: ^0.16.7
+        version: 0.16.8
+      rimraf:
+        specifier: ^5.0.10
+        version: 5.0.10
+      rollup-plugin-webpack-stats:
+        specifier: ^0.2.6
+        version: 0.2.6(rollup@4.60.1)
+      typescript:
+        specifier: ^5.9.3
+        version: 5.9.3
+      vite-plus:
+        specifier: 'catalog:'
+        version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0))
+
   packages/react:
     dependencies:
       '@blocknote/core':
@@ -9881,6 +9979,9 @@ packages:
   '@types/json5@0.0.29':
     resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
 
+  '@types/katex@0.16.8':
+    resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
+
   '@types/lodash.foreach@4.5.9':
     resolution: {integrity: sha512-vmq0p/FK66PsALXRmK/qsnlLlCpnudvozWYrxJImHujHhXMADdeoPEY10zwmu26437w85wCvdxUqpFi+ALtkiQ==}
 
@@ -10429,6 +10530,10 @@ packages:
   '@webassemblyjs/wast-printer@1.14.1':
     resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
 
+  '@xmldom/xmldom@0.9.10':
+    resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
+    engines: {node: '>=14.6'}
+
   '@xtuc/ieee754@1.2.0':
     resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
 
@@ -10999,6 +11104,10 @@ packages:
     resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
     engines: {node: '>= 6'}
 
+  commander@8.3.0:
+    resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
+    engines: {node: '>= 12'}
+
   commondir@1.0.1:
     resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
 
@@ -12494,6 +12603,10 @@ packages:
   jszip@3.10.1:
     resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
 
+  katex@0.16.47:
+    resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
+    hasBin: true
+
   keyv@4.5.4:
     resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
 
@@ -12706,6 +12819,9 @@ packages:
     resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
     engines: {node: '>= 0.4'}
 
+  mathml-to-latex@1.8.0:
+    resolution: {integrity: sha512-gQ0uK3zqB8HwlfaXJkEL5rgaZNbKUiBMmBP/B/W+b+t6KcseLSuYb1b0BjLgS9ZiQa24ePkqTX8/6FaQuDL7wQ==}
+
   mdast-util-find-and-replace@3.0.2:
     resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
 
@@ -19723,6 +19839,8 @@ snapshots:
 
   '@types/json5@0.0.29': {}
 
+  '@types/katex@0.16.8': {}
+
   '@types/lodash.foreach@4.5.9':
     dependencies:
       '@types/lodash': 4.17.24
@@ -20470,6 +20588,8 @@ snapshots:
       '@webassemblyjs/ast': 1.14.1
       '@xtuc/long': 4.2.2
 
+  '@xmldom/xmldom@0.9.10': {}
+
   '@xtuc/ieee754@1.2.0': {}
 
   '@xtuc/long@4.2.2': {}
@@ -21006,6 +21126,8 @@ snapshots:
 
   commander@4.1.1: {}
 
+  commander@8.3.0: {}
+
   commondir@1.0.1: {}
 
   compressible@2.0.18:
@@ -22705,6 +22827,10 @@ snapshots:
       readable-stream: 2.3.8
       setimmediate: 1.0.5
 
+  katex@0.16.47:
+    dependencies:
+      commander: 8.3.0
+
   keyv@4.5.4:
     dependencies:
       json-buffer: 3.0.1
@@ -22863,6 +22989,10 @@ snapshots:
 
   math-intrinsics@1.1.0: {}
 
+  mathml-to-latex@1.8.0:
+    dependencies:
+      '@xmldom/xmldom': 0.9.10
+
   mdast-util-find-and-replace@3.0.2:
     dependencies:
       '@types/mdast': 4.0.4
diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json
index 142a5e7771..ef3a4f138e 100644
--- a/tests/src/unit/core/schema/__snapshots__/blocks.json
+++ b/tests/src/unit/core/schema/__snapshots__/blocks.json
@@ -128,7 +128,6 @@
     },
     "extensions": [
       [Function],
-      [Function],
     ],
     "implementation": {
       "meta": {