From e3f182a66ec12dab29009a5b07c0e55d3d482d61 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 9 Jun 2026 20:38:07 +0200 Subject: [PATCH 1/6] Added `renderPreview` field to code block options and added LaTeX preview --- examples/04-theming/06-code-block/src/App.tsx | 16 ++ packages/code-block/package.json | 4 +- packages/code-block/src/index.ts | 2 + packages/code-block/src/renderLaTeXPreview.ts | 28 +++ packages/core/package.json | 1 + packages/core/src/blocks/Code/block.ts | 188 +++++++++--------- .../Code/renderPreviewWithSourcePopup.ts | 182 +++++++++++++++++ packages/core/src/blocks/Code/renderSource.ts | 64 ++++++ packages/core/src/editor/Block.css | 57 ++++++ packages/core/src/index.ts | 5 +- packages/core/src/schema/blocks/createSpec.ts | 10 +- packages/core/src/schema/blocks/types.ts | 19 +- pnpm-lock.yaml | 28 +++ 13 files changed, 502 insertions(+), 102 deletions(-) create mode 100644 packages/code-block/src/renderLaTeXPreview.ts create mode 100644 packages/core/src/blocks/Code/renderPreviewWithSourcePopup.ts create mode 100644 packages/core/src/blocks/Code/renderSource.ts diff --git a/examples/04-theming/06-code-block/src/App.tsx b/examples/04-theming/06-code-block/src/App.tsx index 82d10bae9e..ce1d989f4b 100644 --- a/examples/04-theming/06-code-block/src/App.tsx +++ b/examples/04-theming/06-code-block/src/App.tsx @@ -31,6 +31,22 @@ export default function App() { { type: "paragraph", }, + { + type: "codeBlock", + props: { + language: "latex", + }, + content: [ + { + type: "text", + text: "f(x) = \\int_{-\\infty}^\\infty \\hat f(\\xi)\\,e^{2 \\pi i \\xi x} \\,d\\xi", + styles: {}, + }, + ], + }, + { + type: "paragraph", + }, { type: "heading", props: { 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.ts b/packages/code-block/src/index.ts index 2cb588092d..e79cc60fa2 100644 --- a/packages/code-block/src/index.ts +++ b/packages/code-block/src/index.ts @@ -1,5 +1,6 @@ import type { CodeBlockOptions } from "@blocknote/core"; import { createHighlighter } from "./shiki.bundle.js"; +import { renderLaTeXPreview } from "./renderLaTeXPreview.js"; export const codeBlockOptions = { defaultLanguage: "javascript", @@ -163,6 +164,7 @@ export const codeBlockOptions = { latex: { name: "LaTeX", aliases: ["latex"], + renderPreview: renderLaTeXPreview, }, lua: { name: "Lua", diff --git a/packages/code-block/src/renderLaTeXPreview.ts b/packages/code-block/src/renderLaTeXPreview.ts new file mode 100644 index 0000000000..c13e6ab0d2 --- /dev/null +++ b/packages/code-block/src/renderLaTeXPreview.ts @@ -0,0 +1,28 @@ +import type { CodeBlockRenderPreview } from "@blocknote/core"; +import katex from "katex"; +import "katex/dist/katex.min.css"; + +/** + * Renders a preview of a LaTeX code block using KaTeX. + * + * This is only responsible for the preview itself - the code block's `render` + * function decides when & where the preview is shown. + */ +export const renderLaTeXPreview: CodeBlockRenderPreview = (block) => { + const dom = document.createElement("div"); + dom.className = "bn-latex-preview"; + + // The LaTeX source is the block's plain text content. + const source = Array.isArray(block.content) + ? block.content.map((node) => ("text" in node ? node.text : "")).join("") + : ""; + + katex.render(source, dom, { + // Renders invalid LaTeX as an error message instead of throwing, so the + // preview updates gracefully while the user is still typing. + throwOnError: false, + displayMode: true, + }); + + return { dom }; +}; diff --git a/packages/core/package.json b/packages/core/package.json index 69b76bb07f..a5fcd75394 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/block.ts b/packages/core/src/blocks/Code/block.ts index dbb7fc33a9..3a9c858d5d 100644 --- a/packages/core/src/blocks/Code/block.ts +++ b/packages/core/src/blocks/Code/block.ts @@ -1,9 +1,32 @@ import type { HighlighterGeneric } from "@shikijs/types"; +import type { ViewMutationRecord } from "prosemirror-view"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { createExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, createBlockSpec } from "../../schema/index.js"; +import type { BlockFromConfig } from "../../schema/index.js"; +import { createRenderPreviewWithSourcePopup } from "./renderPreviewWithSourcePopup.js"; +import { createRenderSource } from "./renderSource.js"; import { lazyShikiPlugin } from "./shiki.js"; import { DOMParser } from "@tiptap/pm/model"; +/** + * 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 CodeBlockRenderPreview = ( + block: BlockFromConfig, + editor: BlockNoteEditor, +) => { + dom: HTMLElement; + 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. @@ -43,6 +66,12 @@ export type CodeBlockOptions = { * 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. + */ + renderPreview?: CodeBlockRenderPreview; } >; /** @@ -68,109 +97,76 @@ export const createCodeBlockConfig = createBlockConfig( export const createCodeBlockSpec = createBlockSpec( createCodeBlockConfig, - (options) => ({ - meta: { - code: true, - 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!; + (options) => { + const renderSource = createRenderSource(options); + const renderPreviewWithSourcePopup = + createRenderPreviewWithSourcePopup(options); + + return { + meta: { + code: true, + defining: true, + isolating: false, + }, + parse: (e) => { + if (e.tagName !== "PRE") { + return undefined; + } - return parser.parse(code, { - preserveWhitespace: "full", - topNode: schema.nodes["codeBlock"].create(), - }).content; - }, + if ( + e.childElementCount !== 1 || + e.firstElementChild?.tagName !== "CODE" + ) { + return undefined; + } - render(block, editor) { - const wrapper = document.createDocumentFragment(); - const pre = document.createElement("pre"); - const code = document.createElement("code"); - pre.appendChild(code); + const code = e.firstElementChild!; + const language = + code.getAttribute("data-language") || + code.className + .split(" ") + .find((name) => name.includes("language-")) + ?.replace("language-", ""); - let removeSelectChangeListener = undefined; + return { language }; + }, - if (options.supportedLanguages) { - const select = document.createElement("select"); + parseContent: ({ el, schema }) => { + const parser = DOMParser.fromSchema(schema); + const code = el.firstElementChild!; - Object.entries(options.supportedLanguages ?? {}).forEach( - ([id, { name }]) => { - const option = document.createElement("option"); + return parser.parse(code, { + preserveWhitespace: "full", + topNode: schema.nodes["codeBlock"].create(), + }).content; + }, - option.value = id; - option.text = name; - select.appendChild(option); - }, - ); - select.value = + render(block, editor) { + const language = 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, - }; - }, - }), + const renderPreview = + options.supportedLanguages?.[language]?.renderPreview; + + // Languages with a preview show the rendered result by default, with the + // editable source in a popup when selected. Other languages just show the + // source. + return renderPreview + ? renderPreviewWithSourcePopup(block, editor, renderPreview) + : renderSource(block, editor); + }, + 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, + }; + }, + }; + }, (options) => { return [ createExtension({ diff --git a/packages/core/src/blocks/Code/renderPreviewWithSourcePopup.ts b/packages/core/src/blocks/Code/renderPreviewWithSourcePopup.ts new file mode 100644 index 0000000000..2a14cdef7b --- /dev/null +++ b/packages/core/src/blocks/Code/renderPreviewWithSourcePopup.ts @@ -0,0 +1,182 @@ +import { + autoUpdate, + computePosition, + flip, + offset, + shift, +} from "@floating-ui/dom"; +import type { Node as ProsemirrorNode } from "prosemirror-model"; +import type { ViewMutationRecord } from "prosemirror-view"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { BlockFromConfig } from "../../schema/index.js"; +import type { + CodeBlockConfig, + CodeBlockOptions, + CodeBlockRenderPreview, +} from "./block.js"; +import { createRenderSource } from "./renderSource.js"; + +/** + * Gets the plain text content (i.e. the source) of a code block. + */ +function getCodeBlockText( + block: BlockFromConfig, +): string { + const content = block.content; + + if (!Array.isArray(content)) { + return ""; + } + + return content.map((node) => ("text" in node ? node.text : "")).join(""); +} + +/** + * Creates a function that renders a preview of the code, showing the editable + * source in a popup below the preview (positioned via FloatingUI) while the + * block is selected. The popup reuses `renderSource` for its content. + */ +export function createRenderPreviewWithSourcePopup(options: CodeBlockOptions) { + const renderSource = createRenderSource(options); + + return ( + block: BlockFromConfig, + editor: BlockNoteEditor, + renderPreview: CodeBlockRenderPreview, + ) => { + const dom = document.createElement("div"); + dom.className = "bn-code-block-with-preview"; + + // Shows the rendered preview. Always visible & never editable. + const previewContainer = document.createElement("div"); + previewContainer.className = "bn-code-block-preview"; + previewContainer.contentEditable = "false"; + dom.appendChild(previewContainer); + + let preview = renderPreview(block, editor); + previewContainer.appendChild(preview.dom); + + // Holds the editable source, shown in a popup below the preview when the + // block is selected. + const source = renderSource(block, editor); + const sourcePopup = document.createElement("div"); + sourcePopup.className = "bn-code-block-source-popup"; + sourcePopup.style.display = "none"; + sourcePopup.appendChild(source.dom); + dom.appendChild(sourcePopup); + + // 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 })], + }).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; + }; + + // Shows the source popup only while the block is selected. + const updateSourcePopupVisibility = () => { + let isSelected = false; + try { + isSelected = editor.getTextCursorPosition().block.id === block.id; + } catch { + isSelected = false; + } + + if (editor.isEditable && isSelected) { + showSourcePopup(); + } else { + hideSourcePopup(); + } + }; + const removeSelectionChangeListener = editor.onSelectionChange( + updateSourcePopupVisibility, + ); + updateSourcePopupVisibility(); + + // 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, which + // selects the block and reveals the popup via the selection listener. + const handlePreviewMouseDown = (event: MouseEvent) => { + if (!editor.isEditable) { + return; + } + event.preventDefault(); + showSourcePopup(); + editor.setTextCursorPosition(block.id, "end"); + 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 = renderPreview( + editor.getBlock(block.id) as BlockFromConfig< + CodeBlockConfig, + any, + any + >, + editor, + ); + previewContainer.appendChild(preview.dom); + } + + return true; + }, + destroy: () => { + source.destroy(); + removeSelectionChangeListener(); + cleanupAutoUpdate?.(); + preview.destroy?.(); + previewContainer.removeEventListener( + "mousedown", + handlePreviewMouseDown, + ); + }, + }; + }; +} diff --git a/packages/core/src/blocks/Code/renderSource.ts b/packages/core/src/blocks/Code/renderSource.ts new file mode 100644 index 0000000000..4aa878c170 --- /dev/null +++ b/packages/core/src/blocks/Code/renderSource.ts @@ -0,0 +1,64 @@ +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { BlockFromConfig } from "../../schema/index.js"; +import type { CodeBlockConfig, CodeBlockOptions } from "./block.js"; + +/** + * Creates a function that renders the editable source of a code block as a + * `
`, with a language selection dropdown. This is the default
+ * rendering for languages that don't support previews, and is reused as the
+ * source popup's content for languages that do.
+ */
+export function createRenderSource(options: CodeBlockOptions) {
+  return (
+    block: BlockFromConfig,
+    editor: BlockNoteEditor,
+  ) => {
+    const language = block.props.language || options.defaultLanguage || "text";
+
+    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 = language;
+
+      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/editor/Block.css b/packages/core/src/editor/Block.css
index 547e009d6f..ee7b26ac43 100644
--- a/packages/core/src/editor/Block.css
+++ b/packages/core/src/editor/Block.css
@@ -453,6 +453,63 @@ NESTED BLOCKS
   transition-delay: 0.1s;
 }
 
+/* CODE BLOCK PREVIEW */
+/* Preview-supporting languages render the preview in place of the raw source,
+so the surrounding "code editor" styling is dropped from the block itself and
+applied to the source popup instead. */
+.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;
+}
+.bn-code-block-preview {
+  padding: 12px;
+  min-height: 1.5em;
+  cursor: text;
+}
+.bn-code-block-source-popup {
+  position: absolute;
+  z-index: 1;
+
+  min-width: 240px;
+
+  background-color: rgb(22 22 22);
+  color: white;
+  border-radius: 8px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
+}
+/* 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: white;
+
+  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;
+}
+
 /* PAGE BREAK */
 .bn-block-content[data-content-type="pageBreak"] > div {
   width: 100%;
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 4d59e79ab8..021b537b59 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,
+  CodeBlockRenderPreview,
+} from "./blocks/Code/block.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/pnpm-lock.yaml b/pnpm-lock.yaml
index 405cff905b..06a07f2959 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4558,7 +4558,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
@@ -4581,6 +4587,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)
@@ -9806,6 +9815,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==}
 
@@ -10877,6 +10889,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==}
 
@@ -12372,6 +12388,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==}
 
@@ -19492,6 +19512,8 @@ snapshots:
 
   '@types/json5@0.0.29': {}
 
+  '@types/katex@0.16.8': {}
+
   '@types/lodash.foreach@4.5.9':
     dependencies:
       '@types/lodash': 4.17.24
@@ -20743,6 +20765,8 @@ snapshots:
 
   commander@4.1.1: {}
 
+  commander@8.3.0: {}
+
   commondir@1.0.1: {}
 
   compressible@2.0.18:
@@ -22442,6 +22466,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

From 6ccc9558b9947581b95e54c8be837f6ed2255e83 Mon Sep 17 00:00:00 2001
From: Matthew Lipski 
Date: Tue, 16 Jun 2026 11:54:00 +0200
Subject: [PATCH 2/6] Math block overhaul

---
 .../docs/features/blocks/code-blocks.mdx      |  48 ++-
 docs/package.json                             |   3 +-
 examples/04-theming/06-code-block/src/App.tsx |  21 +-
 .../07-custom-code-block/src/App.tsx          |  16 +-
 .../09-math-block/.bnexample.json             |  10 +
 .../06-custom-schema/09-math-block/README.md  |  10 +
 .../06-custom-schema/09-math-block/index.html |  14 +
 .../06-custom-schema/09-math-block/main.tsx   |  11 +
 .../09-math-block/package.json                |  32 ++
 .../09-math-block/src/App.tsx                 |  46 +++
 .../09-math-block/tsconfig.json               |  29 ++
 .../09-math-block/vite.config.ts              |  31 ++
 packages/code-block/src/index.test.ts         |   6 +-
 packages/code-block/src/index.ts              |  16 +-
 packages/code-block/src/renderLaTeXPreview.ts |  28 --
 .../core/src/blocks/Code/CodeBlockOptions.ts  |  81 ++++
 packages/core/src/blocks/Code/block.test.ts   |   2 +-
 packages/core/src/blocks/Code/block.ts        | 303 ++------------
 .../createCodeKeyboardShortcutsExtension.ts   | 120 ++++++
 .../createPreviewSourceNavigationExtension.ts | 206 ++++++++++
 .../createPreviewSourceSelectionExtension.ts  |  48 +++
 .../blocks/Code/helpers/parse/parsePreCode.ts |  45 +++
 .../helpers/render/createCodeBlockWrapper.ts  |  19 +
 .../render/createPreviewWithSourcePopup.ts}   |  48 +--
 .../render/createSourceBlock.ts}              |  21 +-
 .../helpers/toExternalHTML/createPreCode.ts   |  14 +
 packages/core/src/blocks/Code/shiki.ts        |  73 ----
 packages/core/src/blocks/index.ts             |   8 +
 packages/core/src/editor/Block.css            |   7 +
 packages/core/src/editor/BlockNoteEditor.ts   |   8 +
 .../managers/ExtensionManager/extensions.ts   |   2 +
 .../SyntaxHighlighting.test.ts                |  36 ++
 .../SyntaxHighlighting/SyntaxHighlighting.ts  |  64 +++
 .../extensions/SyntaxHighlighting/shiki.ts    |  94 +++++
 packages/core/src/extensions/index.ts         |   1 +
 packages/core/src/index.ts                    |   4 +-
 packages/math-block/.gitignore                |  23 ++
 packages/math-block/LICENSE                   | 373 ++++++++++++++++++
 packages/math-block/package.json              |  71 ++++
 packages/math-block/src/block.test.ts         | 345 ++++++++++++++++
 packages/math-block/src/block.ts              |  39 ++
 .../math-block/src/helpers/getMathSource.ts   |  14 +
 .../src/helpers/parse/parseMathML.ts          |  39 ++
 .../src/helpers/render/createMathPreview.ts   |  30 ++
 .../helpers/toExternalHTML/createMathML.ts    |  19 +
 packages/math-block/src/index.ts              |   5 +
 packages/math-block/src/vite-env.d.ts         |   1 +
 packages/math-block/tsconfig.json             |  25 ++
 packages/math-block/vite.config.ts            |  77 ++++
 packages/math-block/vitestSetup.ts            |  10 +
 playground/src/examples.gen.tsx               |  22 ++
 pnpm-lock.yaml                                | 102 +++++
 52 files changed, 2245 insertions(+), 475 deletions(-)
 create mode 100644 examples/06-custom-schema/09-math-block/.bnexample.json
 create mode 100644 examples/06-custom-schema/09-math-block/README.md
 create mode 100644 examples/06-custom-schema/09-math-block/index.html
 create mode 100644 examples/06-custom-schema/09-math-block/main.tsx
 create mode 100644 examples/06-custom-schema/09-math-block/package.json
 create mode 100644 examples/06-custom-schema/09-math-block/src/App.tsx
 create mode 100644 examples/06-custom-schema/09-math-block/tsconfig.json
 create mode 100644 examples/06-custom-schema/09-math-block/vite.config.ts
 delete mode 100644 packages/code-block/src/renderLaTeXPreview.ts
 create mode 100644 packages/core/src/blocks/Code/CodeBlockOptions.ts
 create mode 100644 packages/core/src/blocks/Code/helpers/extensions/createCodeKeyboardShortcutsExtension.ts
 create mode 100644 packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceNavigationExtension.ts
 create mode 100644 packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceSelectionExtension.ts
 create mode 100644 packages/core/src/blocks/Code/helpers/parse/parsePreCode.ts
 create mode 100644 packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts
 rename packages/core/src/blocks/Code/{renderPreviewWithSourcePopup.ts => helpers/render/createPreviewWithSourcePopup.ts} (83%)
 rename packages/core/src/blocks/Code/{renderSource.ts => helpers/render/createSourceBlock.ts} (69%)
 create mode 100644 packages/core/src/blocks/Code/helpers/toExternalHTML/createPreCode.ts
 delete mode 100644 packages/core/src/blocks/Code/shiki.ts
 create mode 100644 packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts
 create mode 100644 packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts
 create mode 100644 packages/core/src/extensions/SyntaxHighlighting/shiki.ts
 create mode 100644 packages/math-block/.gitignore
 create mode 100644 packages/math-block/LICENSE
 create mode 100644 packages/math-block/package.json
 create mode 100644 packages/math-block/src/block.test.ts
 create mode 100644 packages/math-block/src/block.ts
 create mode 100644 packages/math-block/src/helpers/getMathSource.ts
 create mode 100644 packages/math-block/src/helpers/parse/parseMathML.ts
 create mode 100644 packages/math-block/src/helpers/render/createMathPreview.ts
 create mode 100644 packages/math-block/src/helpers/toExternalHTML/createMathML.ts
 create mode 100644 packages/math-block/src/index.ts
 create mode 100644 packages/math-block/src/vite-env.d.ts
 create mode 100644 packages/math-block/tsconfig.json
 create mode 100644 packages/math-block/vite.config.ts
 create mode 100644 packages/math-block/vitestSetup.ts

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 5160dc8574..b902a58112 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -97,7 +97,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 ce1d989f4b..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),
@@ -31,22 +34,6 @@ export default function App() {
       {
         type: "paragraph",
       },
-      {
-        type: "codeBlock",
-        props: {
-          language: "latex",
-        },
-        content: [
-          {
-            type: "text",
-            text: "f(x) = \\int_{-\\infty}^\\infty \\hat f(\\xi)\\,e^{2 \\pi i \\xi x} \\,d\\xi",
-            styles: {},
-          },
-        ],
-      },
-      {
-        type: "paragraph",
-      },
       {
         type: "heading",
         props: {
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/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 e79cc60fa2..1b5db6d952 100644 --- a/packages/code-block/src/index.ts +++ b/packages/code-block/src/index.ts @@ -1,7 +1,13 @@ import type { CodeBlockOptions } from "@blocknote/core"; -import { createHighlighter } from "./shiki.bundle.js"; -import { renderLaTeXPreview } from "./renderLaTeXPreview.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: { @@ -164,7 +170,6 @@ export const codeBlockOptions = { latex: { name: "LaTeX", aliases: ["latex"], - renderPreview: renderLaTeXPreview, }, lua: { name: "Lua", @@ -199,9 +204,4 @@ export const codeBlockOptions = { aliases: ["objective-c", "objc"], }, }, - createHighlighter: () => - createHighlighter({ - themes: ["github-dark", "github-light"], - langs: [], - }), } satisfies CodeBlockOptions; diff --git a/packages/code-block/src/renderLaTeXPreview.ts b/packages/code-block/src/renderLaTeXPreview.ts deleted file mode 100644 index c13e6ab0d2..0000000000 --- a/packages/code-block/src/renderLaTeXPreview.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { CodeBlockRenderPreview } from "@blocknote/core"; -import katex from "katex"; -import "katex/dist/katex.min.css"; - -/** - * Renders a preview of a LaTeX code block using KaTeX. - * - * This is only responsible for the preview itself - the code block's `render` - * function decides when & where the preview is shown. - */ -export const renderLaTeXPreview: CodeBlockRenderPreview = (block) => { - const dom = document.createElement("div"); - dom.className = "bn-latex-preview"; - - // The LaTeX source is the block's plain text content. - const source = Array.isArray(block.content) - ? block.content.map((node) => ("text" in node ? node.text : "")).join("") - : ""; - - katex.render(source, dom, { - // Renders invalid LaTeX as an error message instead of throwing, so the - // preview updates gracefully while the user is still typing. - throwOnError: false, - displayMode: true, - }); - - return { dom }; -}; diff --git a/packages/core/src/blocks/Code/CodeBlockOptions.ts b/packages/core/src/blocks/Code/CodeBlockOptions.ts new file mode 100644 index 0000000000..12c3d3c88e --- /dev/null +++ b/packages/core/src/blocks/Code/CodeBlockOptions.ts @@ -0,0 +1,81 @@ +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; + 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 { + return Object.entries(options.supportedLanguages ?? {}).find( + ([id, { aliases }]) => { + return aliases?.includes(languageName) || id === languageName; + }, + )?.[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 3a9c858d5d..ae13c70ce6 100644 --- a/packages/core/src/blocks/Code/block.ts +++ b/packages/core/src/blocks/Code/block.ts @@ -1,84 +1,12 @@ -import type { HighlighterGeneric } from "@shikijs/types"; -import type { ViewMutationRecord } from "prosemirror-view"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { createExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, createBlockSpec } from "../../schema/index.js"; -import type { BlockFromConfig } from "../../schema/index.js"; -import { createRenderPreviewWithSourcePopup } from "./renderPreviewWithSourcePopup.js"; -import { createRenderSource } from "./renderSource.js"; -import { lazyShikiPlugin } from "./shiki.js"; -import { DOMParser } from "@tiptap/pm/model"; - -/** - * 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 CodeBlockRenderPreview = ( - block: BlockFromConfig, - editor: BlockNoteEditor, -) => { - dom: HTMLElement; - 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. - */ - renderPreview?: CodeBlockRenderPreview; - } - >; - /** - * 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; @@ -97,208 +25,23 @@ export const createCodeBlockConfig = createBlockConfig( export const createCodeBlockSpec = createBlockSpec( createCodeBlockConfig, - (options) => { - const renderSource = createRenderSource(options); - const renderPreviewWithSourcePopup = - createRenderPreviewWithSourcePopup(options); - - return { - meta: { - code: true, - 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 language = - block.props.language || options.defaultLanguage || "text"; - const renderPreview = - options.supportedLanguages?.[language]?.renderPreview; - - // Languages with a preview show the rendered result by default, with the - // editable source in a popup when selected. Other languages just show the - // source. - return renderPreview - ? renderPreviewWithSourcePopup(block, editor, renderPreview) - : renderSource(block, editor); - }, - 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, - }; - }, - }; - }, + (options) => ({ + meta: { + code: true, + defining: true, + isolating: false, + }, + 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..a91617347a --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceNavigationExtension.ts @@ -0,0 +1,206 @@ +import { Plugin, PluginKey, Selection, TextSelection } from "prosemirror-state"; +import { + getBlockInfo, + getBlockInfoFromSelection, + getNearestBlockPos, +} from "../../../../api/getBlockInfoFromPos.js"; +import { createExtension } from "../../../../editor/BlockNoteExtension.js"; + +/** + * Blocks like the math block render their content as a preview and hide the + * editable source unless the block is selected. Because the source has no + * visible size while hidden, the browser (and so ProseMirror's default arrow + * key handling) skips straight over the block when navigating from an adjacent + * block - there's nowhere visible for the cursor to land. + * + * This extension restores that navigation: when an arrow key would move the + * cursor across one of these blocks, we instead place the cursor inside its + * (now revealed) source content. + * + * - Forward keys (ArrowRight/ArrowDown) from the end of the previous block move + * to the start of the block's content. + * - Backward keys (ArrowLeft/ArrowUp) from the start of the next block move to + * the end of the block's content. + * + * It only ever moves *into* the block - leaving it works by default since the + * source is visible while the block is selected. + */ +export const createPreviewSourceNavigationExtension = ( + key: string, + blockType: string, +) => + createExtension({ + key, + prosemirrorPlugins: [ + new Plugin({ + key: new PluginKey(`${key}-plugin`), + props: { + handleKeyDown: (view, event) => { + const forward = + event.key === "ArrowRight" || event.key === "ArrowDown"; + const backward = + event.key === "ArrowLeft" || event.key === "ArrowUp"; + const vertical = + event.key === "ArrowUp" || event.key === "ArrowDown"; + + if (!forward && !backward) { + return false; + } + + // Modifier-held arrows (selection extension, word jumps, etc.) and + // IME composition are left to their default behaviour. + if ( + event.shiftKey || + event.ctrlKey || + event.metaKey || + event.altKey || + event.isComposing + ) { + return false; + } + + const { state } = view; + const { selection, doc } = state; + + // Only collapsed text cursors and node selections (e.g. images) + // can navigate into an adjacent block. Anything else (cell + // selections, ranged selections) is left to the default handler. + const isNodeSelection = "node" in selection; + if (!isNodeSelection && !selection.empty) { + return false; + } + + // If we're already inside one of these blocks, leaving it is + // handled by the default behaviour - don't hijack it. + const currentBlock = getBlockInfoFromSelection(state); + if ( + currentBlock.isBlockContainer && + currentBlock.blockNoteType === blockType + ) { + return false; + } + + // Moves the cursor into the block adjacent to the current one in + // the move direction - but only if it's one of the blocks this + // extension handles. Searching outwards from the block boundary + // (whose parent isn't inline content, so `findFrom` steps into the + // neighbour rather than returning the boundary unchanged) lands on + // the nearest selectable position: the neighbour's content start + // when moving forward, or its end when moving back. `textOnly` is + // false so leaf-node neighbours (e.g. images) are stopped at rather + // than skipped over. Returns whether it moved. + const moveIntoSibling = () => { + const boundaryPos = forward + ? currentBlock.bnBlock.afterPos + : currentBlock.bnBlock.beforePos; + const target = Selection.findFrom( + doc.resolve(boundaryPos), + forward ? 1 : -1, + false, + ); + + if (!target) { + return false; + } + + const targetBlock = getBlockInfo( + getNearestBlockPos(doc, target.from), + ); + if ( + !targetBlock.isBlockContainer || + targetBlock.blockNoteType !== blockType + ) { + return false; + } + + view.dispatch(state.tr.setSelection(target).scrollIntoView()); + + return true; + }; + + // Determines whether the cursor sits at the very end (forward) or + // start (backward) of the current block. We search for the + // nearest text position from *outside* the block's boundary + // inwards - this avoids `findFrom`'s habit of returning the given + // position unchanged when it's already inside inline content, and + // naturally handles tables (the inner position is in the last / + // first cell). + const atBlockEdge = () => { + // A selected node (e.g. an image) has no inner cursor positions, + // so any arrow key exits it. + if (isNodeSelection) { + return true; + } + + const innermost = Selection.findFrom( + doc.resolve( + forward + ? currentBlock.bnBlock.afterPos + : currentBlock.bnBlock.beforePos, + ), + forward ? -1 : 1, + true, + ); + if (!innermost) { + return false; + } + + return forward + ? selection.$to.pos >= innermost.from + : selection.$from.pos <= innermost.from; + }; + + // Primary case: the cursor is at the edge of its block and the + // sibling block in the move direction is the target block. This + // covers inline blocks (paragraphs, headings), node-selected + // blocks (images), and the document-order edge of a table (its + // last / first cell). + if (atBlockEdge() && moveIntoSibling()) { + return true; + } + + // Tables navigate cell-by-cell, so vertical keys from the bottom + // row (down) or top row (up) - other than at the document-order + // corner handled above - aren't caught by the search above. Detect + // that we're at the table's vertical edge and check the sibling + // block directly. + if ( + vertical && + currentBlock.isBlockContainer && + currentBlock.blockNoteType === "table" + ) { + const { $head } = selection as TextSelection; + + let rowDepth = $head.depth; + while ( + rowDepth > 0 && + $head.node(rowDepth).type.name !== "tableRow" + ) { + rowDepth--; + } + + if (rowDepth > 0) { + const tableNode = $head.node(rowDepth - 1); + const rowIndex = $head.index(rowDepth - 1); + const atVerticalEdge = forward + ? rowIndex === tableNode.childCount - 1 + : rowIndex === 0; + + // Only exit when the cursor is on the last/first visual line of + // the cell, so multi-line cells still navigate internally. + if ( + atVerticalEdge && + view.endOfTextblock(forward ? "down" : "up") && + moveIntoSibling() + ) { + return true; + } + } + } + + return false; + }, + }, + }), + ], + }); diff --git a/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceSelectionExtension.ts b/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceSelectionExtension.ts new file mode 100644 index 0000000000..15a805079b --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceSelectionExtension.ts @@ -0,0 +1,48 @@ +import { Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import { getBlockInfoFromSelection } from "../../../../api/getBlockInfoFromPos.js"; +import { createExtension } from "../../../../editor/BlockNoteExtension.js"; + +/** + * The class added to a preview-source block (e.g. the math block) while the + * selection is inside it. Because the source is shown in a popup rather than + * inline, the block never gets a native node selection, so this gives CSS a + * hook to highlight the preview (mimicking `ProseMirror-selectednode`). + */ +export const PREVIEW_SOURCE_SELECTED_CLASS = "bn-preview-source-selected"; + +/** + * Adds {@link PREVIEW_SOURCE_SELECTED_CLASS} to the block's content node + * whenever the selection sits inside it. + */ +export const createPreviewSourceSelectionExtension = ( + key: string, + blockType: string, +) => + createExtension({ + key, + prosemirrorPlugins: [ + new Plugin({ + key: new PluginKey(`${key}-plugin`), + props: { + decorations: (state) => { + const blockInfo = getBlockInfoFromSelection(state); + if ( + !blockInfo.isBlockContainer || + blockInfo.blockNoteType !== blockType + ) { + return null; + } + + return DecorationSet.create(state.doc, [ + Decoration.node( + blockInfo.blockContent.beforePos, + blockInfo.blockContent.afterPos, + { class: PREVIEW_SOURCE_SELECTED_CLASS }, + ), + ]); + }, + }, + }), + ], + }); 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..a3e8c224c2 --- /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.includes("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..6e44295895 --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts @@ -0,0 +1,19 @@ +import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import type { BlockFromConfig } from "../../../../schema/index.js"; +import 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 renderPreview = options.supportedLanguages?.[language]?.createPreview; + + // Languages with a preview show the rendered result by default, with the + // editable source in a popup when selected. 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/renderPreviewWithSourcePopup.ts b/packages/core/src/blocks/Code/helpers/render/createPreviewWithSourcePopup.ts similarity index 83% rename from packages/core/src/blocks/Code/renderPreviewWithSourcePopup.ts rename to packages/core/src/blocks/Code/helpers/render/createPreviewWithSourcePopup.ts index 2a14cdef7b..77c2f5b6fb 100644 --- a/packages/core/src/blocks/Code/renderPreviewWithSourcePopup.ts +++ b/packages/core/src/blocks/Code/helpers/render/createPreviewWithSourcePopup.ts @@ -7,21 +7,16 @@ import { } from "@floating-ui/dom"; import type { Node as ProsemirrorNode } from "prosemirror-model"; import type { ViewMutationRecord } from "prosemirror-view"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import type { BlockFromConfig } from "../../schema/index.js"; +import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import type { BlockFromConfig } from "../../../../schema/index.js"; +import type { CodeBlockConfig } from "../../block.js"; import type { - CodeBlockConfig, CodeBlockOptions, - CodeBlockRenderPreview, -} from "./block.js"; -import { createRenderSource } from "./renderSource.js"; - -/** - * Gets the plain text content (i.e. the source) of a code block. - */ -function getCodeBlockText( - block: BlockFromConfig, -): string { + CodeBlockPreview, +} from "../../CodeBlockOptions.js"; +import { createSourceBlock } from "./createSourceBlock.js"; + +const getCodeBlockText = (block: BlockFromConfig): string => { const content = block.content; if (!Array.isArray(content)) { @@ -29,20 +24,14 @@ function getCodeBlockText( } return content.map((node) => ("text" in node ? node.text : "")).join(""); -} - -/** - * Creates a function that renders a preview of the code, showing the editable - * source in a popup below the preview (positioned via FloatingUI) while the - * block is selected. The popup reuses `renderSource` for its content. - */ -export function createRenderPreviewWithSourcePopup(options: CodeBlockOptions) { - const renderSource = createRenderSource(options); - - return ( - block: BlockFromConfig, +}; + +export const createPreviewWithSourcePopup = + (options: CodeBlockOptions) => + ( + block: BlockFromConfig, editor: BlockNoteEditor, - renderPreview: CodeBlockRenderPreview, + createPreview: CodeBlockPreview, ) => { const dom = document.createElement("div"); dom.className = "bn-code-block-with-preview"; @@ -53,12 +42,12 @@ export function createRenderPreviewWithSourcePopup(options: CodeBlockOptions) { previewContainer.contentEditable = "false"; dom.appendChild(previewContainer); - let preview = renderPreview(block, editor); + let preview = createPreview(block, editor); previewContainer.appendChild(preview.dom); // Holds the editable source, shown in a popup below the preview when the // block is selected. - const source = renderSource(block, editor); + const source = createSourceBlock(options)(block, editor); const sourcePopup = document.createElement("div"); sourcePopup.className = "bn-code-block-source-popup"; sourcePopup.style.display = "none"; @@ -154,7 +143,7 @@ export function createRenderPreviewWithSourcePopup(options: CodeBlockOptions) { preview.destroy?.(); previewContainer.innerHTML = ""; - preview = renderPreview( + preview = createPreview( editor.getBlock(block.id) as BlockFromConfig< CodeBlockConfig, any, @@ -179,4 +168,3 @@ export function createRenderPreviewWithSourcePopup(options: CodeBlockOptions) { }, }; }; -} diff --git a/packages/core/src/blocks/Code/renderSource.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts similarity index 69% rename from packages/core/src/blocks/Code/renderSource.ts rename to packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts index 4aa878c170..7765f141a6 100644 --- a/packages/core/src/blocks/Code/renderSource.ts +++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts @@ -1,18 +1,10 @@ -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import type { BlockFromConfig } from "../../schema/index.js"; -import type { CodeBlockConfig, CodeBlockOptions } from "./block.js"; +import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import type { BlockFromConfig } from "../../../../schema/index.js"; +import type { CodeBlockOptions } from "../../CodeBlockOptions.js"; -/** - * Creates a function that renders the editable source of a code block as a - * `
`, with a language selection dropdown. This is the default
- * rendering for languages that don't support previews, and is reused as the
- * source popup's content for languages that do.
- */
-export function createRenderSource(options: CodeBlockOptions) {
-  return (
-    block: BlockFromConfig,
-    editor: BlockNoteEditor,
-  ) => {
+export const createSourceBlock =
+  (options: CodeBlockOptions) =>
+  (block: BlockFromConfig, editor: BlockNoteEditor) => {
     const language = block.props.language || options.defaultLanguage || "text";
 
     const pre = document.createElement("pre");
@@ -61,4 +53,3 @@ export function createRenderSource(options: CodeBlockOptions) {
       },
     };
   };
-}
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..a90bd27a4b 100644
--- a/packages/core/src/blocks/index.ts
+++ b/packages/core/src/blocks/index.ts
@@ -16,6 +16,14 @@ 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/extensions/createPreviewSourceSelectionExtension.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 ee7b26ac43..02d910cfb4 100644
--- a/packages/core/src/editor/Block.css
+++ b/packages/core/src/editor/Block.css
@@ -471,6 +471,13 @@ applied to the source popup instead. */
   min-height: 1.5em;
   cursor: text;
 }
+/* Preview-source blocks (e.g. math) show their source in a popup, so they never
+get a native node selection. While selected they get this class instead, which
+we use to highlight the preview the same way `ProseMirror-selectednode` does. */
+.bn-preview-source-selected .bn-code-block-preview {
+  border-radius: 4px;
+  outline: 4px solid rgb(100, 160, 255);
+}
 .bn-code-block-source-popup {
   position: absolute;
   z-index: 1;
diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts
index e4888f50f6..06268e89d7 100644
--- a/packages/core/src/editor/BlockNoteEditor.ts
+++ b/packages/core/src/editor/BlockNoteEditor.ts
@@ -18,6 +18,7 @@ import {
   PartialBlock,
 } from "../blocks/index.js";
 import type { CollaborationOptions } from "../extensions/Collaboration/Collaboration.js";
+import type { SyntaxHighlightingOptions } from "../extensions/SyntaxHighlighting/SyntaxHighlighting.js";
 import {
   BlockChangeExtension,
   DropCursorOptions,
@@ -263,6 +264,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 7be7070865..ceaf18eb7c 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";
@@ -174,6 +175,7 @@ export function getDefaultExtensions(
     ShowSelectionExtension(options),
     SideMenuExtension(options),
     SuggestionMenu(options),
+    SyntaxHighlightingExtension(options.syntaxHighlighting),
     ...(options.trailingBlock !== false ? [TrailingNodeExtension()] : []),
   ] as ExtensionFactoryInstance[];
 
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..e3ce240467
--- /dev/null
+++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts
@@ -0,0 +1,36 @@
+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;
+
+  it("installs a highlight plugin when a highlighter is configured", () => {
+    const plugins = pluginsFor({ createHighlighter: async () => ({}) as any });
+
+    expect(plugins).toHaveLength(1);
+  });
+
+  it("installs no plugin when no highlighter is configured", () => {
+    expect(pluginsFor(undefined)).toHaveLength(0);
+    expect(pluginsFor({})).toHaveLength(0);
+  });
+});
diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts
new file mode 100644
index 0000000000..a4ce586c84
--- /dev/null
+++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts
@@ -0,0 +1,64 @@
+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: the plugin is only installed when a `createHighlighter`
+ * is configured.
+ */
+export const SyntaxHighlightingExtension = createExtension(
+  ({
+    editor,
+    options,
+  }: ExtensionOptions) => {
+    if (!options?.createHighlighter) {
+      return { key: "syntaxHighlighting", prosemirrorPlugins: [] };
+    }
+
+    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 210a95222c..f22735987b 100644
--- a/packages/core/src/extensions/index.ts
+++ b/packages/core/src/extensions/index.ts
@@ -20,5 +20,6 @@ export * from "./SuggestionMenu/getDefaultSlashMenuItems.js";
 export * from "./SuggestionMenu/getDefaultEmojiPickerItems.js";
 export * from "./SuggestionMenu/DefaultSuggestionItem.js";
 export * from "./SuggestionMenu/DefaultGridSuggestionItem.js";
+export * from "./SyntaxHighlighting/SyntaxHighlighting.js";
 export * from "./TableHandles/TableHandles.js";
 export * from "./TrailingNode/TrailingNode.js";
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 021b537b59..1c8608a99c 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -27,8 +27,8 @@ export * from "./util/typescript.js";
 
 export type {
   CodeBlockOptions,
-  CodeBlockRenderPreview,
-} from "./blocks/Code/block.js";
+  CodeBlockPreview,
+} from "./blocks/Code/CodeBlockOptions.js";
 export { assertEmpty, UnreachableCaseError } from "./util/typescript.js";
 
 export * from "./util/EventEmitter.js";
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..c0496f888d
--- /dev/null
+++ b/packages/math-block/package.json
@@ -0,0 +1,71 @@
+{
+  "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": {
+    "mathml-to-latex": "^1.8.0",
+    "prosemirror-model": "^1.25.4",
+    "prosemirror-state": "^1.4.4",
+    "temml": "^0.13.3"
+  },
+  "devDependencies": {
+    "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..dcd655adb9
--- /dev/null
+++ b/packages/math-block/src/block.test.ts
@@ -0,0 +1,345 @@
+import {
+  BlockNoteEditor,
+  BlockNoteSchema,
+  PREVIEW_SOURCE_SELECTED_CLASS,
+} from "@blocknote/core";
+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 no-content block (e.g. an image) as a NodeSelection. */
+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!)),
+  );
+}
+
+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 enters the math block's start",
+      (key) => {
+        editor.setTextCursorPosition("before", "end");
+
+        expect(pressKey(editor, key)).toBe(true);
+        expect(editor.getTextCursorPosition().block.type).toBe("math");
+        expect(editor.prosemirrorView.state.selection.$from.parentOffset).toBe(
+          0,
+        );
+      },
+    );
+
+    it.each(["ArrowLeft", "ArrowUp"])(
+      "%s at the start of the next block enters the math block's end",
+      (key) => {
+        editor.setTextCursorPosition("after", "start");
+
+        expect(pressKey(editor, key)).toBe(true);
+        expect(editor.getTextCursorPosition().block.type).toBe("math");
+        const { $from } = editor.prosemirrorView.state.selection;
+        expect($from.parentOffset).toBe($from.parent.content.size);
+      },
+    );
+
+    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("does not hijack navigation while already inside the math block", () => {
+      editor.setTextCursorPosition("math", "start");
+
+      expect(pressKey(editor, "ArrowRight")).toBe(false);
+      expect(editor.getTextCursorPosition().block.type).toBe("math");
+    });
+  });
+
+  describe("from a no-content block (image)", () => {
+    it("forward keys from a selected image before the math block enter it", () => {
+      setup([
+        { id: "img", type: "image" },
+        { id: "math", type: "math", content: "a^2" },
+      ]);
+      selectBlockNode(editor, "img");
+
+      expect(pressKey(editor, "ArrowRight")).toBe(true);
+      expect(editor.getTextCursorPosition().block.type).toBe("math");
+      expect(editor.prosemirrorView.state.selection.$from.parentOffset).toBe(0);
+    });
+
+    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 enter it", () => {
+      setup([
+        { id: "math", type: "math", content: "a^2" },
+        { id: "img", type: "image" },
+      ]);
+      selectBlockNode(editor, "img");
+
+      expect(pressKey(editor, "ArrowUp")).toBe(true);
+      expect(editor.getTextCursorPosition().block.type).toBe("math");
+      const { $from } = editor.prosemirrorView.state.selection;
+      expect($from.parentOffset).toBe($from.parent.content.size);
+    });
+  });
+
+  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 enter 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);
+      expect(editor.getTextCursorPosition().block.type).toBe("math");
+      expect(editor.prosemirrorView.state.selection.$from.parentOffset).toBe(0);
+    });
+
+    it("ArrowDown from a bottom-row, non-corner cell enters 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);
+      expect(editor.getTextCursorPosition().block.type).toBe("math");
+      expect(editor.prosemirrorView.state.selection.$from.parentOffset).toBe(0);
+    });
+
+    it("backward keys from the first cell enter 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);
+      expect(editor.getTextCursorPosition().block.type).toBe("math");
+      const { $from } = editor.prosemirrorView.state.selection;
+      expect($from.parentOffset).toBe($from.parent.content.size);
+    });
+  });
+
+  describe("selection decoration", () => {
+    /** The element carrying the "selected" class, if any. */
+    function selectedPreviewEl() {
+      return div.querySelector(`.${PREVIEW_SOURCE_SELECTED_CLASS}`);
+    }
+
+    beforeEach(() => {
+      setup([
+        { id: "before", type: "paragraph", content: "before" },
+        { id: "math", type: "math", content: "a^2" },
+      ]);
+    });
+
+    it("adds the class to the block while the selection is inside it", () => {
+      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("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("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..cf9f8ea1cc
--- /dev/null
+++ b/packages/math-block/src/block.ts
@@ -0,0 +1,39 @@
+import {
+  createBlockConfig,
+  createBlockSpec,
+  createPreviewSourceNavigationExtension,
+  createPreviewSourceSelectionExtension,
+  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,
+  {
+    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"),
+    createPreviewSourceSelectionExtension("math-block-selection", "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..80c970d5a7
--- /dev/null
+++ b/packages/math-block/src/helpers/parse/parseMathML.ts
@@ -0,0 +1,39 @@
+import { MathMLToLaTeX } from "mathml-to-latex";
+import type { Schema } from "prosemirror-model";
+
+/**
+ * Reads the LaTeX source out of a parsed `` (MathML) element. Prefers the
+ * original TeX when it's present as an annotation (as produced by our own
+ * export, and by temml/MathJax), and otherwise converts the MathML to LaTeX.
+ */
+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 "";
+  }
+};
+
+// The math block's HTML representation is a MathML `` element.
+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..f8dea737d6
--- /dev/null
+++ b/packages/math-block/src/helpers/render/createMathPreview.ts
@@ -0,0 +1,30 @@
+import type { CodeBlockPreview } from "@blocknote/core";
+import temml from "temml";
+// Renders the preview's MathML using local/system math fonts plus Temml's small
+// bundled symbol font - no large external font download required.
+import "temml/dist/Temml-Local.css";
+import { getMathSource } from "../getMathSource.js";
+
+/**
+ * Renders a preview of the block's LaTeX content as MathML using Temml, which
+ * the browser then displays natively.
+ *
+ * This is only responsible for the preview itself - the
+ * `createPreviewWithSourcePopup` render decides when & where it's shown.
+ */
+export const createMathPreview: CodeBlockPreview = (block) => {
+  const dom = document.createElement("div");
+  dom.className = "bn-latex-preview";
+
+  // `renderToString` + `innerHTML` rather than `temml.render` so it also works
+  // when serializing server-side (and in tests), where MathML elements don't
+  // support the DOM style manipulation `temml.render` relies on.
+  dom.innerHTML = temml.renderToString(getMathSource(block), {
+    // Renders invalid LaTeX as an error message instead of throwing, so the
+    // preview updates gracefully while the user is still typing.
+    throwOnError: false,
+    displayMode: true,
+  });
+
+  return { dom };
+};
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..3ef7813a42
--- /dev/null
+++ b/packages/math-block/src/helpers/toExternalHTML/createMathML.ts
@@ -0,0 +1,19 @@
+import type { BlockFromConfig } from "@blocknote/core";
+import temml from "temml";
+import { getMathSource } from "../getMathSource.js";
+
+export const createMathML = (block: BlockFromConfig) => {
+  // Convert the LaTeX source to a MathML `` element, annotating it with
+  // the original TeX so it round-trips losslessly back to LaTeX.
+  const mathml = temml.renderToString(getMathSource(block), {
+    displayMode: true,
+    annotate: true,
+    // Export gracefully renders invalid LaTeX rather than throwing.
+    throwOnError: false,
+  });
+
+  const wrapper = document.createElement("div");
+  wrapper.innerHTML = mathml;
+
+  return { dom: 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 301de57fb7..7fa784f756 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 06a07f2959..2eac199974 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -67,6 +67,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
@@ -3352,6 +3355,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':
@@ -4761,6 +4813,37 @@ 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
+      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
+      temml:
+        specifier: ^0.13.3
+        version: 0.13.3
+    devDependencies:
+      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':
@@ -10319,6 +10402,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==}
 
@@ -12604,6 +12691,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==}
 
@@ -14315,6 +14405,10 @@ packages:
     engines: {node: '>=10'}
     deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
 
+  temml@0.13.3:
+    resolution: {integrity: sha512-GLNEdf5qBWux3adbOxFus4jlds8nCdEIkkKq99m/4GGTfqnsjlVlK/i371Ux7yYSg/WNmOyAkNT/GJlZoJ0v+w==}
+    engines: {node: '>=18.13.0'}
+
   terser-webpack-plugin@5.5.0:
     resolution: {integrity: sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==}
     engines: {node: '>= 10.13.0'}
@@ -20229,6 +20323,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': {}
@@ -22628,6 +22724,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
@@ -25155,6 +25255,8 @@ snapshots:
       yallist: 4.0.0
     optional: true
 
+  temml@0.13.3: {}
+
   terser-webpack-plugin@5.5.0(esbuild@0.27.5)(webpack@5.105.4(esbuild@0.27.5)):
     dependencies:
       '@jridgewell/trace-mapping': 0.3.31

From ae49edc8307eed50a531fc6023ee70326976f15d Mon Sep 17 00:00:00 2001
From: Matthew Lipski 
Date: Tue, 16 Jun 2026 12:28:02 +0200
Subject: [PATCH 3/6] Small fix

---
 .../editor/managers/ExtensionManager/extensions.ts  |  5 ++++-
 .../SyntaxHighlighting/SyntaxHighlighting.test.ts   |  8 +++++---
 .../SyntaxHighlighting/SyntaxHighlighting.ts        | 13 +++----------
 3 files changed, 12 insertions(+), 14 deletions(-)

diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
index ceaf18eb7c..e1e7770946 100644
--- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts
+++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
@@ -175,10 +175,13 @@ export function getDefaultExtensions(
     ShowSelectionExtension(options),
     SideMenuExtension(options),
     SuggestionMenu(options),
-    SyntaxHighlightingExtension(options.syntaxHighlighting),
     ...(options.trailingBlock !== false ? [TrailingNodeExtension()] : []),
   ] as ExtensionFactoryInstance[];
 
+  if (options.syntaxHighlighting) {
+    extensions.push(SyntaxHighlightingExtension(options.syntaxHighlighting));
+  }
+
   if (options.collaboration) {
     extensions.push(CollaborationExtension(options.collaboration));
   } else {
diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts
index e3ce240467..36fd862b3a 100644
--- a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts
+++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts
@@ -23,14 +23,16 @@ describe("SyntaxHighlightingExtension", () => {
     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 no plugin when no highlighter is configured", () => {
-    expect(pluginsFor(undefined)).toHaveLength(0);
-    expect(pluginsFor({})).toHaveLength(0);
+  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
index a4ce586c84..fab55bcbec 100644
--- a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts
+++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts
@@ -36,18 +36,11 @@ export const defaultHighlightBlock = (block: Block) =>
  * 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: the plugin is only installed when a `createHighlighter`
- * is configured.
+ * Highlighting is opt-in: this extension is only instantiated when the
+ * `syntaxHighlighting` option is configured (see `getDefaultExtensions`).
  */
 export const SyntaxHighlightingExtension = createExtension(
-  ({
-    editor,
-    options,
-  }: ExtensionOptions) => {
-    if (!options?.createHighlighter) {
-      return { key: "syntaxHighlighting", prosemirrorPlugins: [] };
-    }
-
+  ({ editor, options }: ExtensionOptions) => {
     const highlightBlock = options.highlightBlock ?? defaultHighlightBlock;
 
     // Every block with inline (text) content is a candidate; `highlightBlock`

From 9687742a212923d6f39db3a94cf7b405117a5296 Mon Sep 17 00:00:00 2001
From: Matthew Lipski 
Date: Tue, 16 Jun 2026 18:26:19 +0200
Subject: [PATCH 4/6] Implemented minor CodeRabbit feedback

---
 packages/core/src/blocks/Code/CodeBlockOptions.ts        | 6 +++++-
 .../core/src/blocks/Code/helpers/parse/parsePreCode.ts   | 2 +-
 .../blocks/Code/helpers/render/createCodeBlockWrapper.ts | 9 +++++++--
 .../src/blocks/Code/helpers/render/createSourceBlock.ts  | 8 ++++++--
 packages/core/src/editor/Block.css                       | 5 -----
 5 files changed, 19 insertions(+), 11 deletions(-)

diff --git a/packages/core/src/blocks/Code/CodeBlockOptions.ts b/packages/core/src/blocks/Code/CodeBlockOptions.ts
index 12c3d3c88e..61910f1b46 100644
--- a/packages/core/src/blocks/Code/CodeBlockOptions.ts
+++ b/packages/core/src/blocks/Code/CodeBlockOptions.ts
@@ -73,9 +73,13 @@ export function getLanguageId(
   options: CodeBlockOptions,
   languageName: string,
 ): string | undefined {
+  const normalizedLanguage = languageName.trim().toLowerCase();
   return Object.entries(options.supportedLanguages ?? {}).find(
     ([id, { aliases }]) => {
-      return aliases?.includes(languageName) || id === languageName;
+      return (
+        id.toLowerCase() === normalizedLanguage ||
+        aliases?.some((alias) => alias.toLowerCase() === normalizedLanguage)
+      );
     },
   )?.[0];
 }
diff --git a/packages/core/src/blocks/Code/helpers/parse/parsePreCode.ts b/packages/core/src/blocks/Code/helpers/parse/parsePreCode.ts
index a3e8c224c2..237462fdb6 100644
--- a/packages/core/src/blocks/Code/helpers/parse/parsePreCode.ts
+++ b/packages/core/src/blocks/Code/helpers/parse/parsePreCode.ts
@@ -18,7 +18,7 @@ export const parsePreCode = (el: HTMLElement) => {
       code.getAttribute("data-language") ||
       code.className
         .split(" ")
-        .find((name) => name.includes("language-"))
+        .find((name) => name.startsWith("language-"))
         ?.replace("language-", "");
 
     return { language };
diff --git a/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts b/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts
index 6e44295895..627fce5b13 100644
--- a/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts
+++ b/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts
@@ -1,6 +1,9 @@
 import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
 import type { BlockFromConfig } from "../../../../schema/index.js";
-import type { CodeBlockOptions } from "../../CodeBlockOptions.js";
+import {
+  getLanguageId,
+  type CodeBlockOptions,
+} from "../../CodeBlockOptions.js";
 import { createPreviewWithSourcePopup } from "./createPreviewWithSourcePopup.js";
 import { createSourceBlock } from "./createSourceBlock.js";
 
@@ -8,7 +11,9 @@ export const createCodeBlockWrapper =
   (options: CodeBlockOptions) =>
   (block: BlockFromConfig, editor: BlockNoteEditor) => {
     const language = block.props.language || options.defaultLanguage || "text";
-    const renderPreview = options.supportedLanguages?.[language]?.createPreview;
+    const resolvedLanguage = getLanguageId(options, language) ?? language;
+    const renderPreview =
+      options.supportedLanguages?.[resolvedLanguage]?.createPreview;
 
     // Languages with a preview show the rendered result by default, with the
     // editable source in a popup when selected. Other languages just show the
diff --git a/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts
index 7765f141a6..4293c9c63d 100644
--- a/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts
+++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts
@@ -1,11 +1,15 @@
 import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
 import type { BlockFromConfig } from "../../../../schema/index.js";
-import type { CodeBlockOptions } from "../../CodeBlockOptions.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");
@@ -22,7 +26,7 @@ export const createSourceBlock =
         option.text = name;
         select.appendChild(option);
       });
-      select.value = language;
+      select.value = resolvedLanguage;
 
       if (editor.isEditable) {
         const handleLanguageChange = (event: Event) => {
diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css
index 02d910cfb4..2d83600fb5 100644
--- a/packages/core/src/editor/Block.css
+++ b/packages/core/src/editor/Block.css
@@ -481,9 +481,7 @@ we use to highlight the preview the same way `ProseMirror-selectednode` does. */
 .bn-code-block-source-popup {
   position: absolute;
   z-index: 1;
-
   min-width: 240px;
-
   background-color: rgb(22 22 22);
   color: white;
   border-radius: 8px;
@@ -498,10 +496,8 @@ we use to highlight the preview the same way `ProseMirror-selectednode` does. */
   border: none;
   cursor: pointer;
   background-color: transparent;
-
   font-size: 0.8em;
   color: white;
-
   padding: 8px 16px 0;
 }
 .bn-code-block-source-popup > div > select > option {
@@ -513,7 +509,6 @@ we use to highlight the preview the same way `ProseMirror-selectednode` does. */
   margin: 0;
   width: 100%;
   tab-size: 2;
-
   padding: 16px;
 }
 

From 9626d7e79a433fd5d57532a6a5b5607118eb69ee Mon Sep 17 00:00:00 2001
From: Matthew Lipski 
Date: Tue, 16 Jun 2026 18:31:35 +0200
Subject: [PATCH 5/6] Updated test snapshot

---
 tests/src/unit/core/schema/__snapshots__/blocks.json | 1 -
 1 file changed, 1 deletion(-)

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": {

From de7b1df1270faa2cb902dcb91b5f8a68ca5a1d7f Mon Sep 17 00:00:00 2001
From: Matthew Lipski 
Date: Thu, 18 Jun 2026 19:53:42 +0200
Subject: [PATCH 6/6] Big update to math block

---
 .../core/src/blocks/Code/CodeBlockOptions.ts  |   3 +
 .../createPreviewSourceNavigationExtension.ts | 400 +++++++-------
 .../createPreviewSourceSelectionExtension.ts  |  48 --
 .../helpers/render/createCodeBlockWrapper.ts  |   5 +-
 .../render/createPreviewWithSourcePopup.ts    |  90 ++-
 packages/core/src/blocks/index.ts             |   1 -
 packages/core/src/editor/Block.css            |  38 +-
 packages/math-block/package.json              |   6 +-
 packages/math-block/src/block.test.ts         | 512 ++++++++++++++++--
 packages/math-block/src/block.ts              |  11 +-
 .../src/helpers/parse/parseMathML.ts          |   6 -
 .../src/helpers/render/createMathPreview.ts   |  46 +-
 .../helpers/toExternalHTML/createMathML.ts    |  15 +-
 pnpm-lock.yaml                                |  23 +-
 14 files changed, 841 insertions(+), 363 deletions(-)
 delete mode 100644 packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceSelectionExtension.ts

diff --git a/packages/core/src/blocks/Code/CodeBlockOptions.ts b/packages/core/src/blocks/Code/CodeBlockOptions.ts
index 61910f1b46..a74766c961 100644
--- a/packages/core/src/blocks/Code/CodeBlockOptions.ts
+++ b/packages/core/src/blocks/Code/CodeBlockOptions.ts
@@ -16,6 +16,9 @@ export type CodeBlockPreview = (
   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;
 };
diff --git a/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceNavigationExtension.ts b/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceNavigationExtension.ts
index a91617347a..37e22df26f 100644
--- a/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceNavigationExtension.ts
+++ b/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceNavigationExtension.ts
@@ -1,29 +1,166 @@
-import { Plugin, PluginKey, Selection, TextSelection } from "prosemirror-state";
+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 {
-  getBlockInfo,
+  getNextBlockInfo,
+  getPrevBlockInfo,
+} from "../../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js";
+import {
+  type BlockInfo,
+  getBlockInfoFromResolvedPos,
   getBlockInfoFromSelection,
-  getNearestBlockPos,
+  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;
+    });
+  };
+
 /**
- * Blocks like the math block render their content as a preview and hide the
- * editable source unless the block is selected. Because the source has no
- * visible size while hidden, the browser (and so ProseMirror's default arrow
- * key handling) skips straight over the block when navigating from an adjacent
- * block - there's nowhere visible for the cursor to land.
- *
- * This extension restores that navigation: when an arrow key would move the
- * cursor across one of these blocks, we instead place the cursor inside its
- * (now revealed) source content.
- *
- * - Forward keys (ArrowRight/ArrowDown) from the end of the previous block move
- *   to the start of the block's content.
- * - Backward keys (ArrowLeft/ArrowUp) from the start of the next block move to
- *   the end of the block's content.
- *
- * It only ever moves *into* the block - leaving it works by default since the
- * source is visible while the block is selected.
+ * 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,
@@ -31,176 +168,57 @@ export const createPreviewSourceNavigationExtension = (
 ) =>
   createExtension({
     key,
-    prosemirrorPlugins: [
-      new Plugin({
-        key: new PluginKey(`${key}-plugin`),
-        props: {
-          handleKeyDown: (view, event) => {
-            const forward =
-              event.key === "ArrowRight" || event.key === "ArrowDown";
-            const backward =
-              event.key === "ArrowLeft" || event.key === "ArrowUp";
-            const vertical =
-              event.key === "ArrowUp" || event.key === "ArrowDown";
-
-            if (!forward && !backward) {
-              return false;
-            }
-
-            // Modifier-held arrows (selection extension, word jumps, etc.) and
-            // IME composition are left to their default behaviour.
-            if (
-              event.shiftKey ||
-              event.ctrlKey ||
-              event.metaKey ||
-              event.altKey ||
-              event.isComposing
-            ) {
-              return false;
-            }
-
-            const { state } = view;
-            const { selection, doc } = state;
-
-            // Only collapsed text cursors and node selections (e.g. images)
-            // can navigate into an adjacent block. Anything else (cell
-            // selections, ranged selections) is left to the default handler.
-            const isNodeSelection = "node" in selection;
-            if (!isNodeSelection && !selection.empty) {
-              return false;
-            }
-
-            // If we're already inside one of these blocks, leaving it is
-            // handled by the default behaviour - don't hijack it.
-            const currentBlock = getBlockInfoFromSelection(state);
-            if (
-              currentBlock.isBlockContainer &&
-              currentBlock.blockNoteType === blockType
-            ) {
-              return false;
-            }
-
-            // Moves the cursor into the block adjacent to the current one in
-            // the move direction - but only if it's one of the blocks this
-            // extension handles. Searching outwards from the block boundary
-            // (whose parent isn't inline content, so `findFrom` steps into the
-            // neighbour rather than returning the boundary unchanged) lands on
-            // the nearest selectable position: the neighbour's content start
-            // when moving forward, or its end when moving back. `textOnly` is
-            // false so leaf-node neighbours (e.g. images) are stopped at rather
-            // than skipped over. Returns whether it moved.
-            const moveIntoSibling = () => {
-              const boundaryPos = forward
-                ? currentBlock.bnBlock.afterPos
-                : currentBlock.bnBlock.beforePos;
-              const target = Selection.findFrom(
-                doc.resolve(boundaryPos),
-                forward ? 1 : -1,
-                false,
-              );
-
-              if (!target) {
-                return false;
-              }
-
-              const targetBlock = getBlockInfo(
-                getNearestBlockPos(doc, target.from),
-              );
-              if (
-                !targetBlock.isBlockContainer ||
-                targetBlock.blockNoteType !== blockType
-              ) {
-                return false;
-              }
-
-              view.dispatch(state.tr.setSelection(target).scrollIntoView());
-
-              return true;
-            };
-
-            // Determines whether the cursor sits at the very end (forward) or
-            // start (backward) of the current block. We search for the
-            // nearest text position from *outside* the block's boundary
-            // inwards - this avoids `findFrom`'s habit of returning the given
-            // position unchanged when it's already inside inline content, and
-            // naturally handles tables (the inner position is in the last /
-            // first cell).
-            const atBlockEdge = () => {
-              // A selected node (e.g. an image) has no inner cursor positions,
-              // so any arrow key exits it.
-              if (isNodeSelection) {
-                return true;
-              }
-
-              const innermost = Selection.findFrom(
-                doc.resolve(
-                  forward
-                    ? currentBlock.bnBlock.afterPos
-                    : currentBlock.bnBlock.beforePos,
-                ),
-                forward ? -1 : 1,
-                true,
-              );
-              if (!innermost) {
-                return false;
-              }
-
-              return forward
-                ? selection.$to.pos >= innermost.from
-                : selection.$from.pos <= innermost.from;
-            };
-
-            // Primary case: the cursor is at the edge of its block and the
-            // sibling block in the move direction is the target block. This
-            // covers inline blocks (paragraphs, headings), node-selected
-            // blocks (images), and the document-order edge of a table (its
-            // last / first cell).
-            if (atBlockEdge() && moveIntoSibling()) {
-              return true;
-            }
-
-            // Tables navigate cell-by-cell, so vertical keys from the bottom
-            // row (down) or top row (up) - other than at the document-order
-            // corner handled above - aren't caught by the search above. Detect
-            // that we're at the table's vertical edge and check the sibling
-            // block directly.
-            if (
-              vertical &&
-              currentBlock.isBlockContainer &&
-              currentBlock.blockNoteType === "table"
-            ) {
-              const { $head } = selection as TextSelection;
-
-              let rowDepth = $head.depth;
-              while (
-                rowDepth > 0 &&
-                $head.node(rowDepth).type.name !== "tableRow"
-              ) {
-                rowDepth--;
-              }
-
-              if (rowDepth > 0) {
-                const tableNode = $head.node(rowDepth - 1);
-                const rowIndex = $head.index(rowDepth - 1);
-                const atVerticalEdge = forward
-                  ? rowIndex === tableNode.childCount - 1
-                  : rowIndex === 0;
-
-                // Only exit when the cursor is on the last/first visual line of
-                // the cell, so multi-line cells still navigate internally.
-                if (
-                  atVerticalEdge &&
-                  view.endOfTextblock(forward ? "down" : "up") &&
-                  moveIntoSibling()
-                ) {
-                  return true;
-                }
-              }
-            }
-
+    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/extensions/createPreviewSourceSelectionExtension.ts b/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceSelectionExtension.ts
deleted file mode 100644
index 15a805079b..0000000000
--- a/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceSelectionExtension.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Plugin, PluginKey } from "prosemirror-state";
-import { Decoration, DecorationSet } from "prosemirror-view";
-import { getBlockInfoFromSelection } from "../../../../api/getBlockInfoFromPos.js";
-import { createExtension } from "../../../../editor/BlockNoteExtension.js";
-
-/**
- * The class added to a preview-source block (e.g. the math block) while the
- * selection is inside it. Because the source is shown in a popup rather than
- * inline, the block never gets a native node selection, so this gives CSS a
- * hook to highlight the preview (mimicking `ProseMirror-selectednode`).
- */
-export const PREVIEW_SOURCE_SELECTED_CLASS = "bn-preview-source-selected";
-
-/**
- * Adds {@link PREVIEW_SOURCE_SELECTED_CLASS} to the block's content node
- * whenever the selection sits inside it.
- */
-export const createPreviewSourceSelectionExtension = (
-  key: string,
-  blockType: string,
-) =>
-  createExtension({
-    key,
-    prosemirrorPlugins: [
-      new Plugin({
-        key: new PluginKey(`${key}-plugin`),
-        props: {
-          decorations: (state) => {
-            const blockInfo = getBlockInfoFromSelection(state);
-            if (
-              !blockInfo.isBlockContainer ||
-              blockInfo.blockNoteType !== blockType
-            ) {
-              return null;
-            }
-
-            return DecorationSet.create(state.doc, [
-              Decoration.node(
-                blockInfo.blockContent.beforePos,
-                blockInfo.blockContent.afterPos,
-                { class: PREVIEW_SOURCE_SELECTED_CLASS },
-              ),
-            ]);
-          },
-        },
-      }),
-    ],
-  });
diff --git a/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts b/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts
index 627fce5b13..7576dc0898 100644
--- a/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts
+++ b/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts
@@ -15,9 +15,8 @@ export const createCodeBlockWrapper =
     const renderPreview =
       options.supportedLanguages?.[resolvedLanguage]?.createPreview;
 
-    // Languages with a preview show the rendered result by default, with the
-    // editable source in a popup when selected. Other languages just show the
-    // source.
+    // 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
index 77c2f5b6fb..393bc5ff25 100644
--- a/packages/core/src/blocks/Code/helpers/render/createPreviewWithSourcePopup.ts
+++ b/packages/core/src/blocks/Code/helpers/render/createPreviewWithSourcePopup.ts
@@ -4,8 +4,10 @@ import {
   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";
@@ -36,7 +38,7 @@ export const createPreviewWithSourcePopup =
     const dom = document.createElement("div");
     dom.className = "bn-code-block-with-preview";
 
-    // Shows the rendered preview. Always visible & never editable.
+    // Shows the preview. Always visible & non-editable.
     const previewContainer = document.createElement("div");
     previewContainer.className = "bn-code-block-preview";
     previewContainer.contentEditable = "false";
@@ -45,21 +47,37 @@ export const createPreviewWithSourcePopup =
     let preview = createPreview(block, editor);
     previewContainer.appendChild(preview.dom);
 
-    // Holds the editable source, shown in a popup below the preview when the
-    // block is selected.
+    // 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);
 
-    // Tracks the current source so the preview is only re-rendered when the
-    // source actually changes (see `update` below).
+    // 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.
+    // 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") {
@@ -69,7 +87,24 @@ export const createPreviewWithSourcePopup =
       cleanupAutoUpdate = autoUpdate(previewContainer, sourcePopup, () => {
         computePosition(previewContainer, sourcePopup, {
           placement: "bottom-start",
-          middleware: [offset(4), flip(), shift({ padding: 4 })],
+          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`;
@@ -85,36 +120,54 @@ export const createPreviewWithSourcePopup =
       cleanupAutoUpdate = undefined;
     };
 
-    // Shows the source popup only while the block is selected.
-    const updateSourcePopupVisibility = () => {
+    // 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 && isSelected) {
+      if (editor.isEditable && isEditing) {
         showSourcePopup();
       } else {
         hideSourcePopup();
       }
+
+      dom
+        .closest(".bn-block-content")
+        ?.classList.toggle("ProseMirror-selectednode", isSelected);
     };
-    const removeSelectionChangeListener = editor.onSelectionChange(
-      updateSourcePopupVisibility,
-    );
-    updateSourcePopupVisibility();
+    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, which
-    // selects the block and reveals the popup via the selection listener.
+    // 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, "end");
+      editor.setTextCursorPosition(block.id, "start");
       editor.focus();
     };
     previewContainer.addEventListener("mousedown", handlePreviewMouseDown);
@@ -152,6 +205,7 @@ export const createPreviewWithSourcePopup =
             editor,
           );
           previewContainer.appendChild(preview.dom);
+          applyPreviewError(preview.error);
         }
 
         return true;
diff --git a/packages/core/src/blocks/index.ts b/packages/core/src/blocks/index.ts
index a90bd27a4b..459999c3ba 100644
--- a/packages/core/src/blocks/index.ts
+++ b/packages/core/src/blocks/index.ts
@@ -18,7 +18,6 @@ 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/extensions/createPreviewSourceSelectionExtension.js";
 export * from "./Code/helpers/parse/parsePreCode.js";
 export * from "./Code/helpers/render/createCodeBlockWrapper.js";
 export * from "./Code/helpers/render/createPreviewWithSourcePopup.js";
diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css
index 2d83600fb5..1804f9b6fd 100644
--- a/packages/core/src/editor/Block.css
+++ b/packages/core/src/editor/Block.css
@@ -454,39 +454,35 @@ NESTED BLOCKS
 }
 
 /* CODE BLOCK PREVIEW */
-/* Preview-supporting languages render the preview in place of the raw source,
-so the surrounding "code editor" styling is dropped from the block itself and
-applied to the source popup instead. */
 .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;
 }
-/* Preview-source blocks (e.g. math) show their source in a popup, so they never
-get a native node selection. While selected they get this class instead, which
-we use to highlight the preview the same way `ProseMirror-selectednode` does. */
-.bn-preview-source-selected .bn-code-block-preview {
-  border-radius: 4px;
-  outline: 4px solid rgb(100, 160, 255);
-}
+
 .bn-code-block-source-popup {
   position: absolute;
   z-index: 1;
-  min-width: 240px;
-  background-color: rgb(22 22 22);
-  color: white;
-  border-radius: 8px;
-  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
+  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 {
@@ -497,12 +493,14 @@ we use to highlight the preview the same way `ProseMirror-selectednode` does. */
   cursor: pointer;
   background-color: transparent;
   font-size: 0.8em;
-  color: white;
+  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;
@@ -512,6 +510,14 @@ we use to highlight the preview the same way `ProseMirror-selectednode` does. */
   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/math-block/package.json b/packages/math-block/package.json
index c0496f888d..398083e3c7 100644
--- a/packages/math-block/package.json
+++ b/packages/math-block/package.json
@@ -54,12 +54,14 @@
     "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",
-    "temml": "^0.13.3"
+    "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",
diff --git a/packages/math-block/src/block.test.ts b/packages/math-block/src/block.test.ts
index dcd655adb9..68eb2f769e 100644
--- a/packages/math-block/src/block.test.ts
+++ b/packages/math-block/src/block.test.ts
@@ -1,8 +1,11 @@
 import {
   BlockNoteEditor,
   BlockNoteSchema,
-  PREVIEW_SOURCE_SELECTED_CLASS,
+  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";
@@ -22,7 +25,7 @@ function pressKey(editor: BlockNoteEditor, key: string) {
   return view.someProp("handleKeyDown", (f) => f(view, event)) === true;
 }
 
-/** Selects a no-content block (e.g. an image) as a NodeSelection. */
+/** Selects a block's content node as a NodeSelection (e.g. an image, math). */
 function selectBlockNode(
   editor: BlockNoteEditor,
   blockId: string,
@@ -42,6 +45,13 @@ function selectBlockNode(
   );
 }
 
+/** 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");
@@ -70,30 +80,84 @@ describe("Math block keyboard navigation", () => {
     });
 
     it.each(["ArrowRight", "ArrowDown"])(
-      "%s at the end of the previous block enters the math block's start",
+      "%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);
-        expect(editor.getTextCursorPosition().block.type).toBe("math");
-        expect(editor.prosemirrorView.state.selection.$from.parentOffset).toBe(
-          0,
-        );
+        expectMathNodeSelected(editor);
       },
     );
 
     it.each(["ArrowLeft", "ArrowUp"])(
-      "%s at the start of the next block enters the math block's end",
+      "%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);
-        expect(editor.getTextCursorPosition().block.type).toBe("math");
-        const { $from } = editor.prosemirrorView.state.selection;
-        expect($from.parentOffset).toBe($from.parent.content.size);
+        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");
 
@@ -101,7 +165,83 @@ describe("Math block keyboard navigation", () => {
       expect(editor.getTextCursorPosition().block.type).toBe("paragraph");
     });
 
-    it("does not hijack navigation while already inside the math block", () => {
+    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);
@@ -109,8 +249,78 @@ describe("Math block keyboard navigation", () => {
     });
   });
 
+  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 enter it", () => {
+    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" },
@@ -118,8 +328,7 @@ describe("Math block keyboard navigation", () => {
       selectBlockNode(editor, "img");
 
       expect(pressKey(editor, "ArrowRight")).toBe(true);
-      expect(editor.getTextCursorPosition().block.type).toBe("math");
-      expect(editor.prosemirrorView.state.selection.$from.parentOffset).toBe(0);
+      expectMathNodeSelected(editor);
     });
 
     it("does not skip a no-content block to reach a math block beyond it", () => {
@@ -137,7 +346,7 @@ describe("Math block keyboard navigation", () => {
       expect(editor.getTextCursorPosition().block.type).not.toBe("math");
     });
 
-    it("backward keys from a selected image after the math block enter it", () => {
+    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" },
@@ -145,9 +354,7 @@ describe("Math block keyboard navigation", () => {
       selectBlockNode(editor, "img");
 
       expect(pressKey(editor, "ArrowUp")).toBe(true);
-      expect(editor.getTextCursorPosition().block.type).toBe("math");
-      const { $from } = editor.prosemirrorView.state.selection;
-      expect($from.parentOffset).toBe($from.parent.content.size);
+      expectMathNodeSelected(editor);
     });
   });
 
@@ -161,7 +368,7 @@ describe("Math block keyboard navigation", () => {
       };
     }
 
-    it("forward keys from the last cell enter the following math block", () => {
+    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" },
@@ -182,11 +389,10 @@ describe("Math block keyboard navigation", () => {
       );
 
       expect(pressKey(editor, "ArrowRight")).toBe(true);
-      expect(editor.getTextCursorPosition().block.type).toBe("math");
-      expect(editor.prosemirrorView.state.selection.$from.parentOffset).toBe(0);
+      expectMathNodeSelected(editor);
     });
 
-    it("ArrowDown from a bottom-row, non-corner cell enters the following math block", () => {
+    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" },
@@ -212,11 +418,10 @@ describe("Math block keyboard navigation", () => {
       view.endOfTextblock = () => true;
 
       expect(pressKey(editor, "ArrowDown")).toBe(true);
-      expect(editor.getTextCursorPosition().block.type).toBe("math");
-      expect(editor.prosemirrorView.state.selection.$from.parentOffset).toBe(0);
+      expectMathNodeSelected(editor);
     });
 
-    it("backward keys from the first cell enter the preceding math block", () => {
+    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) },
@@ -239,16 +444,66 @@ describe("Math block keyboard navigation", () => {
       );
 
       expect(pressKey(editor, "ArrowLeft")).toBe(true);
-      expect(editor.getTextCursorPosition().block.type).toBe("math");
-      const { $from } = editor.prosemirrorView.state.selection;
-      expect($from.parentOffset).toBe($from.parent.content.size);
+      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 "selected" class, if any. */
+    /** The element carrying the standard "selected node" class, if any. */
     function selectedPreviewEl() {
-      return div.querySelector(`.${PREVIEW_SOURCE_SELECTED_CLASS}`);
+      return div.querySelector(".ProseMirror-selectednode");
     }
 
     beforeEach(() => {
@@ -258,7 +513,7 @@ describe("Math block keyboard navigation", () => {
       ]);
     });
 
-    it("adds the class to the block while the selection is inside it", () => {
+    it("adds the class to the block while editing its content", () => {
       editor.setTextCursorPosition("math", "start");
 
       const el = selectedPreviewEl();
@@ -267,6 +522,17 @@ describe("Math block keyboard navigation", () => {
       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");
 
@@ -281,6 +547,186 @@ describe("Math block keyboard navigation", () => {
       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", () => {
@@ -313,7 +759,7 @@ describe("Math block MathML interchange", () => {
         { type: "math", content: "a^2 + b^2 = c^2" } as any,
       ]),
     ).toMatchInlineSnapshot(
-      `"a2+b2=c2a^2 + b^2 = c^2"`,
+      `"a2+b2=c2a^2 + b^2 = c^2"`,
     );
   });
 
diff --git a/packages/math-block/src/block.ts b/packages/math-block/src/block.ts
index cf9f8ea1cc..ba937729cc 100644
--- a/packages/math-block/src/block.ts
+++ b/packages/math-block/src/block.ts
@@ -2,7 +2,6 @@ import {
   createBlockConfig,
   createBlockSpec,
   createPreviewSourceNavigationExtension,
-  createPreviewSourceSelectionExtension,
   createPreviewWithSourcePopup,
 } from "@blocknote/core";
 import {
@@ -26,14 +25,16 @@ export const createMathBlockConfig = createBlockConfig(
 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"),
-    createPreviewSourceSelectionExtension("math-block-selection", "math"),
-  ],
+  [createPreviewSourceNavigationExtension("math-block-navigation", "math")],
 );
diff --git a/packages/math-block/src/helpers/parse/parseMathML.ts b/packages/math-block/src/helpers/parse/parseMathML.ts
index 80c970d5a7..d9d35dfafe 100644
--- a/packages/math-block/src/helpers/parse/parseMathML.ts
+++ b/packages/math-block/src/helpers/parse/parseMathML.ts
@@ -1,11 +1,6 @@
 import { MathMLToLaTeX } from "mathml-to-latex";
 import type { Schema } from "prosemirror-model";
 
-/**
- * Reads the LaTeX source out of a parsed `` (MathML) element. Prefers the
- * original TeX when it's present as an annotation (as produced by our own
- * export, and by temml/MathJax), and otherwise converts the MathML to LaTeX.
- */
 const mathMLElementToLaTeX = (el: HTMLElement): string => {
   const annotations = Array.from(el.getElementsByTagName("annotation"));
   const texAnnotation = annotations.find(
@@ -22,7 +17,6 @@ const mathMLElementToLaTeX = (el: HTMLElement): string => {
   }
 };
 
-// The math block's HTML representation is a MathML `` element.
 export const parseMathML = (el: HTMLElement) =>
   el.nodeName.toLowerCase() === "math" ? {} : undefined;
 
diff --git a/packages/math-block/src/helpers/render/createMathPreview.ts b/packages/math-block/src/helpers/render/createMathPreview.ts
index f8dea737d6..54b728f8ed 100644
--- a/packages/math-block/src/helpers/render/createMathPreview.ts
+++ b/packages/math-block/src/helpers/render/createMathPreview.ts
@@ -1,30 +1,30 @@
 import type { CodeBlockPreview } from "@blocknote/core";
-import temml from "temml";
-// Renders the preview's MathML using local/system math fonts plus Temml's small
-// bundled symbol font - no large external font download required.
-import "temml/dist/Temml-Local.css";
+import katex from "katex";
+import "katex/dist/katex.min.css";
 import { getMathSource } from "../getMathSource.js";
 
-/**
- * Renders a preview of the block's LaTeX content as MathML using Temml, which
- * the browser then displays natively.
- *
- * This is only responsible for the preview itself - the
- * `createPreviewWithSourcePopup` render decides when & where it's shown.
- */
 export const createMathPreview: CodeBlockPreview = (block) => {
-  const dom = document.createElement("div");
-  dom.className = "bn-latex-preview";
+  const source = getMathSource(block);
 
-  // `renderToString` + `innerHTML` rather than `temml.render` so it also works
-  // when serializing server-side (and in tests), where MathML elements don't
-  // support the DOM style manipulation `temml.render` relies on.
-  dom.innerHTML = temml.renderToString(getMathSource(block), {
-    // Renders invalid LaTeX as an error message instead of throwing, so the
-    // preview updates gracefully while the user is still typing.
-    throwOnError: false,
-    displayMode: true,
-  });
+  // 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,
+    });
+  }
 
-  return { dom };
+  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
index 3ef7813a42..23b13de1ab 100644
--- a/packages/math-block/src/helpers/toExternalHTML/createMathML.ts
+++ b/packages/math-block/src/helpers/toExternalHTML/createMathML.ts
@@ -1,19 +1,20 @@
 import type { BlockFromConfig } from "@blocknote/core";
-import temml from "temml";
+import katex from "katex";
 import { getMathSource } from "../getMathSource.js";
 
 export const createMathML = (block: BlockFromConfig) => {
-  // Convert the LaTeX source to a MathML `` element, annotating it with
-  // the original TeX so it round-trips losslessly back to LaTeX.
-  const mathml = temml.renderToString(getMathSource(block), {
+  const mathml = katex.renderToString(getMathSource(block), {
     displayMode: true,
-    annotate: true,
-    // Export gracefully renders invalid LaTeX rather than throwing.
+    output: "mathml",
     throwOnError: false,
   });
 
   const wrapper = document.createElement("div");
   wrapper.innerHTML = mathml;
 
-  return { dom: wrapper.firstElementChild as HTMLElement };
+  // 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/pnpm-lock.yaml b/pnpm-lock.yaml
index 2eac199974..0d08fa8ee2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4818,6 +4818,9 @@ importers:
       '@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
@@ -4827,10 +4830,13 @@ importers:
       prosemirror-state:
         specifier: ^1.4.4
         version: 1.4.4
-      temml:
-        specifier: ^0.13.3
-        version: 0.13.3
     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
@@ -14405,10 +14411,6 @@ packages:
     engines: {node: '>=10'}
     deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
 
-  temml@0.13.3:
-    resolution: {integrity: sha512-GLNEdf5qBWux3adbOxFus4jlds8nCdEIkkKq99m/4GGTfqnsjlVlK/i371Ux7yYSg/WNmOyAkNT/GJlZoJ0v+w==}
-    engines: {node: '>=18.13.0'}
-
   terser-webpack-plugin@5.5.0:
     resolution: {integrity: sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==}
     engines: {node: '>= 10.13.0'}
@@ -19931,6 +19933,7 @@ snapshots:
     optionalDependencies:
       msw: 2.11.5(@types/node@25.5.0)(typescript@5.9.3)
       vite: 8.0.8(@types/node@25.5.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)
+    optional: true
 
   '@vitest/pretty-format@4.1.5':
     dependencies:
@@ -19959,7 +19962,7 @@ snapshots:
       sirv: 3.0.2
       tinyglobby: 0.2.16
       tinyrainbow: 3.1.0
-      vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(@vitest/ui@4.1.5)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(msw@2.11.5(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0))
+      vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@20.19.37)(@vitest/ui@4.1.5)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(msw@2.11.5(@types/node@20.19.37)(typescript@5.9.3))(vite@8.0.8(@types/node@20.19.37)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0))
 
   '@vitest/utils@4.1.5':
     dependencies:
@@ -25255,8 +25258,6 @@ snapshots:
       yallist: 4.0.0
     optional: true
 
-  temml@0.13.3: {}
-
   terser-webpack-plugin@5.5.0(esbuild@0.27.5)(webpack@5.105.4(esbuild@0.27.5)):
     dependencies:
       '@jridgewell/trace-mapping': 0.3.31
@@ -25945,6 +25946,7 @@ snapshots:
       jiti: 2.6.1
       terser: 5.46.2
       tsx: 4.21.0
+    optional: true
 
   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):
     dependencies:
@@ -26038,6 +26040,7 @@ snapshots:
       jsdom: 29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0)
     transitivePeerDependencies:
       - msw
+    optional: true
 
   w3c-keyname@2.2.8: {}