From e3f182a66ec12dab29009a5b07c0e55d3d482d61 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 9 Jun 2026 20:38:07 +0200 Subject: [PATCH 01/21] 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 02/21] 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 03/21] 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 04/21] 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 05/21] 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 06/21] 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: {}
 

From ed8c681f9a76820155773c7e32068dc009b65174 Mon Sep 17 00:00:00 2001
From: Matthew Lipski 
Date: Tue, 23 Jun 2026 13:30:24 +0200
Subject: [PATCH 07/21] Major changes

---
 .../09-math-block/.bnexample.json             |  11 +-
 .../09-math-block/package.json                |   3 +-
 .../09-math-block/src/App.tsx                 |  68 +-
 .../09-math-block/vite-env.d.ts               |   1 +
 .../core/src/blocks/Code/CodeBlockOptions.ts  |   8 +-
 packages/core/src/blocks/Code/block.ts        |  14 +-
 .../createPreviewSourceNavigationExtension.ts | 224 -----
 .../helpers/render/createCodeBlockWrapper.ts  |  23 -
 .../render/createPreviewWithSourcePopup.ts    | 224 -----
 .../Code/helpers/render/createSourceBlock.ts  | 139 +--
 .../render/createSourceBlockWithPreview.ts    | 398 +++++++++
 packages/core/src/blocks/index.ts             |   4 +-
 packages/core/src/editor/Block.css            |  59 +-
 packages/core/src/i18n/locales/ar.ts          |   3 +
 packages/core/src/i18n/locales/de.ts          |   3 +
 packages/core/src/i18n/locales/en.ts          |   3 +
 packages/core/src/i18n/locales/es.ts          |   3 +
 packages/core/src/i18n/locales/fa.ts          |   3 +
 packages/core/src/i18n/locales/fr.ts          |   3 +
 packages/core/src/i18n/locales/he.ts          |   3 +
 packages/core/src/i18n/locales/hr.ts          |   3 +
 packages/core/src/i18n/locales/is.ts          |   3 +
 packages/core/src/i18n/locales/it.ts          |   3 +
 packages/core/src/i18n/locales/ja.ts          |   3 +
 packages/core/src/i18n/locales/ko.ts          |   3 +
 packages/core/src/i18n/locales/nl.ts          |   3 +
 packages/core/src/i18n/locales/no.ts          |   3 +
 packages/core/src/i18n/locales/pl.ts          |   3 +
 packages/core/src/i18n/locales/pt.ts          |   3 +
 packages/core/src/i18n/locales/ru.ts          |   3 +
 packages/core/src/i18n/locales/sk.ts          |   3 +
 packages/core/src/i18n/locales/uk.ts          |   3 +
 packages/core/src/i18n/locales/uz.ts          |   3 +
 packages/core/src/i18n/locales/vi.ts          |   3 +
 packages/core/src/i18n/locales/zh-tw.ts       |   3 +
 packages/core/src/i18n/locales/zh.ts          |   3 +
 packages/math-block/src/block.test.ts         | 816 +++---------------
 packages/math-block/src/block.ts              |  31 +-
 playground/src/examples.gen.tsx               |   9 +-
 playground/vite.config.ts                     |   1 +
 pnpm-lock.yaml                                |   8 +-
 41 files changed, 849 insertions(+), 1261 deletions(-)
 create mode 100644 examples/06-custom-schema/09-math-block/vite-env.d.ts
 delete mode 100644 packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceNavigationExtension.ts
 delete mode 100644 packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts
 delete mode 100644 packages/core/src/blocks/Code/helpers/render/createPreviewWithSourcePopup.ts
 create mode 100644 packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts

diff --git a/examples/06-custom-schema/09-math-block/.bnexample.json b/examples/06-custom-schema/09-math-block/.bnexample.json
index e5aa3d0d6c..d7b46b399c 100644
--- a/examples/06-custom-schema/09-math-block/.bnexample.json
+++ b/examples/06-custom-schema/09-math-block/.bnexample.json
@@ -2,9 +2,16 @@
   "playground": true,
   "docs": true,
   "author": "matthewlipski",
-  "tags": ["Intermediate", "Blocks", "Custom Schemas"],
+  "tags": [
+    "Intermediate",
+    "Blocks",
+    "Custom Schemas",
+    "Suggestion Menus",
+    "Slash Menu"
+  ],
   "dependencies": {
     "@blocknote/code-block": "latest",
-    "@blocknote/math-block": "latest"
+    "@blocknote/math-block": "latest",
+    "react-icons": "^5.5.0"
   }
 }
diff --git a/examples/06-custom-schema/09-math-block/package.json b/examples/06-custom-schema/09-math-block/package.json
index 4c28818956..6ecaf4db94 100644
--- a/examples/06-custom-schema/09-math-block/package.json
+++ b/examples/06-custom-schema/09-math-block/package.json
@@ -21,7 +21,8 @@
     "react": "^19.2.3",
     "react-dom": "^19.2.3",
     "@blocknote/code-block": "latest",
-    "@blocknote/math-block": "latest"
+    "@blocknote/math-block": "latest",
+    "react-icons": "^5.5.0"
   },
   "devDependencies": {
     "@types/react": "^19.2.3",
diff --git a/examples/06-custom-schema/09-math-block/src/App.tsx b/examples/06-custom-schema/09-math-block/src/App.tsx
index 8e980b4217..fa7b806954 100644
--- a/examples/06-custom-schema/09-math-block/src/App.tsx
+++ b/examples/06-custom-schema/09-math-block/src/App.tsx
@@ -1,27 +1,52 @@
 import "@blocknote/core/fonts/inter.css";
 import { BlockNoteSchema } from "@blocknote/core";
+import {
+  filterSuggestionItems,
+  insertOrUpdateBlockForSlashMenu,
+} from "@blocknote/core/extensions";
 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";
+import {
+  getDefaultReactSlashMenuItems,
+  SuggestionMenuController,
+  useCreateBlockNote,
+} from "@blocknote/react";
+import { TbMathFunction } from "react-icons/tb";
+
+// Our schema with block specs, which contain the configs and implementations for blocks
+// that we want our editor to use.
+const schema = BlockNoteSchema.create().extend({
+  blockSpecs: {
+    // Creates an instance of the Math block and adds it to the schema.
+    math: createMathBlockSpec(),
+  },
+});
+
+// Slash menu item to insert a Math block.
+const insertMath = (editor: typeof schema.BlockNoteEditor) => ({
+  title: "Math",
+  subtext: "Insert a LaTeX math formula",
+  onItemClick: () =>
+    insertOrUpdateBlockForSlashMenu(editor, {
+      type: "math",
+    }),
+  aliases: ["math", "latex", "formula", "equation"],
+  group: "Basic blocks",
+  icon: ,
+});
 
 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.
+    // Configures the syntax highlighting extension to always use LaTeX syntax highlighting in the
+    // Math block.
     syntaxHighlighting: {
       createHighlighter,
       highlightBlock: (block) =>
         block.type === "math" ? "latex" : block.props.language,
     },
-    schema: BlockNoteSchema.create().extend({
-      blockSpecs: {
-        math: createMathBlockSpec(),
-      },
-    }),
+    schema,
     initialContent: [
       {
         type: "paragraph",
@@ -37,10 +62,31 @@ export default function App() {
       },
       {
         type: "paragraph",
+        content: "Press the '/' key to open the Slash Menu and add another",
       },
     ],
   });
 
   // Renders the editor instance using a React component.
-  return ;
+  return (
+    
+      {/* Replaces the default Slash Menu. */}
+       {
+          // Gets all default slash menu items.
+          const defaultItems = getDefaultReactSlashMenuItems(editor);
+          // Finds index of last item in "Basic blocks" group.
+          const lastBasicBlockIndex = defaultItems.findLastIndex(
+            (item) => item.group === "Basic blocks",
+          );
+          // Inserts the Math item as the last item in the "Basic blocks" group.
+          defaultItems.splice(lastBasicBlockIndex + 1, 0, insertMath(editor));
+
+          // Returns filtered items based on the query.
+          return filterSuggestionItems(defaultItems, query);
+        }}
+      />
+    
+  );
 }
diff --git a/examples/06-custom-schema/09-math-block/vite-env.d.ts b/examples/06-custom-schema/09-math-block/vite-env.d.ts
new file mode 100644
index 0000000000..bc2d8a36f3
--- /dev/null
+++ b/examples/06-custom-schema/09-math-block/vite-env.d.ts
@@ -0,0 +1 @@
+/// 
diff --git a/packages/core/src/blocks/Code/CodeBlockOptions.ts b/packages/core/src/blocks/Code/CodeBlockOptions.ts
index a74766c961..946a55dba4 100644
--- a/packages/core/src/blocks/Code/CodeBlockOptions.ts
+++ b/packages/core/src/blocks/Code/CodeBlockOptions.ts
@@ -1,4 +1,4 @@
-import type { ViewMutationRecord } from "prosemirror-view";
+// import type { ViewMutationRecord } from "prosemirror-view";
 import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
 import type { BlockFromConfig } from "../../schema/index.js";
 
@@ -15,12 +15,8 @@ export type CodeBlockPreview = (
   block: BlockFromConfig,
   editor: BlockNoteEditor,
 ) => {
-  dom: HTMLElement;
-  // TODO: This is for showing any syntax errors found while rendering the preview, not sure if it
-  // should be here.
+  dom: HTMLElement | DocumentFragment;
   error?: string | null;
-  ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
-  destroy?: () => void;
 };
 
 export type CodeBlockOptions = {
diff --git a/packages/core/src/blocks/Code/block.ts b/packages/core/src/blocks/Code/block.ts
index ae13c70ce6..eab9886c6d 100644
--- a/packages/core/src/blocks/Code/block.ts
+++ b/packages/core/src/blocks/Code/block.ts
@@ -3,10 +3,10 @@ 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";
+import { createSourceBlockWithPreview } from "./helpers/render/createSourceBlockWithPreview.js";
 
 export type CodeBlockConfig = ReturnType;
 
@@ -33,7 +33,17 @@ export const createCodeBlockSpec = createBlockSpec(
     },
     parse: (el) => parsePreCode(el),
     parseContent: (opts) => parsePreCodeContent(opts, "codeBlock"),
-    render: (block, editor) => createCodeBlockWrapper(options)(block, editor),
+    render: (block, editor) =>
+      createSourceBlockWithPreview(
+        block,
+        editor,
+        options.supportedLanguages && {
+          selectedLanguage: block.props.language,
+          supportedLanguages: options.supportedLanguages,
+          createPreview:
+            options.supportedLanguages[block.props.language].createPreview,
+        },
+      ),
     toExternalHTML: (block) => createPreCode(block),
   }),
   (options) => {
diff --git a/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceNavigationExtension.ts b/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceNavigationExtension.ts
deleted file mode 100644
index 37e22df26f..0000000000
--- a/packages/core/src/blocks/Code/helpers/extensions/createPreviewSourceNavigationExtension.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-import type { Node } from "prosemirror-model";
-import { NodeSelection, Selection, TextSelection } from "prosemirror-state";
-import { cellAround, nextCell } from "prosemirror-tables";
-import type { EditorView } from "prosemirror-view";
-import {
-  getNextBlockInfo,
-  getPrevBlockInfo,
-} from "../../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js";
-import {
-  type BlockInfo,
-  getBlockInfoFromResolvedPos,
-  getBlockInfoFromSelection,
-  getBlockInfoFromTransaction,
-} from "../../../../api/getBlockInfoFromPos.js";
-import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
-import { createExtension } from "../../../../editor/BlockNoteExtension.js";
-
-type Direction = "left" | "right" | "up" | "down";
-
-// Checks whether moving the text cursor in a given direction should move it out of the block.
-const endOfBlock = (view: EditorView, direction: Direction): boolean => {
-  const { selection } = view.state;
-
-  const blockInfo = getBlockInfoFromSelection(view.state);
-
-  // Always moves selection to previous/next block when whole block is selected.
-  if (
-    selection instanceof NodeSelection &&
-    selection.node.type.spec.group === "blockContent"
-  ) {
-    return true;
-  }
-
-  // Left/right arrows always collapse selection to it's start/end (default behaviour) - never move
-  // selection out of the block.
-  if (!selection.empty && (direction === "left" || direction === "right")) {
-    return false;
-  }
-
-  // Navigating within text content never moves the selection outside the block.
-  if (!view.endOfTextblock(direction)) {
-    return false;
-  }
-
-  // If there is a cell to move into for the given direction, the selection moves into it.
-  // Otherwise, it moves out of the block.
-  if (blockInfo.isBlockContainer && blockInfo.blockNoteType === "table") {
-    const cell = cellAround(selection.$head);
-
-    if (
-      !cell ||
-      nextCell(
-        cell,
-        direction === "up" || direction === "down" ? "vert" : "horiz",
-        direction === "down" || direction === "right" ? 1 : -1,
-      )
-    ) {
-      return false;
-    }
-  }
-
-  return true;
-};
-
-// Gets the block info of the first or last `blockContainer` in a `column`/`columnList`.
-const getEdgeBlockContainerInfo = (
-  doc: Node,
-  blockInfo: BlockInfo,
-  forward: boolean,
-): BlockInfo => {
-  while (!blockInfo.isBlockContainer) {
-    const group = blockInfo.childContainer.node;
-    const childPos = doc
-      .resolve(blockInfo.childContainer.beforePos + 1)
-      .posAtIndex(forward ? 0 : group.childCount - 1);
-    blockInfo = getBlockInfoFromResolvedPos(doc.resolve(childPos));
-  }
-
-  return blockInfo;
-};
-
-// Handles arrow key presses.
-const createArrowHandler =
-  (blockType: string, direction: Direction) =>
-  ({ editor }: { editor: BlockNoteEditor }) => {
-    const view = editor.prosemirrorView;
-
-    return editor.transact((tr) => {
-      if (!endOfBlock(view, direction)) {
-        return false;
-      }
-
-      const forward = direction === "right" || direction === "down";
-      const vertical = direction === "up" || direction === "down";
-
-      const blockInfo = getBlockInfoFromTransaction(tr);
-      if (!blockInfo.isBlockContainer) {
-        return false;
-      }
-
-      let adjacentBlockInfo = forward
-        ? getNextBlockInfo(tr.doc, blockInfo.bnBlock.beforePos)
-        : getPrevBlockInfo(tr.doc, blockInfo.bnBlock.beforePos);
-
-      if (adjacentBlockInfo && !adjacentBlockInfo.isBlockContainer) {
-        // Edge case for when the adjacent block is a `column`/`columnList` - use the first or last
-        // `blockContainer` in it depending on direction.
-        adjacentBlockInfo = getEdgeBlockContainerInfo(
-          tr.doc,
-          adjacentBlockInfo,
-          forward,
-        );
-      }
-
-      // Use default handling when no adjacent block exists.
-      if (!adjacentBlockInfo || !adjacentBlockInfo.isBlockContainer) {
-        return false;
-      }
-
-      // Navigating onto a preview-source block selects the whole node.
-      if (adjacentBlockInfo.blockNoteType === blockType) {
-        tr.setSelection(
-          NodeSelection.create(
-            tr.doc,
-            adjacentBlockInfo.blockContent.beforePos,
-          ),
-        ).scrollIntoView();
-
-        return true;
-      }
-
-      // Leaving a preview-source block via a vertical arrow emulates the behavior of a horizontal
-      // arrow press at the block's boundary. This is because vertical arrow presses move selection
-      // based on DOM layout, which causes slightly weird UX when done from the source popup.
-      if (vertical && blockInfo.blockNoteType === blockType) {
-        const target = Selection.findFrom(
-          tr.doc.resolve(
-            forward
-              ? adjacentBlockInfo.bnBlock.beforePos
-              : adjacentBlockInfo.bnBlock.afterPos,
-          ),
-          forward ? 1 : -1,
-          false,
-        );
-
-        if (target) {
-          tr.setSelection(target).scrollIntoView();
-
-          return true;
-        }
-      }
-
-      return false;
-    });
-  };
-
-/**
- * This extension is necessary for graceful keyboard navigation around blocks which use
- * `createPreviewWithSourcePopup` to render their content. It's important to have this context as
- * the source code popup with the block's inline content only becomes visible when the selection is
- * moved somewhere into this inline content. This means we cannot rely of default keyboard
- * navigation as while the block has content, that content is hidden while the selection is outside
- * of it, so the default handling skips it.
- */
-export const createPreviewSourceNavigationExtension = (
-  key: string,
-  blockType: string,
-) =>
-  createExtension({
-    key,
-    keyboardShortcuts: {
-      // Toggles between opening and closing the source code popup by setting the selection on the
-      // whole block content node (hiding popup) or at the start of the inline content node
-      // (showing popup).
-      Enter: ({ editor }) =>
-        editor.transact((tr) => {
-          const blockInfo = getBlockInfoFromTransaction(tr);
-          if (
-            !blockInfo.isBlockContainer ||
-            blockInfo.blockNoteType !== blockType
-          ) {
-            return false;
-          }
-
-          if (tr.selection instanceof NodeSelection) {
-            tr.setSelection(
-              TextSelection.create(
-                tr.doc,
-                blockInfo.blockContent.beforePos + 1,
-              ),
-            );
-          } else {
-            tr.setSelection(
-              NodeSelection.create(tr.doc, blockInfo.blockContent.beforePos),
-            );
-          }
-
-          return true;
-        }),
-      // Closes the source code popup by setting the selection on the whole block content node.
-      Escape: ({ editor }) =>
-        editor.transact((tr) => {
-          const blockInfo = getBlockInfoFromTransaction(tr);
-
-          if (
-            !blockInfo.isBlockContainer ||
-            blockInfo.blockNoteType !== blockType ||
-            tr.selection instanceof NodeSelection
-          ) {
-            return false;
-          }
-
-          tr.setSelection(
-            NodeSelection.create(tr.doc, blockInfo.blockContent.beforePos),
-          );
-
-          return true;
-        }),
-      ArrowRight: createArrowHandler(blockType, "right"),
-      ArrowDown: createArrowHandler(blockType, "down"),
-      ArrowLeft: createArrowHandler(blockType, "left"),
-      ArrowUp: createArrowHandler(blockType, "up"),
-    },
-  });
diff --git a/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts b/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts
deleted file mode 100644
index 7576dc0898..0000000000
--- a/packages/core/src/blocks/Code/helpers/render/createCodeBlockWrapper.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
-import type { BlockFromConfig } from "../../../../schema/index.js";
-import {
-  getLanguageId,
-  type CodeBlockOptions,
-} from "../../CodeBlockOptions.js";
-import { createPreviewWithSourcePopup } from "./createPreviewWithSourcePopup.js";
-import { createSourceBlock } from "./createSourceBlock.js";
-
-export const createCodeBlockWrapper =
-  (options: CodeBlockOptions) =>
-  (block: BlockFromConfig, editor: BlockNoteEditor) => {
-    const language = block.props.language || options.defaultLanguage || "text";
-    const resolvedLanguage = getLanguageId(options, language) ?? language;
-    const renderPreview =
-      options.supportedLanguages?.[resolvedLanguage]?.createPreview;
-
-    // Languages with a preview show said preview by default, with the editable source in a popup.
-    // Other languages just show the source.
-    return renderPreview
-      ? createPreviewWithSourcePopup(options)(block, editor, renderPreview)
-      : createSourceBlock(options)(block, editor);
-  };
diff --git a/packages/core/src/blocks/Code/helpers/render/createPreviewWithSourcePopup.ts b/packages/core/src/blocks/Code/helpers/render/createPreviewWithSourcePopup.ts
deleted file mode 100644
index 393bc5ff25..0000000000
--- a/packages/core/src/blocks/Code/helpers/render/createPreviewWithSourcePopup.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-import {
-  autoUpdate,
-  computePosition,
-  flip,
-  offset,
-  shift,
-  size,
-} from "@floating-ui/dom";
-import type { Node as ProsemirrorNode } from "prosemirror-model";
-import { TextSelection } from "prosemirror-state";
-import type { ViewMutationRecord } from "prosemirror-view";
-import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
-import type { BlockFromConfig } from "../../../../schema/index.js";
-import type { CodeBlockConfig } from "../../block.js";
-import type {
-  CodeBlockOptions,
-  CodeBlockPreview,
-} from "../../CodeBlockOptions.js";
-import { createSourceBlock } from "./createSourceBlock.js";
-
-const getCodeBlockText = (block: BlockFromConfig): string => {
-  const content = block.content;
-
-  if (!Array.isArray(content)) {
-    return "";
-  }
-
-  return content.map((node) => ("text" in node ? node.text : "")).join("");
-};
-
-export const createPreviewWithSourcePopup =
-  (options: CodeBlockOptions) =>
-  (
-    block: BlockFromConfig,
-    editor: BlockNoteEditor,
-    createPreview: CodeBlockPreview,
-  ) => {
-    const dom = document.createElement("div");
-    dom.className = "bn-code-block-with-preview";
-
-    // Shows the preview. Always visible & non-editable.
-    const previewContainer = document.createElement("div");
-    previewContainer.className = "bn-code-block-preview";
-    previewContainer.contentEditable = "false";
-    dom.appendChild(previewContainer);
-
-    let preview = createPreview(block, editor);
-    previewContainer.appendChild(preview.dom);
-
-    // Holds the inline content with source code, shown in a popup below the preview while the
-    // selection is within the inline content.
-    const source = createSourceBlock(options)(block, editor);
-    const sourcePopup = document.createElement("div");
-    sourcePopup.className = "bn-code-block-source-popup";
-    sourcePopup.style.display = "none";
-    sourcePopup.appendChild(source.dom);
-
-    // Shows the preview's error message (e.g. a LaTeX syntax error) below the editable source.
-    // Hidden while there's no error.
-    const sourceError = document.createElement("div");
-    sourceError.className = "bn-code-block-source-error";
-    sourceError.contentEditable = "false";
-    sourceError.style.display = "none";
-    sourcePopup.appendChild(sourceError);
-
-    dom.appendChild(sourcePopup);
-
-    // Reflects the latest preview's error in the source popup's error section.
-    const applyPreviewError = (error?: string | null) => {
-      sourceError.textContent = error ?? "";
-      sourceError.style.display = error ? "block" : "none";
-    };
-    applyPreviewError(preview.error);
-
-    // Tracks the current source so the preview is only re-rendered when the source actually
-    // changes (see `update` below).
-    let currentSource = getCodeBlockText(block);
-
-    // Positions the source popup below the preview using FloatingUI, keeping it in place as the
-    // preview moves/resizes while visible.
-    let cleanupAutoUpdate: (() => void) | undefined;
-    const showSourcePopup = () => {
-      if (sourcePopup.style.display === "block") {
-        return;
-      }
-      sourcePopup.style.display = "block";
-      cleanupAutoUpdate = autoUpdate(previewContainer, sourcePopup, () => {
-        computePosition(previewContainer, sourcePopup, {
-          placement: "bottom-start",
-          middleware: [
-            offset(4),
-            flip(),
-            shift({ padding: 4 }),
-            // Match the popup's width to the block. The preview shrink-wraps
-            // its rendered content, so we measure the full-width block content
-            // element rather than the preview itself.
-            size({
-              apply({ rects, elements }) {
-                const blockContent =
-                  previewContainer.closest(".bn-block-content");
-                const width =
-                  blockContent?.getBoundingClientRect().width ??
-                  rects.reference.width;
-                elements.floating.style.width = `${width}px`;
-              },
-            }),
-          ],
-        }).then(({ x, y }) => {
-          sourcePopup.style.left = `${x}px`;
-          sourcePopup.style.top = `${y}px`;
-        });
-      });
-    };
-    const hideSourcePopup = () => {
-      if (sourcePopup.style.display === "none") {
-        return;
-      }
-      sourcePopup.style.display = "none";
-      cleanupAutoUpdate?.();
-      cleanupAutoUpdate = undefined;
-    };
-
-    // Reflects the current selection in the block's UI on every selection
-    // change. Two distinct states:
-    // - "Editing" (a text cursor inside the content) opens the source popup. A
-    //   whole-node selection or a gap cursor beside the block keeps it closed.
-    // - "Selected" (the selection is anywhere within the block - the whole node
-    //   selected *or* editing) gets the `ProseMirror-selectednode` class, so the
-    //   preview shows the standard selected styling in both states. ProseMirror
-    //   only adds that class for a whole-node selection, and even strips it once
-    //   editing begins (the inner selection isn't a node selection), so we manage
-    //   it here. `onSelectionChange` runs after ProseMirror's `deselectNode`, so
-    //   the class reliably sticks.
-    const updateSelectionState = () => {
-      let isSelected = false;
-      let isEditing = false;
-      try {
-        const { selection } = editor.prosemirrorState;
-        isSelected = editor.getTextCursorPosition().block.id === block.id;
-        isEditing = isSelected && selection instanceof TextSelection;
-      } catch {
-        isSelected = false;
-        isEditing = false;
-      }
-
-      if (editor.isEditable && isEditing) {
-        showSourcePopup();
-      } else {
-        hideSourcePopup();
-      }
-
-      dom
-        .closest(".bn-block-content")
-        ?.classList.toggle("ProseMirror-selectednode", isSelected);
-    };
-    const removeSelectionChangeListener =
-      editor.onSelectionChange(updateSelectionState);
-    updateSelectionState();
-
-    // The source is hidden inside the popup, so clicking the preview can't
-    // place the text cursor in the block on its own. We do it manually, placing
-    // the cursor at the content start, which reveals the popup via the selection
-    // listener.
-    const handlePreviewMouseDown = (event: MouseEvent) => {
-      if (!editor.isEditable) {
-        return;
-      }
-      event.preventDefault();
-      showSourcePopup();
-      editor.setTextCursorPosition(block.id, "start");
-      editor.focus();
-    };
-    previewContainer.addEventListener("mousedown", handlePreviewMouseDown);
-
-    return {
-      dom,
-      contentDOM: source.contentDOM,
-      // Ignores mutations outside the editable source (e.g. preview
-      // re-renders), so ProseMirror doesn't try to read them as content.
-      ignoreMutation: (mutation: ViewMutationRecord) =>
-        !source.contentDOM.contains(mutation.target as Node),
-      // Re-renders the preview in place whenever this block's source changes,
-      // keeping it in sync without recreating the whole view. ProseMirror
-      // only calls this for changes to this block's node, so unlike a global
-      // change listener it does no work while other blocks are edited.
-      update: (node: ProsemirrorNode) => {
-        // The preview layout depends on the language, so let ProseMirror
-        // recreate the view (via `render`) when it changes.
-        if (node.attrs.language !== block.props.language) {
-          return false;
-        }
-
-        const text = node.textContent;
-        if (text !== currentSource) {
-          currentSource = text;
-
-          preview.destroy?.();
-          previewContainer.innerHTML = "";
-          preview = createPreview(
-            editor.getBlock(block.id) as BlockFromConfig<
-              CodeBlockConfig,
-              any,
-              any
-            >,
-            editor,
-          );
-          previewContainer.appendChild(preview.dom);
-          applyPreviewError(preview.error);
-        }
-
-        return true;
-      },
-      destroy: () => {
-        source.destroy();
-        removeSelectionChangeListener();
-        cleanupAutoUpdate?.();
-        preview.destroy?.();
-        previewContainer.removeEventListener(
-          "mousedown",
-          handlePreviewMouseDown,
-        );
-      },
-    };
-  };
diff --git a/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts
index 4293c9c63d..04f739982a 100644
--- a/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts
+++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts
@@ -1,59 +1,94 @@
 import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
 import type { BlockFromConfig } from "../../../../schema/index.js";
-import {
-  getLanguageId,
-  type CodeBlockOptions,
-} from "../../CodeBlockOptions.js";
-
-export const createSourceBlock =
-  (options: CodeBlockOptions) =>
-  (block: BlockFromConfig, editor: BlockNoteEditor) => {
-    const language = block.props.language || options.defaultLanguage || "text";
-    const resolvedLanguage = getLanguageId(options, language) ?? language;
-
-    const pre = document.createElement("pre");
-    const code = document.createElement("code");
-    pre.appendChild(code);
-
-    const dom = document.createDocumentFragment();
-
-    let removeSelectChangeListener: (() => void) | undefined;
-    if (options.supportedLanguages) {
-      const select = document.createElement("select");
-      Object.entries(options.supportedLanguages).forEach(([id, { name }]) => {
-        const option = document.createElement("option");
-        option.value = id;
-        option.text = name;
-        select.appendChild(option);
-      });
-      select.value = resolvedLanguage;
-
-      if (editor.isEditable) {
-        const handleLanguageChange = (event: Event) => {
-          editor.updateBlock(block.id, {
-            props: { language: (event.target as HTMLSelectElement).value },
-          });
-        };
-        select.addEventListener("change", handleLanguageChange);
-        removeSelectChangeListener = () =>
-          select.removeEventListener("change", handleLanguageChange);
-      } else {
-        select.disabled = true;
-      }
 
-      const selectWrapper = document.createElement("div");
-      selectWrapper.contentEditable = "false";
-      selectWrapper.appendChild(select);
-      dom.appendChild(selectWrapper);
+// Select dropdown to change the block's language. Assumes `block` has a `language` prop.
+export const createLanguageSelect = (
+  block: BlockFromConfig,
+  editor: BlockNoteEditor,
+  selectedLanguage: string,
+  supportedLanguages: Record<
+    string,
+    {
+      name: string;
     }
+  >,
+) => {
+  if (!(selectedLanguage in supportedLanguages)) {
+    throw new Error(`Language ${selectedLanguage} is not supported.`);
+  }
+
+  const select = document.createElement("select");
+  Object.entries(supportedLanguages).forEach(([id, { name }]) => {
+    const option = document.createElement("option");
+    option.value = id;
+    option.text = name;
+    select.appendChild(option);
+  });
+  select.value = selectedLanguage;
+
+  const handleLanguageChange = (event: Event) => {
+    editor.updateBlock(block.id, {
+      props: { language: (event.target as HTMLSelectElement).value },
+    });
+  };
+
+  if (editor.isEditable) {
+    select.addEventListener("change", handleLanguageChange);
+  } else {
+    select.disabled = true;
+  }
+
+  const selectWrapper = document.createElement("div");
+  selectWrapper.contentEditable = "false";
+  selectWrapper.appendChild(select);
+
+  return {
+    dom: selectWrapper,
+    destroy: () => select.removeEventListener("change", handleLanguageChange),
+  };
+};
+
+// Renders the block's inline content as code, alongside a language picker, if multiple languages
+// are supported.
+export const createSourceBlock = (
+  block: BlockFromConfig,
+  editor: BlockNoteEditor,
+  options?: {
+    selectedLanguage: string;
+    supportedLanguages: Record<
+      string,
+      {
+        name: string;
+      }
+    >;
+  },
+) => {
+  const pre = document.createElement("pre");
+  const code = document.createElement("code");
+  pre.appendChild(code);
+
+  const sourceBlock = document.createDocumentFragment();
+
+  let languageSelect: ReturnType | undefined =
+    undefined;
+  if (options && Object.keys(options.supportedLanguages).length > 1) {
+    languageSelect = createLanguageSelect(
+      block,
+      editor,
+      options.selectedLanguage,
+      options.supportedLanguages,
+    );
+
+    sourceBlock.appendChild(languageSelect.dom);
+  }
 
-    dom.appendChild(pre);
+  sourceBlock.appendChild(pre);
 
-    return {
-      dom,
-      contentDOM: code,
-      destroy: () => {
-        removeSelectChangeListener?.();
-      },
-    };
+  return {
+    dom: sourceBlock,
+    contentDOM: code,
+    destroy: () => {
+      languageSelect?.destroy();
+    },
   };
+};
diff --git a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts
new file mode 100644
index 0000000000..2c9594afd6
--- /dev/null
+++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts
@@ -0,0 +1,398 @@
+import {
+  autoUpdate,
+  computePosition,
+  flip,
+  offset,
+  shift,
+  size,
+} 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, StyledText } from "../../../../schema/index.js";
+import { createSourceBlock } from "./createSourceBlock.js";
+import { CodeBlockPreview } from "../../CodeBlockOptions.js";
+
+// Element shown instead of the preview when block has no content.
+const createAddSourceButton = (editor: BlockNoteEditor) => {
+  const addSourceButton = document.createElement("div");
+  addSourceButton.className = "bn-add-source-code-button";
+  addSourceButton.contentEditable = "false";
+
+  const addSourceButtonIcon = document.createElement("div");
+  addSourceButtonIcon.className = "bn-add-source-code-button-icon";
+  addSourceButtonIcon.innerHTML =
+    '';
+  addSourceButton.appendChild(addSourceButtonIcon);
+
+  const addSourceButtonText = document.createElement("p");
+  addSourceButtonText.className = "bn-add-source-code-button-text";
+  addSourceButtonText.textContent =
+    editor.dictionary.code_block.add_source_button_text;
+  addSourceButton.appendChild(addSourceButtonText);
+
+  return { dom: addSourceButton };
+};
+
+// Handles toggling popup visibility using the keyboard. and keyboard navigation while popup is
+// hidden.
+const handleKeyboardNavigation = (
+  block: BlockFromConfig,
+  editor: BlockNoteEditor,
+  isSourcePopupOpen: () => boolean,
+  setSourcePopupOpen: (open: boolean) => void,
+) => {
+  const handleKeyDown = (event: KeyboardEvent) => {
+    if (!editor.isEditable) {
+      return;
+    }
+
+    if (editor.getTextCursorPosition().block.id !== block.id) {
+      return;
+    }
+
+    // Toggles popup visibility.
+    if (event.key === "Enter") {
+      editor.setTextCursorPosition(block.id, "end");
+      setSourcePopupOpen(!isSourcePopupOpen());
+
+      event.preventDefault();
+      event.stopImmediatePropagation();
+
+      return;
+    }
+
+    // Hides popup.
+    if (event.key === "Escape") {
+      if (!isSourcePopupOpen()) {
+        return;
+      }
+
+      editor.setTextCursorPosition(block.id, "end");
+      setSourcePopupOpen(false);
+
+      event.preventDefault();
+      event.stopImmediatePropagation();
+
+      return;
+    }
+
+    // While popup is hidden, moves selection straight to previous/next block.
+    if (
+      (event.key === "ArrowUp" ||
+        event.key === "ArrowDown" ||
+        event.key === "ArrowLeft" ||
+        event.key === "ArrowRight") &&
+      !event.ctrlKey &&
+      !event.metaKey
+    ) {
+      if (isSourcePopupOpen()) {
+        return;
+      }
+
+      const direction =
+        event.key === "ArrowUp" || event.key === "ArrowLeft" ? "prev" : "next";
+
+      const { prevBlock, nextBlock } = editor.getTextCursorPosition();
+      const targetBlock = direction === "prev" ? prevBlock : nextBlock;
+      if (!targetBlock) {
+        return;
+      }
+
+      editor.setTextCursorPosition(
+        targetBlock.id,
+        direction === "prev" ? "end" : "start",
+      );
+
+      event.preventDefault();
+      event.stopImmediatePropagation();
+    }
+
+    // While popup is hidden, prevents editing of block content.
+    // TODO: This doesn't account for all cases, e.g. cut/paste with Cmd+X/Cmd+V.
+    if (
+      (event.key.length === 1 && !event.ctrlKey && !event.metaKey) ||
+      event.key === "Backspace" ||
+      event.key === "Delete" ||
+      event.key === "Tab"
+    ) {
+      if (isSourcePopupOpen()) {
+        return;
+      }
+
+      event.preventDefault();
+      event.stopImmediatePropagation();
+    }
+  };
+
+  editor.domElement?.addEventListener("keydown", handleKeyDown, true);
+
+  return {
+    destroy: () =>
+      editor.domElement?.removeEventListener("keydown", handleKeyDown, true),
+  };
+};
+
+// Handles opening the popup when clicking the preview.
+const handlePreviewMouseDown = (
+  block: BlockFromConfig,
+  editor: BlockNoteEditor,
+  preview: HTMLElement,
+  setSourcePopupOpen: (open: boolean) => void,
+) => {
+  const handleMouseDown = (event: MouseEvent) => {
+    if (!editor.isEditable) {
+      return;
+    }
+
+    setSourcePopupOpen(true);
+
+    event.preventDefault();
+    event.stopPropagation();
+
+    editor.setTextCursorPosition(block.id, "end");
+    editor.focus();
+  };
+
+  preview.addEventListener("mousedown", handleMouseDown);
+
+  return {
+    destroy: () => preview.removeEventListener("mousedown", handleMouseDown),
+  };
+};
+
+// Handles closing the popup when selection moves outside of the block, and makes the block appear
+// selected while the selection is anywhere inside it.
+const handleSelectionChange = (
+  block: BlockFromConfig,
+  editor: BlockNoteEditor,
+  container: HTMLElement,
+  isSourcePopupOpen: () => boolean,
+  setSourcePopupOpen: (open: boolean) => void,
+) => {
+  const destroy = editor.onSelectionChange((editor) => {
+    const blockContent = container.closest(".bn-block-content");
+
+    if (editor.getTextCursorPosition().block.id !== block.id) {
+      if (isSourcePopupOpen()) {
+        setSourcePopupOpen(false);
+      }
+
+      // Sets selected block styles.
+      if (
+        blockContent &&
+        blockContent.classList.contains("ProseMirror-selectednode")
+      ) {
+        blockContent.classList.remove("ProseMirror-selectednode");
+      }
+    } else {
+      if (
+        blockContent &&
+        !blockContent.classList.contains("ProseMirror-selectednode")
+      ) {
+        blockContent.classList.add("ProseMirror-selectednode");
+      }
+    }
+  });
+
+  return { destroy };
+};
+
+// Handles positioning for the popup, including edge cases where it doesn't fit in the viewport.
+// TODO: Would be nice to replace this logic with CSS anchors:
+// https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/anchor
+const positionSourcePopup = (
+  preview: HTMLElement,
+  sourcePopup: HTMLElement,
+) => {
+  const destroy = autoUpdate(preview, sourcePopup, async () => {
+    const { x, y } = await computePosition(preview, sourcePopup, {
+      placement: "bottom-start",
+      middleware: [
+        offset(4),
+        flip(),
+        shift({ padding: 4 }),
+        // Match the popup's width to the block.
+        size({
+          apply({ rects, elements }) {
+            const blockContent = preview.closest(".bn-block-content");
+            const width =
+              blockContent?.getBoundingClientRect().width ??
+              rects.reference.width;
+            elements.floating.style.width = `${width}px`;
+          },
+        }),
+      ],
+    });
+    sourcePopup.style.left = `${x}px`;
+    sourcePopup.style.top = `${y}px`;
+  });
+
+  return { destroy };
+};
+
+// Renders a preview which can be clicked to show the block's inline content as code in a popup,
+// alongside a language picker if multiple languages are supported. If no preview is provided, just
+// renders the same thing as `createSourceBlock`.
+export const createSourceBlockWithPreview = (
+  block: BlockFromConfig,
+  editor: BlockNoteEditor,
+  options?:
+    | {
+        selectedLanguage: string;
+        supportedLanguages: Record<
+          string,
+          {
+            name: string;
+            createPreview?: CodeBlockPreview;
+          }
+        >;
+      }
+    | {
+        createPreview: CodeBlockPreview;
+      },
+) => {
+  const sourceBlock = createSourceBlock(
+    block,
+    editor,
+    options && "selectedLanguage" in options ? options : undefined,
+  );
+
+  const sourceCode = (block.content as StyledText[])
+    .map(({ text }) => text)
+    .join("");
+
+  // Tracks the source the preview was last rendered from, so `update` can tell
+  // a source-text change (which it handles in place) from any other update.
+  let currentSource = sourceCode;
+
+  const createPreview = options
+    ? "createPreview" in options
+      ? options.createPreview
+      : options.supportedLanguages[options.selectedLanguage].createPreview
+    : undefined;
+
+  const preview = createPreview
+    ? sourceCode.length > 0
+      ? createPreview(block, editor)
+      : createAddSourceButton(editor)
+    : undefined;
+
+  if (!preview) {
+    return sourceBlock;
+  }
+
+  const previewWithSourcePopup = document.createElement("div");
+  previewWithSourcePopup.className = "bn-preview-with-source-popup";
+  previewWithSourcePopup.dataset.open = "false";
+
+  const previewContainer = document.createElement("div");
+  previewContainer.className = "bn-preview-container";
+  previewContainer.contentEditable = "false";
+  previewContainer.appendChild(preview.dom);
+  previewWithSourcePopup.appendChild(previewContainer);
+
+  const sourceBlockPopup = document.createElement("div");
+  sourceBlockPopup.className = "bn-source-block-popup";
+  sourceBlockPopup.inert = true;
+  sourceBlockPopup.appendChild(sourceBlock.dom);
+
+  const errorMessage = "error" in preview && preview.error ? preview.error : "";
+
+  const sourceError = document.createElement("div");
+  sourceError.className = "bn-code-block-source-error";
+  sourceError.contentEditable = "false";
+  sourceError.textContent = errorMessage;
+  sourceError.style.display = errorMessage ? "block" : "none";
+  sourceBlockPopup.appendChild(sourceError);
+
+  previewWithSourcePopup.appendChild(sourceBlockPopup);
+
+  const isSourcePopupOpen = () =>
+    previewWithSourcePopup.dataset.open === "true";
+  const setSourcePopupOpen = (open: boolean) =>
+    (previewWithSourcePopup.dataset.open = open ? "true" : "false");
+
+  const keyboardNavigationHandler = handleKeyboardNavigation(
+    block,
+    editor,
+    isSourcePopupOpen,
+    setSourcePopupOpen,
+  );
+
+  const previewMouseDownHandler = handlePreviewMouseDown(
+    block,
+    editor,
+    previewContainer,
+    setSourcePopupOpen,
+  );
+
+  const selectionMoveOutHandler = handleSelectionChange(
+    block,
+    editor,
+    previewWithSourcePopup,
+    isSourcePopupOpen,
+    setSourcePopupOpen,
+  );
+
+  const sourcePopupPositioner = positionSourcePopup(
+    previewContainer,
+    sourceBlockPopup,
+  );
+
+  return {
+    dom: previewWithSourcePopup,
+    contentDOM: sourceBlock.contentDOM,
+    ignoreMutation: (mutation: ViewMutationRecord) =>
+      // Ignore mutations outside of the inline content container. Used mainly to ignore DOM
+      // changes caused preview updates.
+      !sourceBlock.contentDOM.parentElement ||
+      !sourceBlock.contentDOM.parentElement.contains(mutation.target),
+    update: (node: ProsemirrorNode) => {
+      // Always returns `false` and recreates the view when an update was triggered but the block's
+      // text content didn't change. If the text content did change and the preview didn't return
+      // an error, returns `true` preventing the view from getting recreated, and updates the
+      // preview in-place.
+      const newSource = node.textContent;
+      if (newSource === currentSource) {
+        return false;
+      }
+      currentSource = newSource;
+
+      if (!createPreview) {
+        return false;
+      }
+
+      const currentBlock = editor.getBlock(block.id);
+      if (!currentBlock) {
+        return false;
+      }
+
+      const preview =
+        newSource.length > 0
+          ? createPreview(
+              currentBlock as BlockFromConfig,
+              editor,
+            )
+          : createAddSourceButton(editor);
+
+      const errorMessage =
+        "error" in preview && preview.error ? preview.error : "";
+      if (!errorMessage) {
+        previewContainer.replaceChildren(preview.dom);
+      }
+
+      sourceError.textContent = errorMessage;
+      sourceError.style.display = errorMessage ? "block" : "none";
+
+      return true;
+    },
+    destroy: () => {
+      sourceBlock.destroy();
+      keyboardNavigationHandler.destroy();
+      previewMouseDownHandler.destroy();
+      selectionMoveOutHandler.destroy();
+      sourcePopupPositioner.destroy();
+    },
+  };
+};
diff --git a/packages/core/src/blocks/index.ts b/packages/core/src/blocks/index.ts
index 459999c3ba..cf96624cf9 100644
--- a/packages/core/src/blocks/index.ts
+++ b/packages/core/src/blocks/index.ts
@@ -17,11 +17,9 @@ export * from "./Video/block.js";
 
 export { EMPTY_CELL_HEIGHT, EMPTY_CELL_WIDTH } from "./Table/TableExtension.js";
 export * from "./Code/helpers/extensions/createCodeKeyboardShortcutsExtension.js";
-export * from "./Code/helpers/extensions/createPreviewSourceNavigationExtension.js";
 export * from "./Code/helpers/parse/parsePreCode.js";
-export * from "./Code/helpers/render/createCodeBlockWrapper.js";
-export * from "./Code/helpers/render/createPreviewWithSourcePopup.js";
 export * from "./Code/helpers/render/createSourceBlock.js";
+export * from "./Code/helpers/render/createSourceBlockWithPreview.js";
 export * from "./Code/helpers/toExternalHTML/createPreCode.js";
 export * from "./ToggleWrapper/createToggleWrapper.js";
 export * from "./File/helpers/uploadToTmpFilesDotOrg_DEV_ONLY.js";
diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css
index 1804f9b6fd..9249af676f 100644
--- a/packages/core/src/editor/Block.css
+++ b/packages/core/src/editor/Block.css
@@ -455,25 +455,25 @@ NESTED BLOCKS
 
 /* CODE BLOCK PREVIEW */
 .bn-block-content[data-content-type="codeBlock"]:has(
-    .bn-code-block-with-preview
+    .bn-preview-with-source-popup
   ) {
   background-color: transparent;
   color: inherit;
 }
 
-.bn-code-block-with-preview {
+.bn-preview-with-source-popup {
   position: relative;
   flex: 1;
   min-width: 0;
 }
 
-.bn-code-block-preview {
+.bn-preview-container {
   padding: 12px;
   min-height: 1.5em;
   cursor: text;
 }
 
-.bn-code-block-source-popup {
+.bn-source-block-popup {
   position: absolute;
   z-index: 1;
   background-color: var(--bn-colors-menu-background);
@@ -481,11 +481,16 @@ NESTED BLOCKS
   border: var(--bn-border);
   border-radius: var(--bn-border-radius-medium);
   box-shadow: var(--bn-shadow-medium);
+  opacity: 0;
+  pointer-events: none;
+}
+
+.bn-preview-with-source-popup[data-open="true"] .bn-source-block-popup {
+  opacity: 1;
+  pointer-events: auto;
 }
 
-/* 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 {
+.bn-source-block-popup > div > select {
   outline: none !important;
   appearance: none;
   user-select: none;
@@ -497,11 +502,11 @@ NESTED BLOCKS
   padding: 8px 16px 0;
 }
 
-.bn-code-block-source-popup > div > select > option {
+.bn-source-block-popup > div > select > option {
   color: black;
 }
 
-.bn-code-block-source-popup > pre {
+.bn-source-block-popup > pre {
   white-space: pre;
   overflow-x: auto;
   margin: 0;
@@ -518,6 +523,42 @@ NESTED BLOCKS
   white-space: pre-wrap;
 }
 
+/* Shown in place of the preview when the source is empty. Mirrors the file
+block's "add file" button styling. */
+.bn-add-source-code-button {
+  align-items: center;
+  background-color: rgb(242, 241, 238);
+  border-radius: 4px;
+  color: rgb(125, 121, 122);
+  display: flex;
+  gap: 10px;
+  padding: 12px;
+}
+
+.bn-add-source-code-button:where(.dark, .dark *) {
+  background-color: rgb(70, 70, 70);
+  color: rgb(190, 190, 190);
+}
+
+.bn-editor[contenteditable="true"] .bn-add-source-code-button:hover {
+  background-color: rgb(225, 225, 225);
+}
+
+.bn-editor[contenteditable="true"]
+  .bn-add-source-code-button:hover:where(.dark, .dark *) {
+  background-color: rgb(90, 90, 90);
+}
+
+.bn-add-source-code-button-icon {
+  width: 24px;
+  height: 24px;
+}
+
+.bn-add-source-code-button-text {
+  font-size: 0.9rem;
+  margin: 0;
+}
+
 /* PAGE BREAK */
 .bn-block-content[data-content-type="pageBreak"] > div {
   width: 100%;
diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts
index 37abc3e30b..518829fb76 100644
--- a/packages/core/src/i18n/locales/ar.ts
+++ b/packages/core/src/i18n/locales/ar.ts
@@ -185,6 +185,9 @@ export const ar: Dictionary = {
   toggle_blocks: {
     add_block_button: "تبديل فارغ. انقر لإضافة كتلة.",
   },
+  code_block: {
+    add_source_button_text: "إضافة كود المصدر",
+  },
   // from react package:
   side_menu: {
     add_block_label: "إضافة محتوي",
diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts
index 40944212b3..07b2dc6b0f 100644
--- a/packages/core/src/i18n/locales/de.ts
+++ b/packages/core/src/i18n/locales/de.ts
@@ -221,6 +221,9 @@ export const de: Dictionary = {
     add_block_button:
       "Leerer aufklappbarer Bereich. Klicken, um einen Block hinzuzufügen.",
   },
+  code_block: {
+    add_source_button_text: "Quellcode hinzufügen",
+  },
   side_menu: {
     add_block_label: "Block hinzufügen",
     drag_handle_label: "Blockmenü öffnen",
diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts
index 5a9968eab2..09c6aa5484 100644
--- a/packages/core/src/i18n/locales/en.ts
+++ b/packages/core/src/i18n/locales/en.ts
@@ -200,6 +200,9 @@ export const en = {
   toggle_blocks: {
     add_block_button: "Empty toggle. Click to add a block.",
   },
+  code_block: {
+    add_source_button_text: "Add source code",
+  },
   // from react package:
   side_menu: {
     add_block_label: "Add block",
diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts
index 4757d9784f..57857ebd21 100644
--- a/packages/core/src/i18n/locales/es.ts
+++ b/packages/core/src/i18n/locales/es.ts
@@ -200,6 +200,9 @@ export const es: Dictionary = {
   toggle_blocks: {
     add_block_button: "Toggle vacío. Haz clic para añadir un bloque.",
   },
+  code_block: {
+    add_source_button_text: "Agregar código fuente",
+  },
   side_menu: {
     add_block_label: "Agregar bloque",
     drag_handle_label: "Abrir menú de bloque",
diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts
index c9c67c1fee..ccb2fdc631 100644
--- a/packages/core/src/i18n/locales/fa.ts
+++ b/packages/core/src/i18n/locales/fa.ts
@@ -168,6 +168,9 @@ export const fa = {
   toggle_blocks: {
     add_block_button: "تاشوی خالی. برای افزودن بلوک کلیک کنید.",
   },
+  code_block: {
+    add_source_button_text: "افزودن کد منبع",
+  },
   // from react package:
   side_menu: {
     add_block_label: "افزودن بلوک",
diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts
index b05d346409..f4c7f3a270 100644
--- a/packages/core/src/i18n/locales/fr.ts
+++ b/packages/core/src/i18n/locales/fr.ts
@@ -246,6 +246,9 @@ export const fr: Dictionary = {
   toggle_blocks: {
     add_block_button: "Liste repliable vide. Cliquez pour ajouter un bloc.",
   },
+  code_block: {
+    add_source_button_text: "Ajouter le code source",
+  },
   // from react package:
   side_menu: {
     add_block_label: "Ajouter un bloc",
diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts
index 797831460c..4a44bda462 100644
--- a/packages/core/src/i18n/locales/he.ts
+++ b/packages/core/src/i18n/locales/he.ts
@@ -202,6 +202,9 @@ export const he: Dictionary = {
   toggle_blocks: {
     add_block_button: "מתג ריק. לחץ כדי להוסיף בלוק.",
   },
+  code_block: {
+    add_source_button_text: "הוסף קוד מקור",
+  },
   side_menu: {
     add_block_label: "הוסף בלוק",
     drag_handle_label: "פתח תפריט בלוק",
diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts
index c2081599cc..41cdcd2fa4 100644
--- a/packages/core/src/i18n/locales/hr.ts
+++ b/packages/core/src/i18n/locales/hr.ts
@@ -213,6 +213,9 @@ export const hr: Dictionary = {
   toggle_blocks: {
     add_block_button: "Prazan sklopivi blok. Klikni da dodaš sadržaj.",
   },
+  code_block: {
+    add_source_button_text: "Dodaj izvorni kôd",
+  },
   // from react package:
   side_menu: {
     add_block_label: "Dodaj blok",
diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts
index fcde471e56..250ff06d62 100644
--- a/packages/core/src/i18n/locales/is.ts
+++ b/packages/core/src/i18n/locales/is.ts
@@ -214,6 +214,9 @@ export const is: Dictionary = {
   toggle_blocks: {
     add_block_button: "Tóm fellilína. Smelltu til að bæta við blokk.",
   },
+  code_block: {
+    add_source_button_text: "Bæta við frumkóða",
+  },
   side_menu: {
     add_block_label: "Bæta við blokki",
     drag_handle_label: "Opna blokkarvalmynd",
diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts
index 4053581107..846231c642 100644
--- a/packages/core/src/i18n/locales/it.ts
+++ b/packages/core/src/i18n/locales/it.ts
@@ -222,6 +222,9 @@ export const it: Dictionary = {
   toggle_blocks: {
     add_block_button: "Toggle vuoto. Clicca per aggiungere un blocco.",
   },
+  code_block: {
+    add_source_button_text: "Aggiungi codice sorgente",
+  },
   // from react package:
   side_menu: {
     add_block_label: "Aggiungi blocco",
diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts
index ce5ba87a77..bbc1b48ab2 100644
--- a/packages/core/src/i18n/locales/ja.ts
+++ b/packages/core/src/i18n/locales/ja.ts
@@ -240,6 +240,9 @@ export const ja: Dictionary = {
   toggle_blocks: {
     add_block_button: "空のトグルです。クリックしてブロックを追加。",
   },
+  code_block: {
+    add_source_button_text: "ソースコードを追加",
+  },
   // from react package:
   side_menu: {
     add_block_label: "ブロックを追加",
diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts
index 53a5def39e..f276ba5190 100644
--- a/packages/core/src/i18n/locales/ko.ts
+++ b/packages/core/src/i18n/locales/ko.ts
@@ -213,6 +213,9 @@ export const ko: Dictionary = {
   toggle_blocks: {
     add_block_button: "비어 있는 토글입니다. 클릭하여 블록을 추가하세요.",
   },
+  code_block: {
+    add_source_button_text: "소스 코드 추가",
+  },
   // from react package:
   side_menu: {
     add_block_label: "블록 추가",
diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts
index a1bff3fc6b..d8069069a2 100644
--- a/packages/core/src/i18n/locales/nl.ts
+++ b/packages/core/src/i18n/locales/nl.ts
@@ -201,6 +201,9 @@ export const nl: Dictionary = {
   toggle_blocks: {
     add_block_button: "Lege uitklapper. Klik om een blok toe te voegen.",
   },
+  code_block: {
+    add_source_button_text: "Broncode toevoegen",
+  },
   // from react package:
   side_menu: {
     add_block_label: "Nieuw blok",
diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts
index 5d518d116b..88fad39e17 100644
--- a/packages/core/src/i18n/locales/no.ts
+++ b/packages/core/src/i18n/locales/no.ts
@@ -219,6 +219,9 @@ export const no: Dictionary = {
   toggle_blocks: {
     add_block_button: "Tomt toggle. Klikk for å legge til en blokk.",
   },
+  code_block: {
+    add_source_button_text: "Legg til kildekode",
+  },
   side_menu: {
     add_block_label: "Legg til blokk",
     drag_handle_label: "Åpne blokkmeny",
diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts
index 614f64e9f2..c68be16a6f 100644
--- a/packages/core/src/i18n/locales/pl.ts
+++ b/packages/core/src/i18n/locales/pl.ts
@@ -192,6 +192,9 @@ export const pl: Dictionary = {
     add_block_button:
       "Brak bloków do rozwinięcia. Kliknij, aby dodać pierwszego.",
   },
+  code_block: {
+    add_source_button_text: "Dodaj kod źródłowy",
+  },
   side_menu: {
     add_block_label: "Dodaj blok",
     drag_handle_label: "Otwórz menu bloków",
diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts
index c12c94012e..616a776806 100644
--- a/packages/core/src/i18n/locales/pt.ts
+++ b/packages/core/src/i18n/locales/pt.ts
@@ -192,6 +192,9 @@ export const pt: Dictionary = {
   toggle_blocks: {
     add_block_button: "Toggle vazio. Clique para adicionar um bloco.",
   },
+  code_block: {
+    add_source_button_text: "Adicionar código-fonte",
+  },
   // from react package:
   side_menu: {
     add_block_label: "Adicionar bloco",
diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts
index 2982c8f5f6..979a4b7be9 100644
--- a/packages/core/src/i18n/locales/ru.ts
+++ b/packages/core/src/i18n/locales/ru.ts
@@ -243,6 +243,9 @@ export const ru: Dictionary = {
   toggle_blocks: {
     add_block_button: "Пустой переключатель. Нажмите, чтобы добавить блок.",
   },
+  code_block: {
+    add_source_button_text: "Добавить исходный код",
+  },
   // from react package:
   side_menu: {
     add_block_label: "Добавить блок",
diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts
index c24974f392..5f70be1bcf 100644
--- a/packages/core/src/i18n/locales/sk.ts
+++ b/packages/core/src/i18n/locales/sk.ts
@@ -200,6 +200,9 @@ export const sk = {
   toggle_blocks: {
     add_block_button: "Prázdne prepínanie. Kliknite pre pridanie bloku.",
   },
+  code_block: {
+    add_source_button_text: "Pridať zdrojový kód",
+  },
   side_menu: {
     add_block_label: "Pridať blok",
     drag_handle_label: "Otvoriť menu bloku",
diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts
index a5d7d8f9af..3bbb08c311 100644
--- a/packages/core/src/i18n/locales/uk.ts
+++ b/packages/core/src/i18n/locales/uk.ts
@@ -225,6 +225,9 @@ export const uk: Dictionary = {
   toggle_blocks: {
     add_block_button: "Порожній перемикач. Натисніть, щоб додати блок.",
   },
+  code_block: {
+    add_source_button_text: "Додати вихідний код",
+  },
   // from react package:
   side_menu: {
     add_block_label: "Додати блок",
diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts
index ffc8d04ac6..2d6ce0184a 100644
--- a/packages/core/src/i18n/locales/uz.ts
+++ b/packages/core/src/i18n/locales/uz.ts
@@ -262,6 +262,9 @@ export const uz: Dictionary = {
     add_block_button: "Bo‘sh toggle. Blok qo‘shish uchun bosing.",
   },
 
+  code_block: {
+    add_source_button_text: "Manba kodini qoʻshish",
+  },
   side_menu: {
     add_block_label: "Blok qo‘shish",
     drag_handle_label: "Blok menyusini ochish",
diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts
index cbe0e5e628..445355a403 100644
--- a/packages/core/src/i18n/locales/vi.ts
+++ b/packages/core/src/i18n/locales/vi.ts
@@ -199,6 +199,9 @@ export const vi: Dictionary = {
   toggle_blocks: {
     add_block_button: "Toggle trống. Nhấp để thêm khối.",
   },
+  code_block: {
+    add_source_button_text: "Thêm mã nguồn",
+  },
   // từ gói phản ứng:
   side_menu: {
     add_block_label: "Thêm khối",
diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts
index b64912255f..3706d660ff 100644
--- a/packages/core/src/i18n/locales/zh-tw.ts
+++ b/packages/core/src/i18n/locales/zh-tw.ts
@@ -241,6 +241,9 @@ export const zhTW: Dictionary = {
   toggle_blocks: {
     add_block_button: "空的切換區。點擊新增區塊。",
   },
+  code_block: {
+    add_source_button_text: "新增原始碼",
+  },
   // from react package:
   side_menu: {
     add_block_label: "新增區塊",
diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts
index ba5a2fe73b..93c834b319 100644
--- a/packages/core/src/i18n/locales/zh.ts
+++ b/packages/core/src/i18n/locales/zh.ts
@@ -241,6 +241,9 @@ export const zh: Dictionary = {
   toggle_blocks: {
     add_block_button: "空的切换区。点击添加区块。",
   },
+  code_block: {
+    add_source_button_text: "添加源代码",
+  },
   // from react package:
   side_menu: {
     add_block_label: "添加块",
diff --git a/packages/math-block/src/block.test.ts b/packages/math-block/src/block.test.ts
index 68eb2f769e..56443b2488 100644
--- a/packages/math-block/src/block.test.ts
+++ b/packages/math-block/src/block.test.ts
@@ -1,12 +1,4 @@
-import {
-  BlockNoteEditor,
-  BlockNoteSchema,
-  createInlineContentSpec,
-  defaultInlineContentSpecs,
-  FormattingToolbarExtension,
-} from "@blocknote/core";
-import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column";
-import { NodeSelection, TextSelection } from "prosemirror-state";
+import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core";
 import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
 import { createMathBlockSpec } from "./block.js";
 
@@ -19,44 +11,15 @@ const schema = BlockNoteSchema.create().extend({
   blockSpecs: { math: createMathBlockSpec() },
 });
 
-function pressKey(editor: BlockNoteEditor, key: string) {
-  const view = editor.prosemirrorView;
-  const event = new KeyboardEvent("keydown", { key });
-  return view.someProp("handleKeyDown", (f) => f(view, event)) === true;
-}
-
-/** Selects a block's content node as a NodeSelection (e.g. an image, math). */
-function selectBlockNode(
-  editor: BlockNoteEditor,
-  blockId: string,
-) {
-  const view = editor.prosemirrorView;
-  let nodePos: number | undefined;
-  view.state.doc.descendants((node, pos) => {
-    if (node.attrs.id === blockId) {
-      // The blockContent node sits just inside the blockContainer.
-      nodePos = pos + 1;
-      return false;
-    }
-    return true;
-  });
-  view.dispatch(
-    view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos!)),
-  );
-}
-
-/** Asserts the whole math node is selected (a NodeSelection on it). */
-function expectMathNodeSelected(editor: BlockNoteEditor) {
-  const { selection } = editor.prosemirrorView.state;
-  expect("node" in selection).toBe(true);
-  expect((selection as NodeSelection).node.type.name).toBe("math");
-}
-
-describe("Math block keyboard navigation", () => {
+describe("Math block source popup keyboard handling", () => {
   let editor: BlockNoteEditor;
   const div = document.createElement("div");
 
   beforeEach(() => {
+    // The keyboard handler listens on the document (capture phase), so the
+    // mount point must be in the document tree for dispatched keydowns to reach
+    // it - a detached element's events never propagate to `document`.
+    document.body.appendChild(div);
     editor = BlockNoteEditor.create({ schema });
     editor.mount(div);
   });
@@ -64,728 +27,217 @@ describe("Math block keyboard navigation", () => {
   afterEach(() => {
     editor._tiptapEditor.destroy();
     editor = undefined as any;
+    div.remove();
   });
 
   function setup(blocks: any[]) {
     editor.replaceBlocks(editor.document, blocks);
   }
 
-  describe("from an inline content block (paragraph)", () => {
-    beforeEach(() => {
-      setup([
-        { id: "before", type: "paragraph", content: "before" },
-        { id: "math", type: "math", content: "a^2" },
-        { id: "after", type: "paragraph", content: "after" },
-      ]);
-    });
-
-    it.each(["ArrowRight", "ArrowDown"])(
-      "%s at the end of the previous block selects the whole math node",
-      (key) => {
-        editor.setTextCursorPosition("before", "end");
-        // jsdom can't compute layout, so endOfTextblock is stubbed (single-line
-        // block => on the last visual line).
-        editor.prosemirrorView.endOfTextblock = () => true;
-
-        expect(pressKey(editor, key)).toBe(true);
-        expectMathNodeSelected(editor);
-      },
-    );
-
-    it.each(["ArrowLeft", "ArrowUp"])(
-      "%s at the start of the next block selects the whole math node",
-      (key) => {
-        editor.setTextCursorPosition("after", "start");
-        editor.prosemirrorView.endOfTextblock = () => true;
-
-        expect(pressKey(editor, key)).toBe(true);
-        expectMathNodeSelected(editor);
-      },
-    );
-
-    it("ArrowDown selects the math node from anywhere on the previous block's last line", () => {
-      // Cursor in the *middle* of a single-line paragraph - down should still
-      // reach the math block, not just from the very end.
-      editor.setTextCursorPosition("before", "start");
-      editor.prosemirrorView.endOfTextblock = () => true;
-
-      expect(pressKey(editor, "ArrowDown")).toBe(true);
-      expectMathNodeSelected(editor);
-    });
-
-    it("ArrowDown does not select the math node from an earlier line of the previous block", () => {
-      editor.setTextCursorPosition("before", "start");
-      // Not on the last visual line yet.
-      editor.prosemirrorView.endOfTextblock = () => false;
-
-      expect(pressKey(editor, "ArrowDown")).toBe(false);
-      expect(editor.getTextCursorPosition().block.type).toBe("paragraph");
-    });
-
-    it("ArrowDown with a non-empty selection ending on the last line selects the math node", () => {
-      // A ranged (non-empty) selection only collapses for horizontal arrows;
-      // a vertical arrow from its last line still moves to the next block, which
-      // would otherwise skip the hidden math source.
-      const view = editor.prosemirrorView;
-      editor.setTextCursorPosition("before", "start");
-      const from = view.state.selection.from;
-      editor.setTextCursorPosition("before", "end");
-      const to = view.state.selection.from;
-      view.dispatch(
-        view.state.tr.setSelection(
-          TextSelection.create(view.state.doc, from, to),
-        ),
-      );
-      view.endOfTextblock = () => true;
-
-      expect(pressKey(editor, "ArrowDown")).toBe(true);
-      expectMathNodeSelected(editor);
-    });
-
-    it("ArrowRight with a non-empty selection defers to the default (collapses)", () => {
-      const view = editor.prosemirrorView;
-      editor.setTextCursorPosition("before", "start");
-      const from = view.state.selection.from;
-      editor.setTextCursorPosition("before", "end");
-      const to = view.state.selection.from;
-      view.dispatch(
-        view.state.tr.setSelection(
-          TextSelection.create(view.state.doc, from, to),
-        ),
-      );
-
-      expect(pressKey(editor, "ArrowRight")).toBe(false);
-      expect(editor.getTextCursorPosition().block.id).toBe("before");
-    });
-
-    it("does not hijack navigation away from the block boundary", () => {
-      editor.setTextCursorPosition("before", "start");
-
-      expect(pressKey(editor, "ArrowRight")).toBe(false);
-      expect(editor.getTextCursorPosition().block.type).toBe("paragraph");
-    });
+  /** The preview-with-source-popup root, which holds `data-open`. */
+  function previewRoot(blockId: string): HTMLElement {
+    return div.querySelector(
+      `.bn-block[data-id="${blockId}"] .bn-preview-with-source-popup`,
+    ) as HTMLElement;
+  }
 
-    it("defers to the default when leaving a selected math node for a non-math block", () => {
-      selectBlockNode(editor, "math");
+  /** Whether the source popup is open (the preview is being edited). */
+  function isPopupOpen(blockId: string): boolean {
+    return previewRoot(blockId)?.getAttribute("data-open") === "true";
+  }
 
-      // 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);
-    });
-  });
+  /** Dispatches a keydown on the block's preview, as if the caret were in its
+   * (possibly hidden) source. Returns whether the default was prevented. */
+  function pressKey(
+    blockId: string,
+    key: string,
+    init: KeyboardEventInit = {},
+  ): boolean {
+    const event = new KeyboardEvent("keydown", {
+      key,
+      bubbles: true,
+      cancelable: true,
+      ...init,
+    });
+    previewRoot(blockId).dispatchEvent(event);
+    return event.defaultPrevented;
+  }
 
-  describe("state transitions", () => {
+  describe("with adjacent paragraphs", () => {
     beforeEach(() => {
       setup([
         { id: "before", type: "paragraph", content: "before" },
         { id: "math", type: "math", content: "a^2" },
         { id: "after", type: "paragraph", content: "after" },
       ]);
+      editor.setTextCursorPosition("math", "start");
     });
 
-    it("Enter on the selected math node starts editing at its content start", () => {
-      selectBlockNode(editor, "math");
+    it("Enter opens the source popup, keeping the caret in the source", () => {
+      expect(isPopupOpen("math")).toBe(false);
 
-      expect(pressKey(editor, "Enter")).toBe(true);
+      expect(pressKey("math", "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);
+      expect(isPopupOpen("math")).toBe(true);
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
     });
 
-    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("Enter again closes the source popup", () => {
+      pressKey("math", "Enter");
+      expect(isPopupOpen("math")).toBe(true);
 
-    it("ArrowRight at the end of the content defers to the default for a non-math next block", () => {
-      editor.setTextCursorPosition("math", "end");
+      expect(pressKey("math", "Enter")).toBe(true);
 
-      // 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);
+      expect(isPopupOpen("math")).toBe(false);
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
     });
 
-    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("Escape closes the source popup while editing", () => {
+      pressKey("math", "Enter");
+      expect(isPopupOpen("math")).toBe(true);
 
-    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("math", "Escape")).toBe(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);
+      expect(isPopupOpen("math")).toBe(false);
     });
 
-    it("ArrowUp at the top of the content moves to the end of the previous block", () => {
-      editor.setTextCursorPosition("math", "start");
-      editor.prosemirrorView.endOfTextblock = () => true;
+    it("Escape leaves an already-closed popup closed", () => {
+      expect(isPopupOpen("math")).toBe(false);
+
+      // Defers to the default; our handler doesn't touch the popup state.
+      pressKey("math", "Escape");
 
-      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);
+      expect(isPopupOpen("math")).toBe(false);
     });
 
-    it("ArrowRight in the middle of the content stays in the math block", () => {
-      editor.setTextCursorPosition("math", "start");
+    it("ArrowRight while the popup is hidden moves to the next block", () => {
+      expect(pressKey("math", "ArrowRight")).toBe(true);
 
-      expect(pressKey(editor, "ArrowRight")).toBe(false);
-      expect(editor.getTextCursorPosition().block.type).toBe("math");
+      expect(editor.getTextCursorPosition().block.id).toBe("after");
     });
-  });
 
-  describe("between adjacent math blocks", () => {
-    beforeEach(() => {
-      setup([
-        { id: "m1", type: "math", content: "a^2" },
-        { id: "m2", type: "math", content: "b^2" },
-      ]);
-    });
+    it("ArrowLeft while the popup is hidden moves to the previous block", () => {
+      expect(pressKey("math", "ArrowLeft")).toBe(true);
 
-    /** 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");
+      expect(editor.getTextCursorPosition().block.id).toBe("before");
     });
-  });
 
-  describe("from a no-content block (image)", () => {
-    it("forward keys from a selected image before the math block select it", () => {
-      setup([
-        { id: "img", type: "image" },
-        { id: "math", type: "math", content: "a^2" },
-      ]);
-      selectBlockNode(editor, "img");
+    it("ArrowRight with Ctrl/Cmd held defers to the default (no block jump)", () => {
+      // A modifier turns the arrow into a shortcut (e.g. word/line navigation),
+      // so we don't hijack it to move between blocks.
+      expect(pressKey("math", "ArrowRight", { ctrlKey: true })).toBe(false);
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
 
-      expect(pressKey(editor, "ArrowRight")).toBe(true);
-      expectMathNodeSelected(editor);
+      expect(pressKey("math", "ArrowRight", { metaKey: true })).toBe(false);
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
     });
 
-    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");
+    it("ArrowRight while editing defers to the default (navigates the source)", () => {
+      pressKey("math", "Enter");
+      expect(isPopupOpen("math")).toBe(true);
+
+      // The arrow isn't hijacked: we stay in the math block with the popup open.
+      pressKey("math", "ArrowRight");
 
-      expect(pressKey(editor, "ArrowRight")).toBe(false);
-      expect(editor.getTextCursorPosition().block.type).not.toBe("math");
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
+      expect(isPopupOpen("math")).toBe(true);
     });
 
-    it("backward keys from a selected image after the math block select it", () => {
-      setup([
-        { id: "math", type: "math", content: "a^2" },
-        { id: "img", type: "image" },
-      ]);
-      selectBlockNode(editor, "img");
+    it("blocks character input while the popup is closed", () => {
+      expect(isPopupOpen("math")).toBe(false);
 
-      expect(pressKey(editor, "ArrowUp")).toBe(true);
-      expectMathNodeSelected(editor);
+      // The source is hidden, so the keystroke is swallowed (prevented) rather
+      // than silently editing the source the user can't see.
+      expect(pressKey("math", "a")).toBe(true);
     });
-  });
 
-  describe("from a table", () => {
-    function makeTable(rows: number, cols: number) {
-      return {
-        type: "tableContent" as const,
-        rows: Array.from({ length: rows }, () => ({
-          cells: Array.from({ length: cols }, () => "x"),
-        })),
-      };
-    }
-
-    it("forward keys from the last cell select the following math block", () => {
-      setup([
-        { id: "table", type: "table", content: makeTable(2, 2) },
-        { id: "math", type: "math", content: "a^2" },
-      ]);
-      // Place the cursor in the last cell (bottom-right).
-      const view = editor.prosemirrorView;
-      let lastCellEnd = 0;
-      view.state.doc.descendants((node, pos) => {
-        if (node.type.name === "tableParagraph") {
-          lastCellEnd = pos + node.nodeSize - 1;
-        }
-        return true;
-      });
-      view.dispatch(
-        view.state.tr.setSelection(
-          TextSelection.create(view.state.doc, lastCellEnd),
-        ),
-      );
+    it("defers character input to the default while the popup is open", () => {
+      pressKey("math", "Enter");
+      expect(isPopupOpen("math")).toBe(true);
 
-      expect(pressKey(editor, "ArrowRight")).toBe(true);
-      expectMathNodeSelected(editor);
+      // The source is visible, so we don't swallow the key - ProseMirror gets to
+      // handle it as normal text input.
+      expect(pressKey("math", "a")).toBe(false);
     });
 
-    it("ArrowDown from a bottom-row, non-corner cell selects the following math block", () => {
-      setup([
-        { id: "table", type: "table", content: makeTable(2, 2) },
-        { id: "math", type: "math", content: "a^2" },
-      ]);
-      // Cursor in the bottom-LEFT cell (bottom row, but not the document-order
-      // corner), so only the table vertical-edge path can catch it.
-      const view = editor.prosemirrorView;
-      const cellStarts: number[] = [];
-      view.state.doc.descendants((node, pos) => {
-        if (node.type.name === "tableParagraph") {
-          cellStarts.push(pos + 1);
-        }
-        return true;
-      });
-      // 2x2 table: cells are [TL, TR, BL, BR]; bottom-left is index 2.
-      view.dispatch(
-        view.state.tr.setSelection(
-          TextSelection.create(view.state.doc, cellStarts[2]),
-        ),
-      );
-      // jsdom can't compute layout, so endOfTextblock is stubbed (single-line
-      // cell => at the bottom visual line).
-      view.endOfTextblock = () => true;
+    it("blocks deletion and indent keys while the popup is closed", () => {
+      expect(isPopupOpen("math")).toBe(false);
 
-      expect(pressKey(editor, "ArrowDown")).toBe(true);
-      expectMathNodeSelected(editor);
+      // These all edit the hidden source, so they're swallowed.
+      expect(pressKey("math", "Backspace")).toBe(true);
+      expect(pressKey("math", "Delete")).toBe(true);
+      expect(pressKey("math", "Tab")).toBe(true);
     });
 
-    it("backward keys from the first cell select the preceding math block", () => {
-      setup([
-        { id: "math", type: "math", content: "a^2" },
-        { id: "table", type: "table", content: makeTable(2, 2) },
-      ]);
-      const view = editor.prosemirrorView;
-      let firstCellStart: number | undefined;
-      view.state.doc.descendants((node, pos) => {
-        if (
-          node.type.name === "tableParagraph" &&
-          firstCellStart === undefined
-        ) {
-          firstCellStart = pos + 1;
-        }
-        return true;
-      });
-      view.dispatch(
-        view.state.tr.setSelection(
-          TextSelection.create(view.state.doc, firstCellStart!),
-        ),
-      );
+    it("defers Ctrl/Cmd shortcuts to the default while the popup is closed", () => {
+      expect(isPopupOpen("math")).toBe(false);
 
-      expect(pressKey(editor, "ArrowLeft")).toBe(true);
-      expectMathNodeSelected(editor);
+      // Single-character keys are only blocked when no Ctrl/Cmd is held, so
+      // shortcuts pass through - keeping copy/select-all/find working.
+      // (Cut/paste also pass through; that's a known limitation.)
+      expect(pressKey("math", "c", { ctrlKey: true })).toBe(false);
+      expect(pressKey("math", "a", { ctrlKey: true })).toBe(false);
+      expect(pressKey("math", "f", { ctrlKey: true })).toBe(false);
+      expect(pressKey("math", "v", { metaKey: true })).toBe(false);
     });
 
-    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;
+    it("defers deletion keys to the default while the popup is open", () => {
+      pressKey("math", "Enter");
+      expect(isPopupOpen("math")).toBe(true);
 
-      pressKey(editor, "ArrowDown");
-      expect(editor.getTextCursorPosition().block.type).toBe("table");
+      // The source is visible, so deletion is allowed through to ProseMirror.
+      expect(pressKey("math", "Backspace")).toBe(false);
     });
+  });
 
-    it("ArrowRight from a non-last cell stays in the table", () => {
+  describe("at the document edges", () => {
+    it("ArrowLeft with no previous block defers to the default", () => {
       setup([
-        { id: "table", type: "table", content: makeTable(2, 2) },
         { id: "math", type: "math", content: "a^2" },
+        { id: "after", type: "paragraph", content: "after" },
       ]);
-      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!),
-        ),
-      );
+      editor.setTextCursorPosition("math", "start");
 
-      pressKey(editor, "ArrowRight");
-      expect(editor.getTextCursorPosition().block.type).toBe("table");
+      // No previous block to jump to, so the arrow isn't hijacked.
+      pressKey("math", "ArrowLeft");
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
     });
-  });
 
-  describe("selection decoration", () => {
-    /** The element carrying the standard "selected node" class, if any. */
-    function selectedPreviewEl() {
-      return div.querySelector(".ProseMirror-selectednode");
-    }
-
-    beforeEach(() => {
+    it("ArrowRight with no next block defers to the default", () => {
       setup([
         { id: "before", type: "paragraph", content: "before" },
         { id: "math", type: "math", content: "a^2" },
       ]);
-    });
-
-    it("adds the class to the block while editing its content", () => {
-      editor.setTextCursorPosition("math", "start");
-
-      const el = selectedPreviewEl();
-      expect(el).not.toBeNull();
-      // The class lands on the block content wrapper, with the preview inside.
-      expect(el!.querySelector(".bn-code-block-preview")).not.toBeNull();
-    });
-
-    it("keeps the class when moving from the whole node into editing its content", () => {
-      // Reproduces the regression where ProseMirror's `deselectNode` strips the
-      // class on the node-selection -> text-selection transition: select the
-      // whole node, then Enter to start editing.
-      selectBlockNode(editor, "math");
-      expect(selectedPreviewEl()).not.toBeNull();
-
-      expect(pressKey(editor, "Enter")).toBe(true);
-      expect(selectedPreviewEl()).not.toBeNull();
-    });
-
-    it("does not add the class while the selection is in another block", () => {
-      editor.setTextCursorPosition("before", "end");
-
-      expect(selectedPreviewEl()).toBeNull();
-    });
-
-    it("removes the class when the selection leaves the block", () => {
       editor.setTextCursorPosition("math", "start");
-      expect(selectedPreviewEl()).not.toBeNull();
 
-      editor.setTextCursorPosition("before", "end");
-      expect(selectedPreviewEl()).toBeNull();
+      // No next block to jump to, so the arrow isn't hijacked.
+      pressKey("math", "ArrowRight");
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
     });
   });
 
-  describe("formatting toolbar suppression", () => {
-    const toolbarShown = () =>
-      editor.getExtension(FormattingToolbarExtension)!.store.state;
-
+  describe("clicking the preview", () => {
     beforeEach(() => {
       setup([
         { id: "before", type: "paragraph", content: "before" },
-        { id: "math", type: "math", content: "a^2+b^2" },
+        { id: "math", type: "math", content: "a^2" },
       ]);
+      editor.setTextCursorPosition("before", "start");
     });
 
-    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");
+    it("opens the popup and places the cursor at the source end", () => {
+      const preview = div.querySelector(
+        `.bn-block[data-id="math"] .bn-preview-container`,
+      ) as HTMLElement;
 
-      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!),
-        ),
+      preview.dispatchEvent(
+        new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
       );
 
-      expect(toolbarShown()).toBe(false);
+      expect(isPopupOpen("math")).toBe(true);
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
+      // The cursor lands at the end of the source (after "a^2").
+      expect(editor.prosemirrorView.state.selection.$from.parentOffset).toBe(3);
     });
   });
 });
-
-describe("Math block nested navigation", () => {
-  // Columns aren't a core block, so register them alongside the math block.
-  const nestedSchema = BlockNoteSchema.create().extend({
-    blockSpecs: {
-      math: createMathBlockSpec(),
-      column: ColumnBlock,
-      columnList: ColumnListBlock,
-    },
-  });
-
-  let editor: BlockNoteEditor;
-  const div = document.createElement("div");
-
-  beforeEach(() => {
-    editor = BlockNoteEditor.create({ schema: nestedSchema });
-    editor.mount(div);
-  });
-
-  afterEach(() => {
-    editor._tiptapEditor.destroy();
-    editor = undefined as any;
-  });
-
-  it.each(["ArrowDown", "ArrowRight"])(
-    "%s into a column selects a math block nested as its first block",
-    (key) => {
-      editor.replaceBlocks(editor.document, [
-        { id: "before", type: "paragraph", content: "before" },
-        {
-          type: "columnList",
-          children: [
-            {
-              type: "column",
-              children: [
-                { id: "nested-math", type: "math", content: "a^2" },
-                { type: "paragraph", content: "x" },
-              ],
-            },
-            { type: "column", children: [{ type: "paragraph", content: "y" }] },
-          ],
-        },
-      ] as any);
-      editor.setTextCursorPosition("before", "end");
-      // jsdom can't compute layout (needed for the vertical edge check).
-      editor.prosemirrorView.endOfTextblock = () => true;
-
-      expect(pressKey(editor, key)).toBe(true);
-      const { selection } = editor.prosemirrorView.state;
-      expect("node" in selection).toBe(true);
-      expect((selection as NodeSelection).node.type.name).toBe("math");
-      expect(editor.getTextCursorPosition().block.id).toBe("nested-math");
-    },
-  );
-});
-
-describe("Math block navigation from selected inline content", () => {
-  // A no-content inline node (like a mention) can be selected as a node, which
-  // is distinct from selecting the whole block.
-  const mention = createInlineContentSpec(
-    { type: "mention", propSchema: { user: { default: "" } }, content: "none" },
-    {
-      render: (ic) => {
-        const dom = document.createElement("span");
-        dom.textContent = `@${ic.props.user}`;
-        return { dom };
-      },
-    },
-  );
-  const inlineSchema = BlockNoteSchema.create({
-    inlineContentSpecs: { mention, ...defaultInlineContentSpecs },
-  }).extend({ blockSpecs: { math: createMathBlockSpec() } });
-
-  let editor: BlockNoteEditor;
-  const div = document.createElement("div");
-
-  beforeEach(() => {
-    editor = BlockNoteEditor.create({ schema: inlineSchema });
-    editor.mount(div);
-    editor.replaceBlocks(editor.document, [
-      {
-        id: "p",
-        type: "paragraph",
-        content: [
-          "hi ",
-          { type: "mention", props: { user: "M" }, content: undefined } as any,
-        ],
-      },
-      { id: "math", type: "math", content: "a^2" },
-    ]);
-  });
-
-  afterEach(() => {
-    editor._tiptapEditor.destroy();
-    editor = undefined as any;
-  });
-
-  /** Selects the inline mention node (distinct from selecting the block). */
-  function selectMention() {
-    const view = editor.prosemirrorView;
-    let pos: number | undefined;
-    view.state.doc.descendants((node, p) => {
-      if (node.type.name === "mention") {
-        pos = p;
-        return false;
-      }
-      return true;
-    });
-    view.dispatch(
-      view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos!)),
-    );
-  }
-
-  it("ArrowDown from a node-selected inline node on the last line selects the next math block", () => {
-    selectMention();
-    editor.prosemirrorView.endOfTextblock = () => true;
-
-    expect(pressKey(editor, "ArrowDown")).toBe(true);
-    const { selection } = editor.prosemirrorView.state;
-    expect("node" in selection).toBe(true);
-    expect((selection as NodeSelection).node.type.name).toBe("math");
-  });
-
-  it("ArrowRight from a node-selected inline node defers to the default (stays in the block)", () => {
-    selectMention();
-
-    // A node selection isn't the whole block, so horizontal arrows must not jump
-    // to the math block - they move within the block by default.
-    expect(pressKey(editor, "ArrowRight")).toBe(false);
-    expect(editor.getTextCursorPosition().block.id).toBe("p");
-  });
-});
-
-describe("Math block MathML interchange", () => {
-  let editor: BlockNoteEditor;
-  const div = document.createElement("div");
-
-  beforeEach(() => {
-    editor = BlockNoteEditor.create({ schema });
-    editor.mount(div);
-  });
-
-  afterEach(() => {
-    editor._tiptapEditor.destroy();
-    editor = undefined as any;
-  });
-
-  // Parses HTML and returns the LaTeX source of the first math block.
-  const parseMathLatex = (html: string) => {
-    const blocks = editor.tryParseHTMLToBlocks(html);
-    const mathBlock = blocks.find((block) => block.type === "math");
-    if (!mathBlock) {
-      throw new Error(`No math block parsed from: ${html}`);
-    }
-    return (mathBlock.content as any[]).map((node) => node.text ?? "").join("");
-  };
-
-  it("exports a math block to a  (MathML) element", () => {
-    expect(
-      editor.blocksToHTMLLossy([
-        { type: "math", content: "a^2 + b^2 = c^2" } as any,
-      ]),
-    ).toMatchInlineSnapshot(
-      `"a2+b2=c2a^2 + b^2 = c^2"`,
-    );
-  });
-
-  it("parses a plain  element into LaTeX", () => {
-    expect(
-      parseMathLatex("a2"),
-    ).toMatchInlineSnapshot(`"a^{2}"`);
-  });
-
-  it("parses a  element using its LaTeX annotation when present", () => {
-    expect(
-      parseMathLatex(
-        'a\\frac{a}{b}',
-      ),
-    ).toMatchInlineSnapshot(`"\\frac{a}{b}"`);
-  });
-
-  it("round-trips LaTeX through MathML export and back", () => {
-    const latex = "a^2 + b^2 = c^2";
-
-    const html = editor.blocksToHTMLLossy([
-      { type: "math", content: latex } as any,
-    ]);
-
-    // The exported MathML is annotated with the original TeX, so it round-trips
-    // back to exactly the same LaTeX.
-    expect(parseMathLatex(html)).toBe(latex);
-  });
-});
diff --git a/packages/math-block/src/block.ts b/packages/math-block/src/block.ts
index ba937729cc..76c8cbb545 100644
--- a/packages/math-block/src/block.ts
+++ b/packages/math-block/src/block.ts
@@ -1,8 +1,7 @@
 import {
   createBlockConfig,
   createBlockSpec,
-  createPreviewSourceNavigationExtension,
-  createPreviewWithSourcePopup,
+  createSourceBlockWithPreview,
 } from "@blocknote/core";
 import {
   parseMathML,
@@ -22,19 +21,17 @@ export const createMathBlockConfig = createBlockConfig(
     }) as const,
 );
 
-export const createMathBlockSpec = createBlockSpec(
-  createMathBlockConfig,
-  {
-    meta: {
-      code: true,
-      defining: true,
-      isolating: false,
-    },
-    parse: (el) => parseMathML(el),
-    parseContent: ({ el, schema }) => parseMathMLContent({ el, schema }),
-    render: (block, editor) =>
-      createPreviewWithSourcePopup({})(block, editor, createMathPreview),
-    toExternalHTML: (block) => createMathML(block),
+export const createMathBlockSpec = createBlockSpec(createMathBlockConfig, {
+  meta: {
+    code: true,
+    defining: true,
+    isolating: false,
   },
-  [createPreviewSourceNavigationExtension("math-block-navigation", "math")],
-);
+  parse: (el) => parseMathML(el),
+  parseContent: ({ el, schema }) => parseMathMLContent({ el, schema }),
+  render: (block, editor) =>
+    createSourceBlockWithPreview(block, editor, {
+      createPreview: createMathPreview,
+    }),
+  toExternalHTML: (block) => createMathML(block),
+});
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 3518018abd..3e69de7ac3 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -1453,10 +1453,17 @@ export const examples = {
           playground: true,
           docs: true,
           author: "matthewlipski",
-          tags: ["Intermediate", "Blocks", "Custom Schemas"],
+          tags: [
+            "Intermediate",
+            "Blocks",
+            "Custom Schemas",
+            "Suggestion Menus",
+            "Slash Menu",
+          ],
           dependencies: {
             "@blocknote/code-block": "latest",
             "@blocknote/math-block": "latest",
+            "react-icons": "^5.5.0",
           } as any,
         },
         title: "Math Block",
diff --git a/playground/vite.config.ts b/playground/vite.config.ts
index c513f5c347..1a8e8590ca 100644
--- a/playground/vite.config.ts
+++ b/playground/vite.config.ts
@@ -35,6 +35,7 @@ const devAliases: Record = {
     __dirname,
     "../packages/xl-email-exporter/src",
   ),
+  "@blocknote/math-block": resolve(__dirname, "../packages/math-block/src"),
   // "@liveblocks/react-blocknote": resolve(
   //   __dirname,
   //   "../../liveblocks/packages/liveblocks-react-blocknote/src/",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d14acfd5c2..42faa023b5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3396,6 +3396,9 @@ importers:
       react-dom:
         specifier: ^19.2.3
         version: 19.2.5(react@19.2.5)
+      react-icons:
+        specifier: ^5.5.0
+        version: 5.6.0(react@19.2.5)
     devDependencies:
       '@types/react':
         specifier: ^19.2.3
@@ -20195,7 +20198,6 @@ 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:
@@ -20224,7 +20226,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@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: 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/utils@4.1.5':
     dependencies:
@@ -26208,7 +26210,6 @@ 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:
@@ -26302,7 +26303,6 @@ snapshots:
       jsdom: 29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0)
     transitivePeerDependencies:
       - msw
-    optional: true
 
   w3c-keyname@2.2.8: {}
 

From c8388ccd1fa037f6ddadd62d1543f678469b387c Mon Sep 17 00:00:00 2001
From: Matthew Lipski 
Date: Tue, 23 Jun 2026 13:49:02 +0200
Subject: [PATCH 08/21] Fixed build issue

---
 playground/package.json | 1 +
 pnpm-lock.yaml          | 3 +++
 2 files changed, 4 insertions(+)

diff --git a/playground/package.json b/playground/package.json
index a14ad91238..246bdf865d 100644
--- a/playground/package.json
+++ b/playground/package.json
@@ -17,6 +17,7 @@
     "@blocknote/code-block": "workspace:^",
     "@blocknote/core": "workspace:^",
     "@blocknote/mantine": "workspace:^",
+    "@blocknote/math-block": "workspace:^",
     "@blocknote/react": "workspace:^",
     "@blocknote/server-util": "workspace:^",
     "@blocknote/shadcn": "workspace:^",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 42faa023b5..58d1b601e4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5559,6 +5559,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

From f2bfe62584945b270ef46573920f661184f293e3 Mon Sep 17 00:00:00 2001
From: Matthew Lipski 
Date: Tue, 23 Jun 2026 13:52:41 +0200
Subject: [PATCH 09/21] Reverted `inert`

---
 .../blocks/Code/helpers/render/createSourceBlockWithPreview.ts   | 1 -
 1 file changed, 1 deletion(-)

diff --git a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts
index 2c9594afd6..5fe0a5d8ae 100644
--- a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts
+++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts
@@ -294,7 +294,6 @@ export const createSourceBlockWithPreview = (
 
   const sourceBlockPopup = document.createElement("div");
   sourceBlockPopup.className = "bn-source-block-popup";
-  sourceBlockPopup.inert = true;
   sourceBlockPopup.appendChild(sourceBlock.dom);
 
   const errorMessage = "error" in preview && preview.error ? preview.error : "";

From 57c459a1c12f38876aab831d1c6b52c1fb579d80 Mon Sep 17 00:00:00 2001
From: Matthew Lipski 
Date: Tue, 23 Jun 2026 14:07:28 +0200
Subject: [PATCH 10/21] Implemented PR feedback

---
 packages/core/src/blocks/Code/block.ts                    | 2 --
 .../src/blocks/Code/helpers/render/createSourceBlock.ts   | 4 ++++
 .../Code/helpers/render/createSourceBlockWithPreview.ts   | 8 ++++++++
 packages/math-block/src/helpers/getMathSource.ts          | 4 +++-
 4 files changed, 15 insertions(+), 3 deletions(-)

diff --git a/packages/core/src/blocks/Code/block.ts b/packages/core/src/blocks/Code/block.ts
index eab9886c6d..6010ef96dc 100644
--- a/packages/core/src/blocks/Code/block.ts
+++ b/packages/core/src/blocks/Code/block.ts
@@ -40,8 +40,6 @@ export const createCodeBlockSpec = createBlockSpec(
         options.supportedLanguages && {
           selectedLanguage: block.props.language,
           supportedLanguages: options.supportedLanguages,
-          createPreview:
-            options.supportedLanguages[block.props.language].createPreview,
         },
       ),
     toExternalHTML: (block) => createPreCode(block),
diff --git a/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts
index 04f739982a..1f96a600a9 100644
--- a/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts
+++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts
@@ -27,6 +27,10 @@ export const createLanguageSelect = (
   select.value = selectedLanguage;
 
   const handleLanguageChange = (event: Event) => {
+    if (!editor.isEditable) {
+      return;
+    }
+
     editor.updateBlock(block.id, {
       props: { language: (event.target as HTMLSelectElement).value },
     });
diff --git a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts
index 5fe0a5d8ae..9f3a57c789 100644
--- a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts
+++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts
@@ -252,6 +252,14 @@ export const createSourceBlockWithPreview = (
         createPreview: CodeBlockPreview;
       },
 ) => {
+  if (
+    options &&
+    "selectedLanguage" in options &&
+    !(options.selectedLanguage in options.supportedLanguages)
+  ) {
+    throw new Error(`Language ${options.selectedLanguage} is not supported.`);
+  }
+
   const sourceBlock = createSourceBlock(
     block,
     editor,
diff --git a/packages/math-block/src/helpers/getMathSource.ts b/packages/math-block/src/helpers/getMathSource.ts
index 9cc1fee0e4..779724ddff 100644
--- a/packages/math-block/src/helpers/getMathSource.ts
+++ b/packages/math-block/src/helpers/getMathSource.ts
@@ -7,7 +7,9 @@ export const getMathSource = (block: { content: unknown }): string => {
   }
   if (Array.isArray(block.content)) {
     return block.content
-      .map((node) => ("text" in node ? node.text : ""))
+      .map((node) =>
+        node && typeof node === "object" && "text" in node ? node.text : "",
+      )
       .join("");
   }
   return "";

From 3a1e4cac781133eed99005b20648b7052fc2116d Mon Sep 17 00:00:00 2001
From: Matthew Lipski 
Date: Tue, 23 Jun 2026 14:52:00 +0200
Subject: [PATCH 11/21] Fixed test editor having unsupported default language
 for code block

---
 .../copy/__snapshots__/text/html/basicBlocks.html          | 2 +-
 .../clipboard/copy/__snapshots__/text/plain/basicBlocks.md | 2 +-
 tests/src/unit/core/createTestEditor.ts                    | 5 +++++
 .../blocknoteHTML/codeBlock/contains-newlines.html         | 7 ++-----
 .../blocknoteHTML/codeBlock/defaultLanguage.html           | 1 +
 .../__snapshots__/blocknoteHTML/codeBlock/empty.html       | 1 +
 .../__snapshots__/blocknoteHTML/codeBlock/python.html      | 1 +
 .../__snapshots__/blocknoteHTML/complex/document.html      | 7 ++-----
 .../__snapshots__/html/codeBlock/contains-newlines.html    | 2 +-
 .../__snapshots__/html/codeBlock/defaultLanguage.html      | 2 +-
 .../export/__snapshots__/html/codeBlock/empty.html         | 2 +-
 .../export/__snapshots__/html/complex/document.html        | 2 +-
 .../__snapshots__/markdown/codeBlock/defaultLanguage.md    | 2 +-
 .../export/__snapshots__/markdown/codeBlock/empty.md       | 2 +-
 .../__snapshots__/nodes/codeBlock/defaultLanguage.json     | 2 +-
 .../export/__snapshots__/nodes/codeBlock/empty.json        | 2 +-
 .../markdown/markdown/specialCharEscaping.json             | 2 +-
 .../parse/__snapshots__/html/codeBlocks.json               | 2 +-
 .../parse/__snapshots__/html/codeBlocksMultiLine.json      | 2 +-
 .../parse/__snapshots__/markdown/codeBlockBasic.json       | 2 +-
 .../parse/__snapshots__/markdown/codeBlockTildes.json      | 2 +-
 tests/src/unit/core/schema/__snapshots__/blocks.json       | 2 +-
 22 files changed, 28 insertions(+), 26 deletions(-)

diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/basicBlocks.html b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/basicBlocks.html
index b59aa81d46..0d6a42d952 100644
--- a/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/basicBlocks.html
+++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/basicBlocks.html
@@ -22,7 +22,7 @@ 

Heading 1

-  console.log("Hello World");
+  console.log("Hello World");
 
diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/basicBlocks.md b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/basicBlocks.md index 59e94f2356..378193ad13 100644 --- a/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/basicBlocks.md +++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/basicBlocks.md @@ -8,7 +8,7 @@ Paragraph 1 * [ ] Check List Item 1 * Toggle List Item 1 -```text +```javascript console.log("Hello World"); ``` diff --git a/tests/src/unit/core/createTestEditor.ts b/tests/src/unit/core/createTestEditor.ts index aa804ffdd6..26c9324f91 100644 --- a/tests/src/unit/core/createTestEditor.ts +++ b/tests/src/unit/core/createTestEditor.ts @@ -26,11 +26,16 @@ export const createTestEditor = < schema: schema.extend({ blockSpecs: { codeBlock: createCodeBlockSpec({ + defaultLanguage: "javascript", supportedLanguages: { javascript: { name: "JavaScript", aliases: ["js"], }, + typescript: { + name: "TypeScript", + aliases: ["ts"], + }, python: { name: "Python", aliases: ["py"], diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/contains-newlines.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/contains-newlines.html index bf789c1a7d..553b646dbc 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/contains-newlines.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/contains-newlines.html @@ -1,14 +1,11 @@
-
+
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/defaultLanguage.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/defaultLanguage.html index 861d648003..b5b31e8062 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/defaultLanguage.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/defaultLanguage.html @@ -5,6 +5,7 @@
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/empty.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/empty.html index ce97dbaaac..8aac992379 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/empty.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/empty.html @@ -5,6 +5,7 @@
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/python.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/python.html index 1223a7d041..0d65939e44 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/python.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/codeBlock/python.html @@ -9,6 +9,7 @@
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/document.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/document.html index 4376ebf7f1..b87505e81f 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/document.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/document.html @@ -70,14 +70,11 @@

Section 1

-
+
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/contains-newlines.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/contains-newlines.html index a7db81b06b..ea6a3e8a21 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/contains-newlines.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/contains-newlines.html @@ -1,4 +1,4 @@ -
+
   const hello = 'world';
 console.log(hello);
 
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/defaultLanguage.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/defaultLanguage.html
index c5939c1b5e..d9a00bc084 100644
--- a/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/defaultLanguage.html
+++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/defaultLanguage.html
@@ -1,3 +1,3 @@
 
-  console.log('Hello, world!');
+  console.log('Hello, world!');
 
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/empty.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/empty.html index 9bbe62c374..f2e39bcbc7 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/empty.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/codeBlock/empty.html @@ -1,3 +1,3 @@
-  
+  
 
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/complex/document.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/complex/document.html index 421d420c08..47caad18e7 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/html/complex/document.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/complex/document.html @@ -25,6 +25,6 @@

Section 1


A notable quote
-
+
   const x = 42;
 
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/codeBlock/defaultLanguage.md b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/codeBlock/defaultLanguage.md index f5b118ae95..eca2b94e33 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/codeBlock/defaultLanguage.md +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/codeBlock/defaultLanguage.md @@ -1,3 +1,3 @@ -```text +```javascript console.log('Hello, world!'); ``` diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/codeBlock/empty.md b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/codeBlock/empty.md index b5c9416ec5..04144d877f 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/codeBlock/empty.md +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/codeBlock/empty.md @@ -1,2 +1,2 @@ -```text +```javascript ``` diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/codeBlock/defaultLanguage.json b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/codeBlock/defaultLanguage.json index e25d8ad37a..cb4329b686 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/codeBlock/defaultLanguage.json +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/codeBlock/defaultLanguage.json @@ -6,7 +6,7 @@ "content": [ { "attrs": { - "language": "text", + "language": "javascript", }, "content": [ { diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/codeBlock/empty.json b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/codeBlock/empty.json index fc526a8406..a278421822 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/codeBlock/empty.json +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/codeBlock/empty.json @@ -6,7 +6,7 @@ "content": [ { "attrs": { - "language": "text", + "language": "javascript", }, "type": "codeBlock", }, diff --git a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/specialCharEscaping.json b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/specialCharEscaping.json index 0ede1c2000..aae0b9afdc 100644 --- a/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/specialCharEscaping.json +++ b/tests/src/unit/core/formatConversion/exportParseEquality/__snapshots__/markdown/markdown/specialCharEscaping.json @@ -117,7 +117,7 @@ const y = '```triple backticks```';", ], "id": "5", "props": { - "language": "text", + "language": "javascript", }, "type": "codeBlock", }, diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocks.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocks.json index f9bd791440..f4a808da84 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocks.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocks.json @@ -10,7 +10,7 @@ ], "id": "1", "props": { - "language": "text", + "language": "javascript", }, "type": "codeBlock", }, diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocksMultiLine.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocksMultiLine.json index 6cb94084f1..4d8c20bb1a 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocksMultiLine.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/codeBlocksMultiLine.json @@ -12,7 +12,7 @@ console.log("Third Line")", ], "id": "1", "props": { - "language": "text", + "language": "javascript", }, "type": "codeBlock", }, diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockBasic.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockBasic.json index cf59869a6f..fc0a925232 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockBasic.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockBasic.json @@ -10,7 +10,7 @@ ], "id": "1", "props": { - "language": "text", + "language": "javascript", }, "type": "codeBlock", }, diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockTildes.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockTildes.json index 1a656bd726..a3da1d66c6 100644 --- a/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockTildes.json +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/markdown/codeBlockTildes.json @@ -10,7 +10,7 @@ ], "id": "1", "props": { - "language": "text", + "language": "javascript", }, "type": "codeBlock", }, diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json index ef3a4f138e..4b0e8678a1 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -121,7 +121,7 @@ "content": "inline", "propSchema": { "language": { - "default": "text", + "default": "javascript", }, }, "type": "codeBlock", From ed8e6c1ee20c7ff168ff5760be6601fa7067d50e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 23 Jun 2026 14:53:52 +0200 Subject: [PATCH 12/21] Fixed export error --- .../Code/helpers/render/createSourceBlockWithPreview.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts index 9f3a57c789..2f5ca733cb 100644 --- a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts +++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts @@ -266,9 +266,12 @@ export const createSourceBlockWithPreview = ( options && "selectedLanguage" in options ? options : undefined, ); - const sourceCode = (block.content as StyledText[]) - .map(({ text }) => text) - .join(""); + const sourceCode = + typeof block.content === "string" + ? block.content + : Array.isArray(block.content) + ? (block.content as StyledText[]).map(({ text }) => text).join("") + : ""; // Tracks the source the preview was last rendered from, so `update` can tell // a source-text change (which it handles in place) from any other update. From ce997704e06722c4a92bad482faa2a07434cebe1 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 24 Jun 2026 15:59:58 +0200 Subject: [PATCH 13/21] Removed FloatingUI positioning from math block popup --- packages/core/package.json | 1 - .../render/createSourceBlockWithPreview.ts | 47 ------------------- packages/core/src/editor/Block.css | 3 ++ pnpm-lock.yaml | 3 -- 4 files changed, 3 insertions(+), 51 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index a5f6087d78..72b58d02c3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -90,7 +90,6 @@ }, "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/helpers/render/createSourceBlockWithPreview.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts index 2f5ca733cb..d5bd281942 100644 --- a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts +++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts @@ -1,11 +1,3 @@ -import { - autoUpdate, - computePosition, - flip, - offset, - shift, - size, -} 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"; @@ -198,39 +190,6 @@ const handleSelectionChange = ( return { destroy }; }; -// Handles positioning for the popup, including edge cases where it doesn't fit in the viewport. -// TODO: Would be nice to replace this logic with CSS anchors: -// https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/anchor -const positionSourcePopup = ( - preview: HTMLElement, - sourcePopup: HTMLElement, -) => { - const destroy = autoUpdate(preview, sourcePopup, async () => { - const { x, y } = await computePosition(preview, sourcePopup, { - placement: "bottom-start", - middleware: [ - offset(4), - flip(), - shift({ padding: 4 }), - // Match the popup's width to the block. - size({ - apply({ rects, elements }) { - const blockContent = preview.closest(".bn-block-content"); - const width = - blockContent?.getBoundingClientRect().width ?? - rects.reference.width; - elements.floating.style.width = `${width}px`; - }, - }), - ], - }); - sourcePopup.style.left = `${x}px`; - sourcePopup.style.top = `${y}px`; - }); - - return { destroy }; -}; - // Renders a preview which can be clicked to show the block's inline content as code in a popup, // alongside a language picker if multiple languages are supported. If no preview is provided, just // renders the same thing as `createSourceBlock`. @@ -345,11 +304,6 @@ export const createSourceBlockWithPreview = ( setSourcePopupOpen, ); - const sourcePopupPositioner = positionSourcePopup( - previewContainer, - sourceBlockPopup, - ); - return { dom: previewWithSourcePopup, contentDOM: sourceBlock.contentDOM, @@ -402,7 +356,6 @@ export const createSourceBlockWithPreview = ( keyboardNavigationHandler.destroy(); previewMouseDownHandler.destroy(); selectionMoveOutHandler.destroy(); - sourcePopupPositioner.destroy(); }, }; }; diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 9249af676f..e735ca2dbc 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -475,6 +475,9 @@ NESTED BLOCKS .bn-source-block-popup { position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; z-index: 1; background-color: var(--bn-colors-menu-background); color: var(--bn-colors-menu-text); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58d1b601e4..cf81902600 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4648,9 +4648,6 @@ 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) From d6d1d464920937e6675c90403c880e54c0172862 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 24 Jun 2026 18:00:00 +0200 Subject: [PATCH 14/21] Moved keyboard & selection handling to extension --- packages/core/src/blocks/Code/block.ts | 13 +- .../extensions/SourceBlockPreviewExtension.ts | 140 +++++++++++ .../render/createSourceBlockWithPreview.ts | 223 +++--------------- packages/core/src/blocks/index.ts | 1 + packages/math-block/src/block.ts | 38 ++- 5 files changed, 212 insertions(+), 203 deletions(-) create mode 100644 packages/core/src/blocks/Code/helpers/extensions/SourceBlockPreviewExtension.ts diff --git a/packages/core/src/blocks/Code/block.ts b/packages/core/src/blocks/Code/block.ts index 6010ef96dc..3440167645 100644 --- a/packages/core/src/blocks/Code/block.ts +++ b/packages/core/src/blocks/Code/block.ts @@ -5,9 +5,13 @@ import { } from "./helpers/parse/parsePreCode.js"; import { createPreCode } from "./helpers/toExternalHTML/createPreCode.js"; import { createCodeKeyboardShortcutsExtension } from "./helpers/extensions/createCodeKeyboardShortcutsExtension.js"; +import { SourceBlockPreviewExtension } from "./helpers/extensions/SourceBlockPreviewExtension.js"; import { CodeBlockOptions } from "./CodeBlockOptions.js"; import { createSourceBlockWithPreview } from "./helpers/render/createSourceBlockWithPreview.js"; +const CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY = "code-block-keyboard-shortcuts"; +const CODE_BLOCK_PREVIEW_KEY = "code-block-preview"; + export type CodeBlockConfig = ReturnType; export const createCodeBlockConfig = createBlockConfig( @@ -47,9 +51,16 @@ export const createCodeBlockSpec = createBlockSpec( (options) => { return [ createCodeKeyboardShortcutsExtension(options)( - "code-block-keyboard-shortcuts", + CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY, "codeBlock", ), + SourceBlockPreviewExtension({ + key: CODE_BLOCK_PREVIEW_KEY, + blockType: "codeBlock", + hasPreview: (block) => + !!options.supportedLanguages?.[block.props.language]?.createPreview, + runsBefore: [CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY], + }), ]; }, ); diff --git a/packages/core/src/blocks/Code/helpers/extensions/SourceBlockPreviewExtension.ts b/packages/core/src/blocks/Code/helpers/extensions/SourceBlockPreviewExtension.ts new file mode 100644 index 0000000000..882569fded --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/extensions/SourceBlockPreviewExtension.ts @@ -0,0 +1,140 @@ +import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import { + createExtension, + createStore, +} from "../../../../editor/BlockNoteExtension.js"; +import { Block } from "../../../index.js"; + +export const SourceBlockPreviewExtension = createExtension( + ({ + editor, + options: { key, blockType, hasPreview, runsBefore = [] }, + }: { + editor: BlockNoteEditor; + options: { + key: string; + blockType: string; + hasPreview: (block: Block) => boolean; + runsBefore?: readonly string[]; + }; + }) => { + const store = createStore<{ + popupOpen: string | undefined; + selected: string | undefined; + }>({ + popupOpen: undefined, + selected: undefined, + }); + + const blockHasPreview = (block: Block) => + block.type === blockType && hasPreview(block); + + const handleArrow = + (direction: "prev" | "next") => + ({ editor }: { editor: BlockNoteEditor }) => { + const { block, prevBlock, nextBlock } = editor.getTextCursorPosition(); + if (!blockHasPreview(block) || store.state.popupOpen === block.id) { + return false; + } + + const targetBlock = direction === "prev" ? prevBlock : nextBlock; + if (!targetBlock) { + return false; + } + + editor.setTextCursorPosition( + targetBlock.id, + direction === "prev" ? "end" : "start", + ); + + return true; + }; + + return { + key, + store, + runsBefore, + keyboardShortcuts: { + // Toggles the popup. + Enter: ({ editor }) => { + const { block } = editor.getTextCursorPosition(); + if (!blockHasPreview(block)) { + return false; + } + + editor.setTextCursorPosition(block.id, "end"); + store.setState((state) => ({ + ...state, + popupOpen: + store.state.popupOpen === block.id ? undefined : block.id, + })); + + return true; + }, + // Closes the popup. + Escape: ({ editor }) => { + const { block } = editor.getTextCursorPosition(); + if (!blockHasPreview(block) || store.state.popupOpen !== block.id) { + return false; + } + + editor.setTextCursorPosition(block.id, "end"); + + store.setState((state) => ({ ...state, popupOpen: undefined })); + + return true; + }, + // While the popup is closed, moves the selection straight to the previous/next block + // instead of into the (hidden) source. + ArrowUp: handleArrow("prev"), + ArrowLeft: handleArrow("prev"), + ArrowDown: handleArrow("next"), + ArrowRight: handleArrow("next"), + }, + mount: ({ dom, signal }) => { + // Closes the popup when the selection leaves the block that owns it and tracks which block + // the selection is in. + const unsubscribeSelectionChange = editor.onSelectionChange(() => { + const { block } = editor.getTextCursorPosition(); + + store.setState((state) => ({ + selected: blockHasPreview(block) ? block.id : undefined, + popupOpen: + state.popupOpen && state.popupOpen !== block.id + ? undefined + : state.popupOpen, + })); + }); + signal.addEventListener("abort", unsubscribeSelectionChange); + + // While the popup is closed, prevents editing of the (hidden) source. Handled here rather + // than in `keyboardShortcuts` as it needs to match any text-input key, which a keymap + // can't express. + const handleKeyDown = (event: KeyboardEvent) => { + if (!editor.isEditable) { + return; + } + + const { block } = editor.getTextCursorPosition(); + if (!blockHasPreview(block) || store.state.popupOpen === block.id) { + return; + } + + if ( + (event.key.length === 1 && !event.ctrlKey && !event.metaKey) || + event.key === "Backspace" || + event.key === "Delete" || + event.key === "Tab" + ) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + }; + dom.addEventListener("keydown", handleKeyDown, { + capture: true, + signal, + }); + }, + }; + }, +); diff --git a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts index d5bd281942..3b9de014cd 100644 --- a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts +++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts @@ -4,6 +4,7 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import type { BlockFromConfig, StyledText } from "../../../../schema/index.js"; import { createSourceBlock } from "./createSourceBlock.js"; import { CodeBlockPreview } from "../../CodeBlockOptions.js"; +import { SourceBlockPreviewExtension } from "../extensions/SourceBlockPreviewExtension.js"; // Element shown instead of the preview when block has no content. const createAddSourceButton = (editor: BlockNoteEditor) => { @@ -26,170 +27,6 @@ const createAddSourceButton = (editor: BlockNoteEditor) => { return { dom: addSourceButton }; }; -// Handles toggling popup visibility using the keyboard. and keyboard navigation while popup is -// hidden. -const handleKeyboardNavigation = ( - block: BlockFromConfig, - editor: BlockNoteEditor, - isSourcePopupOpen: () => boolean, - setSourcePopupOpen: (open: boolean) => void, -) => { - const handleKeyDown = (event: KeyboardEvent) => { - if (!editor.isEditable) { - return; - } - - if (editor.getTextCursorPosition().block.id !== block.id) { - return; - } - - // Toggles popup visibility. - if (event.key === "Enter") { - editor.setTextCursorPosition(block.id, "end"); - setSourcePopupOpen(!isSourcePopupOpen()); - - event.preventDefault(); - event.stopImmediatePropagation(); - - return; - } - - // Hides popup. - if (event.key === "Escape") { - if (!isSourcePopupOpen()) { - return; - } - - editor.setTextCursorPosition(block.id, "end"); - setSourcePopupOpen(false); - - event.preventDefault(); - event.stopImmediatePropagation(); - - return; - } - - // While popup is hidden, moves selection straight to previous/next block. - if ( - (event.key === "ArrowUp" || - event.key === "ArrowDown" || - event.key === "ArrowLeft" || - event.key === "ArrowRight") && - !event.ctrlKey && - !event.metaKey - ) { - if (isSourcePopupOpen()) { - return; - } - - const direction = - event.key === "ArrowUp" || event.key === "ArrowLeft" ? "prev" : "next"; - - const { prevBlock, nextBlock } = editor.getTextCursorPosition(); - const targetBlock = direction === "prev" ? prevBlock : nextBlock; - if (!targetBlock) { - return; - } - - editor.setTextCursorPosition( - targetBlock.id, - direction === "prev" ? "end" : "start", - ); - - event.preventDefault(); - event.stopImmediatePropagation(); - } - - // While popup is hidden, prevents editing of block content. - // TODO: This doesn't account for all cases, e.g. cut/paste with Cmd+X/Cmd+V. - if ( - (event.key.length === 1 && !event.ctrlKey && !event.metaKey) || - event.key === "Backspace" || - event.key === "Delete" || - event.key === "Tab" - ) { - if (isSourcePopupOpen()) { - return; - } - - event.preventDefault(); - event.stopImmediatePropagation(); - } - }; - - editor.domElement?.addEventListener("keydown", handleKeyDown, true); - - return { - destroy: () => - editor.domElement?.removeEventListener("keydown", handleKeyDown, true), - }; -}; - -// Handles opening the popup when clicking the preview. -const handlePreviewMouseDown = ( - block: BlockFromConfig, - editor: BlockNoteEditor, - preview: HTMLElement, - setSourcePopupOpen: (open: boolean) => void, -) => { - const handleMouseDown = (event: MouseEvent) => { - if (!editor.isEditable) { - return; - } - - setSourcePopupOpen(true); - - event.preventDefault(); - event.stopPropagation(); - - editor.setTextCursorPosition(block.id, "end"); - editor.focus(); - }; - - preview.addEventListener("mousedown", handleMouseDown); - - return { - destroy: () => preview.removeEventListener("mousedown", handleMouseDown), - }; -}; - -// Handles closing the popup when selection moves outside of the block, and makes the block appear -// selected while the selection is anywhere inside it. -const handleSelectionChange = ( - block: BlockFromConfig, - editor: BlockNoteEditor, - container: HTMLElement, - isSourcePopupOpen: () => boolean, - setSourcePopupOpen: (open: boolean) => void, -) => { - const destroy = editor.onSelectionChange((editor) => { - const blockContent = container.closest(".bn-block-content"); - - if (editor.getTextCursorPosition().block.id !== block.id) { - if (isSourcePopupOpen()) { - setSourcePopupOpen(false); - } - - // Sets selected block styles. - if ( - blockContent && - blockContent.classList.contains("ProseMirror-selectednode") - ) { - blockContent.classList.remove("ProseMirror-selectednode"); - } - } else { - if ( - blockContent && - !blockContent.classList.contains("ProseMirror-selectednode") - ) { - blockContent.classList.add("ProseMirror-selectednode"); - } - } - }); - - return { destroy }; -}; - // Renders a preview which can be clicked to show the block's inline content as code in a popup, // alongside a language picker if multiple languages are supported. If no preview is provided, just // renders the same thing as `createSourceBlock`. @@ -277,32 +114,39 @@ export const createSourceBlockWithPreview = ( previewWithSourcePopup.appendChild(sourceBlockPopup); - const isSourcePopupOpen = () => - previewWithSourcePopup.dataset.open === "true"; - const setSourcePopupOpen = (open: boolean) => - (previewWithSourcePopup.dataset.open = open ? "true" : "false"); + const store = editor.getExtension(SourceBlockPreviewExtension)?.store; + + // Sync the popup's initial open state so it survives a re-render (the block's + // DOM isn't mounted yet, so the selected class is left to `updateFromStore`). + previewWithSourcePopup.dataset.open = + store?.state.popupOpen === block.id ? "true" : "false"; + + const unsubscribeFromStore = store?.subscribe(() => { + previewWithSourcePopup.dataset.open = + store?.state.popupOpen === block.id ? "true" : "false"; + previewWithSourcePopup + .closest(".bn-block-content") + ?.classList.toggle( + "ProseMirror-selectednode", + store?.state.selected === block.id, + ); + }); - const keyboardNavigationHandler = handleKeyboardNavigation( - block, - editor, - isSourcePopupOpen, - setSourcePopupOpen, - ); + // Opens the popup when clicking the preview. + const handleMouseDown = (event: MouseEvent) => { + if (!editor.isEditable) { + return; + } - const previewMouseDownHandler = handlePreviewMouseDown( - block, - editor, - previewContainer, - setSourcePopupOpen, - ); + store?.setState((state) => ({ ...state, popupOpen: block.id })); - const selectionMoveOutHandler = handleSelectionChange( - block, - editor, - previewWithSourcePopup, - isSourcePopupOpen, - setSourcePopupOpen, - ); + event.preventDefault(); + event.stopPropagation(); + + editor.setTextCursorPosition(block.id, "end"); + editor.focus(); + }; + previewContainer.addEventListener("mousedown", handleMouseDown); return { dom: previewWithSourcePopup, @@ -353,9 +197,8 @@ export const createSourceBlockWithPreview = ( }, destroy: () => { sourceBlock.destroy(); - keyboardNavigationHandler.destroy(); - previewMouseDownHandler.destroy(); - selectionMoveOutHandler.destroy(); + unsubscribeFromStore?.(); + previewContainer.removeEventListener("mousedown", handleMouseDown); }, }; }; diff --git a/packages/core/src/blocks/index.ts b/packages/core/src/blocks/index.ts index cf96624cf9..65a5417e48 100644 --- a/packages/core/src/blocks/index.ts +++ b/packages/core/src/blocks/index.ts @@ -17,6 +17,7 @@ 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/SourceBlockPreviewExtension.js"; export * from "./Code/helpers/parse/parsePreCode.js"; export * from "./Code/helpers/render/createSourceBlock.js"; export * from "./Code/helpers/render/createSourceBlockWithPreview.js"; diff --git a/packages/math-block/src/block.ts b/packages/math-block/src/block.ts index 76c8cbb545..5a62aec53a 100644 --- a/packages/math-block/src/block.ts +++ b/packages/math-block/src/block.ts @@ -1,6 +1,7 @@ import { createBlockConfig, createBlockSpec, + SourceBlockPreviewExtension, createSourceBlockWithPreview, } from "@blocknote/core"; import { @@ -10,6 +11,8 @@ import { import { createMathPreview } from "./helpers/render/createMathPreview.js"; import { createMathML } from "./helpers/toExternalHTML/createMathML.js"; +const MATH_BLOCK_PREVIEW_KEY = "math-block-preview"; + export type MathBlockConfig = ReturnType; export const createMathBlockConfig = createBlockConfig( @@ -21,17 +24,28 @@ export const createMathBlockConfig = createBlockConfig( }) as const, ); -export const createMathBlockSpec = createBlockSpec(createMathBlockConfig, { - meta: { - code: true, - defining: true, - isolating: false, +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) => + createSourceBlockWithPreview(block, editor, { + createPreview: createMathPreview, + }), + toExternalHTML: (block) => createMathML(block), }, - parse: (el) => parseMathML(el), - parseContent: ({ el, schema }) => parseMathMLContent({ el, schema }), - render: (block, editor) => - createSourceBlockWithPreview(block, editor, { - createPreview: createMathPreview, + [ + // Math blocks always render a preview. + SourceBlockPreviewExtension({ + key: MATH_BLOCK_PREVIEW_KEY, + blockType: "math", + hasPreview: () => true, }), - toExternalHTML: (block) => createMathML(block), -}); + ], +); From aa666bc8c513424d3321f757873e108611dc4327 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 25 Jun 2026 16:36:58 +0200 Subject: [PATCH 15/21] Added React math block --- .../09-math-block/src/App.tsx | 4 +- packages/core/src/blocks/Code/block.ts | 8 +- ...n.ts => CodeKeyboardShortcutsExtension.ts} | 2 +- ....ts => SourceBlockWithPreviewExtension.ts} | 2 +- .../render/createSourceBlockWithPreview.ts | 4 +- packages/core/src/blocks/index.ts | 4 +- packages/math-block/package.json | 9 +- packages/math-block/src/block.ts | 4 +- .../src/helpers/render/MathPreview.tsx | 133 ++++++++++++++++++ .../src/helpers/toExternalHTML/MathML.tsx | 42 ++++++ packages/math-block/src/index.ts | 3 + packages/math-block/src/reactBlock.tsx | 37 +++++ pnpm-lock.yaml | 12 ++ 13 files changed, 249 insertions(+), 15 deletions(-) rename packages/core/src/blocks/Code/helpers/extensions/{createCodeKeyboardShortcutsExtension.ts => CodeKeyboardShortcutsExtension.ts} (98%) rename packages/core/src/blocks/Code/helpers/extensions/{SourceBlockPreviewExtension.ts => SourceBlockWithPreviewExtension.ts} (98%) create mode 100644 packages/math-block/src/helpers/render/MathPreview.tsx create mode 100644 packages/math-block/src/helpers/toExternalHTML/MathML.tsx create mode 100644 packages/math-block/src/reactBlock.tsx diff --git a/examples/06-custom-schema/09-math-block/src/App.tsx b/examples/06-custom-schema/09-math-block/src/App.tsx index fa7b806954..eb9838f279 100644 --- a/examples/06-custom-schema/09-math-block/src/App.tsx +++ b/examples/06-custom-schema/09-math-block/src/App.tsx @@ -5,7 +5,7 @@ import { insertOrUpdateBlockForSlashMenu, } from "@blocknote/core/extensions"; import { createHighlighter } from "@blocknote/code-block"; -import { createMathBlockSpec } from "@blocknote/math-block"; +import { createReactMathBlockSpec } from "@blocknote/math-block"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { @@ -20,7 +20,7 @@ import { TbMathFunction } from "react-icons/tb"; const schema = BlockNoteSchema.create().extend({ blockSpecs: { // Creates an instance of the Math block and adds it to the schema. - math: createMathBlockSpec(), + math: createReactMathBlockSpec(), }, }); diff --git a/packages/core/src/blocks/Code/block.ts b/packages/core/src/blocks/Code/block.ts index 3440167645..6c07cb0f8f 100644 --- a/packages/core/src/blocks/Code/block.ts +++ b/packages/core/src/blocks/Code/block.ts @@ -4,8 +4,8 @@ import { parsePreCodeContent, } from "./helpers/parse/parsePreCode.js"; import { createPreCode } from "./helpers/toExternalHTML/createPreCode.js"; -import { createCodeKeyboardShortcutsExtension } from "./helpers/extensions/createCodeKeyboardShortcutsExtension.js"; -import { SourceBlockPreviewExtension } from "./helpers/extensions/SourceBlockPreviewExtension.js"; +import { CodeKeyboardShortcutsExtension } from "./helpers/extensions/CodeKeyboardShortcutsExtension.js"; +import { SourceBlockWithPreviewExtension } from "./helpers/extensions/SourceBlockWithPreviewExtension.js"; import { CodeBlockOptions } from "./CodeBlockOptions.js"; import { createSourceBlockWithPreview } from "./helpers/render/createSourceBlockWithPreview.js"; @@ -50,11 +50,11 @@ export const createCodeBlockSpec = createBlockSpec( }), (options) => { return [ - createCodeKeyboardShortcutsExtension(options)( + CodeKeyboardShortcutsExtension(options)( CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY, "codeBlock", ), - SourceBlockPreviewExtension({ + SourceBlockWithPreviewExtension({ key: CODE_BLOCK_PREVIEW_KEY, blockType: "codeBlock", hasPreview: (block) => diff --git a/packages/core/src/blocks/Code/helpers/extensions/createCodeKeyboardShortcutsExtension.ts b/packages/core/src/blocks/Code/helpers/extensions/CodeKeyboardShortcutsExtension.ts similarity index 98% rename from packages/core/src/blocks/Code/helpers/extensions/createCodeKeyboardShortcutsExtension.ts rename to packages/core/src/blocks/Code/helpers/extensions/CodeKeyboardShortcutsExtension.ts index 71c20216cb..f6b69f6c69 100644 --- a/packages/core/src/blocks/Code/helpers/extensions/createCodeKeyboardShortcutsExtension.ts +++ b/packages/core/src/blocks/Code/helpers/extensions/CodeKeyboardShortcutsExtension.ts @@ -1,7 +1,7 @@ import { createExtension } from "../../../../editor/BlockNoteExtension.js"; import { CodeBlockOptions, getLanguageId } from "../../CodeBlockOptions.js"; -export const createCodeKeyboardShortcutsExtension = +export const CodeKeyboardShortcutsExtension = (options: CodeBlockOptions) => (key: string, blockType: string) => createExtension({ key, diff --git a/packages/core/src/blocks/Code/helpers/extensions/SourceBlockPreviewExtension.ts b/packages/core/src/blocks/Code/helpers/extensions/SourceBlockWithPreviewExtension.ts similarity index 98% rename from packages/core/src/blocks/Code/helpers/extensions/SourceBlockPreviewExtension.ts rename to packages/core/src/blocks/Code/helpers/extensions/SourceBlockWithPreviewExtension.ts index 882569fded..951bf0cfed 100644 --- a/packages/core/src/blocks/Code/helpers/extensions/SourceBlockPreviewExtension.ts +++ b/packages/core/src/blocks/Code/helpers/extensions/SourceBlockWithPreviewExtension.ts @@ -5,7 +5,7 @@ import { } from "../../../../editor/BlockNoteExtension.js"; import { Block } from "../../../index.js"; -export const SourceBlockPreviewExtension = createExtension( +export const SourceBlockWithPreviewExtension = createExtension( ({ editor, options: { key, blockType, hasPreview, runsBefore = [] }, diff --git a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts index 3b9de014cd..b56c6ec2c0 100644 --- a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts +++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts @@ -4,7 +4,7 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import type { BlockFromConfig, StyledText } from "../../../../schema/index.js"; import { createSourceBlock } from "./createSourceBlock.js"; import { CodeBlockPreview } from "../../CodeBlockOptions.js"; -import { SourceBlockPreviewExtension } from "../extensions/SourceBlockPreviewExtension.js"; +import { SourceBlockWithPreviewExtension } from "../extensions/SourceBlockWithPreviewExtension.js"; // Element shown instead of the preview when block has no content. const createAddSourceButton = (editor: BlockNoteEditor) => { @@ -114,7 +114,7 @@ export const createSourceBlockWithPreview = ( previewWithSourcePopup.appendChild(sourceBlockPopup); - const store = editor.getExtension(SourceBlockPreviewExtension)?.store; + const store = editor.getExtension(SourceBlockWithPreviewExtension)?.store; // Sync the popup's initial open state so it survives a re-render (the block's // DOM isn't mounted yet, so the selected class is left to `updateFromStore`). diff --git a/packages/core/src/blocks/index.ts b/packages/core/src/blocks/index.ts index 65a5417e48..ada4918c16 100644 --- a/packages/core/src/blocks/index.ts +++ b/packages/core/src/blocks/index.ts @@ -16,8 +16,8 @@ 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/SourceBlockPreviewExtension.js"; +export * from "./Code/helpers/extensions/CodeKeyboardShortcutsExtension.js"; +export * from "./Code/helpers/extensions/SourceBlockWithPreviewExtension.js"; export * from "./Code/helpers/parse/parsePreCode.js"; export * from "./Code/helpers/render/createSourceBlock.js"; export * from "./Code/helpers/render/createSourceBlockWithPreview.js"; diff --git a/packages/math-block/package.json b/packages/math-block/package.json index 398083e3c7..1352212768 100644 --- a/packages/math-block/package.json +++ b/packages/math-block/package.json @@ -60,14 +60,21 @@ "prosemirror-state": "^1.4.4" }, "devDependencies": { + "@blocknote/react": "workspace:^", "@blocknote/xl-multi-column": "workspace:^", "@types/katex": "^0.16.7", + "@types/react": "^19.2.3", + "react": "^19.2.5", + "react-dom": "^19.2.5", "rimraf": "^5.0.10", "rollup-plugin-webpack-stats": "^0.2.6", "typescript": "^5.9.3", "vite-plus": "catalog:" }, "peerDependencies": { - "@blocknote/core": "workspace:^" + "@blocknote/core": "workspace:^", + "@blocknote/react": "workspace:^", + "react": "^18.0 || ^19.0 || >= 19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" } } diff --git a/packages/math-block/src/block.ts b/packages/math-block/src/block.ts index 5a62aec53a..9cdd1fcccb 100644 --- a/packages/math-block/src/block.ts +++ b/packages/math-block/src/block.ts @@ -1,8 +1,8 @@ import { createBlockConfig, createBlockSpec, - SourceBlockPreviewExtension, createSourceBlockWithPreview, + SourceBlockWithPreviewExtension, } from "@blocknote/core"; import { parseMathML, @@ -42,7 +42,7 @@ export const createMathBlockSpec = createBlockSpec( }, [ // Math blocks always render a preview. - SourceBlockPreviewExtension({ + SourceBlockWithPreviewExtension({ key: MATH_BLOCK_PREVIEW_KEY, blockType: "math", hasPreview: () => true, diff --git a/packages/math-block/src/helpers/render/MathPreview.tsx b/packages/math-block/src/helpers/render/MathPreview.tsx new file mode 100644 index 0000000000..d5cc682e00 --- /dev/null +++ b/packages/math-block/src/helpers/render/MathPreview.tsx @@ -0,0 +1,133 @@ +import { SourceBlockWithPreviewExtension } from "@blocknote/core"; +import { + ReactCustomBlockRenderProps, + useExtension, + useExtensionState, +} from "@blocknote/react"; +import katex from "katex"; +import "katex/dist/katex.min.css"; +import { MouseEvent, useEffect, useRef } from "react"; +import type { MathBlockConfig } from "../../block.js"; +import { getMathSource } from "../getMathSource.js"; + +// Renders the LaTeX source to a KaTeX HTML string. Uses `throwOnError: true` +// first so we can surface a syntax error, then falls back to a best-effort +// render so the preview still shows something while the source is invalid. +const renderMath = (source: string): { html: string; error: string | null } => { + 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 { html, error }; +}; + +// Shown in place of the preview when the block has no source yet. +const AddSourceButton = (props: { text: string }) => ( +
+
+ + + +
+

{props.text}

+
+); + +export const MathPreview = ( + props: ReactCustomBlockRenderProps, +) => { + const { block, editor, contentRef } = props; + + const source = getMathSource(block); + + const { store } = useExtension(SourceBlockWithPreviewExtension, { editor }); + const popupOpen = useExtensionState(SourceBlockWithPreviewExtension, { + editor, + selector: (state) => state.popupOpen === block.id, + }); + const selected = useExtensionState(SourceBlockWithPreviewExtension, { + editor, + selector: (state) => state.selected === block.id, + }); + + // The source is hidden, so highlight the whole block while the cursor is in + // it. Mirrors the vanilla `createSourceBlockWithPreview` store sync. + const rootRef = useRef(null); + useEffect(() => { + rootRef.current + ?.closest(".bn-block-content") + ?.classList.toggle("ProseMirror-selectednode", selected); + }, [selected]); + + const { html, error } = renderMath(source); + + // Keeps the last error-free KaTeX output so a transient syntax error doesn't + // blank the preview - mirrors the vanilla in-place update. + const lastHtmlRef = useRef(null); + if (!error || lastHtmlRef.current === null) { + lastHtmlRef.current = html; + } + + // Opens the popup when clicking the preview. + const handleMouseDown = (event: MouseEvent) => { + if (!editor.isEditable) { + return; + } + + store.setState((state) => ({ ...state, popupOpen: block.id })); + + event.preventDefault(); + event.stopPropagation(); + + editor.setTextCursorPosition(block.id, "end"); + editor.focus(); + }; + + return ( +
+
+ {source.length > 0 ? ( + + ) : ( + + )} +
+
+
+          
+        
+
+ {error} +
+
+
+ ); +}; diff --git a/packages/math-block/src/helpers/toExternalHTML/MathML.tsx b/packages/math-block/src/helpers/toExternalHTML/MathML.tsx new file mode 100644 index 0000000000..235731ddfa --- /dev/null +++ b/packages/math-block/src/helpers/toExternalHTML/MathML.tsx @@ -0,0 +1,42 @@ +import { ReactCustomBlockRenderProps } from "@blocknote/react"; +import katex from "katex"; +import { createElement } from "react"; +import type { MathBlockConfig } from "../../block.js"; +import { getMathSource } from "../getMathSource.js"; + +/** + * Renders the block's LaTeX source as MathML for external HTML export. The + * React equivalent of `createMathML`. + */ +export const MathML = (props: ReactCustomBlockRenderProps) => { + const mathml = katex.renderToString(getMathSource(props.block), { + displayMode: true, + output: "mathml", + throwOnError: false, + }); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = mathml; + + // KaTeX wraps its MathML in a ``; export the bare + // `` element as the top-level node. + const el = (wrapper.querySelector("math") ?? + wrapper.firstElementChild) as HTMLElement | null; + + if (!el) { + return null; + } + + const attributes = Object.fromEntries( + Array.from(el.attributes).map((attr) => [ + // React expects `className` rather than the `class` DOM attribute. + attr.name === "class" ? "className" : attr.name, + attr.value, + ]), + ); + + return createElement(el.tagName.toLowerCase(), { + ...attributes, + dangerouslySetInnerHTML: { __html: el.innerHTML }, + }); +}; diff --git a/packages/math-block/src/index.ts b/packages/math-block/src/index.ts index a8ec7c51f9..31cdd67c20 100644 --- a/packages/math-block/src/index.ts +++ b/packages/math-block/src/index.ts @@ -1,5 +1,8 @@ export * from "./block.js"; +export * from "./reactBlock.js"; export * from "./helpers/getMathSource.js"; export * from "./helpers/parse/parseMathML.js"; export * from "./helpers/render/createMathPreview.js"; +export * from "./helpers/render/MathPreview.js"; export * from "./helpers/toExternalHTML/createMathML.js"; +export * from "./helpers/toExternalHTML/MathML.js"; diff --git a/packages/math-block/src/reactBlock.tsx b/packages/math-block/src/reactBlock.tsx new file mode 100644 index 0000000000..11627a729f --- /dev/null +++ b/packages/math-block/src/reactBlock.tsx @@ -0,0 +1,37 @@ +import { SourceBlockWithPreviewExtension } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; +import { createMathBlockConfig } from "./block.js"; +import { + parseMathML, + parseMathMLContent, +} from "./helpers/parse/parseMathML.js"; +import { MathPreview } from "./helpers/render/MathPreview.js"; +import { MathML } from "./helpers/toExternalHTML/MathML.js"; + +const MATH_BLOCK_PREVIEW_KEY = "math-block-preview"; + +// React equivalent of `createMathBlockSpec`. Renders the preview and external +// HTML with React components (`MathPreview` and `MathML`) via +// `createReactBlockSpec`, but is otherwise identical. +export const createReactMathBlockSpec = createReactBlockSpec( + createMathBlockConfig, + { + meta: { + code: true, + defining: true, + isolating: false, + }, + parse: (el) => parseMathML(el), + parseContent: ({ el, schema }) => parseMathMLContent({ el, schema }), + render: MathPreview, + toExternalHTML: MathML, + }, + [ + // Math blocks always render a preview. + SourceBlockWithPreviewExtension({ + key: MATH_BLOCK_PREVIEW_KEY, + blockType: "math", + hasPreview: () => true, + }), + ], +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf81902600..03e1477e24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4837,12 +4837,24 @@ importers: specifier: ^1.4.4 version: 1.4.4 devDependencies: + '@blocknote/react': + specifier: workspace:^ + version: link:../react '@blocknote/xl-multi-column': specifier: workspace:^ version: link:../xl-multi-column '@types/katex': specifier: ^0.16.7 version: 0.16.8 + '@types/react': + specifier: ^19.2.3 + version: 19.2.14 + react: + specifier: ^19.2.5 + version: 19.2.5 + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) rimraf: specifier: ^5.0.10 version: 5.0.10 From d1b52d28ef0b6dfbba0b1d94176f9653e89a327b Mon Sep 17 00:00:00 2001 From: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:35:35 +0200 Subject: [PATCH 16/21] feat: Math inline content (#2878) * Added math inline content * Reimplemented math inline content in React * Generalized bug fix for typing in empty math inline content to all inline content * Made popup show state only based on selection --- .../09-math-block/src/App.tsx | 44 ++++++- packages/core/src/editor/Block.css | 55 ++++++++- .../managers/ExtensionManager/extensions.ts | 2 + .../editor/managers/ExtensionManager/index.ts | 14 +++ .../InlineContentBoundaryEdit.ts | 109 ++++++++++++++++++ packages/core/src/extensions/index.ts | 1 + .../src/schema/inlineContent/createSpec.ts | 25 +++- .../core/src/schema/inlineContent/internal.ts | 6 + .../core/src/schema/inlineContent/types.ts | 5 + packages/math-block/package.json | 3 +- .../src/helpers/render/MathPreview.tsx | 39 +------ .../src/helpers/render/createMathPreview.ts | 30 +---- .../src/helpers/render/renderKatex.ts | 37 ++++++ .../src/helpers/render/useKatexPreview.ts | 26 +++++ .../src/helpers/toExternalHTML/MathML.tsx | 38 +----- .../helpers/toExternalHTML/MathMLContent.tsx | 33 ++++++ .../helpers/toExternalHTML/createMathML.ts | 16 ++- packages/math-block/src/index.ts | 5 + .../src/inlineContent/InlineMathML.tsx | 21 ++++ .../src/inlineContent/InlineMathPreview.tsx | 98 ++++++++++++++++ ...SourceInlineContentWithPreviewExtension.ts | 82 +++++++++++++ .../createReactInlineMathSpec.tsx | 27 +++++ .../src/inlineContent/inlineMathConfig.ts | 5 + .../src/schema/ReactInlineContentSpec.tsx | 36 ++++++ pnpm-lock.yaml | 3 + 25 files changed, 656 insertions(+), 104 deletions(-) create mode 100644 packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts create mode 100644 packages/math-block/src/helpers/render/renderKatex.ts create mode 100644 packages/math-block/src/helpers/render/useKatexPreview.ts create mode 100644 packages/math-block/src/helpers/toExternalHTML/MathMLContent.tsx create mode 100644 packages/math-block/src/inlineContent/InlineMathML.tsx create mode 100644 packages/math-block/src/inlineContent/InlineMathPreview.tsx create mode 100644 packages/math-block/src/inlineContent/SourceInlineContentWithPreviewExtension.ts create mode 100644 packages/math-block/src/inlineContent/createReactInlineMathSpec.tsx create mode 100644 packages/math-block/src/inlineContent/inlineMathConfig.ts diff --git a/examples/06-custom-schema/09-math-block/src/App.tsx b/examples/06-custom-schema/09-math-block/src/App.tsx index eb9838f279..2145d40de8 100644 --- a/examples/06-custom-schema/09-math-block/src/App.tsx +++ b/examples/06-custom-schema/09-math-block/src/App.tsx @@ -5,7 +5,10 @@ import { insertOrUpdateBlockForSlashMenu, } from "@blocknote/core/extensions"; import { createHighlighter } from "@blocknote/code-block"; -import { createReactMathBlockSpec } from "@blocknote/math-block"; +import { + createReactInlineMathSpec, + createReactMathBlockSpec, +} from "@blocknote/math-block"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { @@ -22,6 +25,10 @@ const schema = BlockNoteSchema.create().extend({ // Creates an instance of the Math block and adds it to the schema. math: createReactMathBlockSpec(), }, + inlineContentSpecs: { + // Creates an instance of the inline Math content and adds it to the schema. + inlineMath: createReactInlineMathSpec(), + }, }); // Slash menu item to insert a Math block. @@ -37,6 +44,23 @@ const insertMath = (editor: typeof schema.BlockNoteEditor) => ({ icon: , }); +// Slash menu item to insert an inline Math equation. +const insertInlineMath = (editor: typeof schema.BlockNoteEditor) => ({ + title: "Inline Math", + subtext: "Insert an inline LaTeX math formula", + onItemClick: () => { + editor.insertInlineContent([ + // Inserts an empty inline equation, ready to be edited. + { type: "inlineMath", content: "" }, + // Adds a trailing space so the cursor can leave the equation. + " ", + ]); + }, + aliases: ["inline math", "inline latex", "inline formula", "inline equation"], + group: "Inline", + icon: , +}); + export default function App() { const editor = useCreateBlockNote({ // Configures the syntax highlighting extension to always use LaTeX syntax highlighting in the @@ -60,6 +84,14 @@ export default function App() { type: "math", content: "\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}", }, + { + type: "paragraph", + content: [ + "Equations can also be inline, like ", + { type: "inlineMath", content: "e^{i\\pi} + 1 = 0" }, + ". Click one to edit its LaTeX source.", + ], + }, { type: "paragraph", content: "Press the '/' key to open the Slash Menu and add another", @@ -80,8 +112,14 @@ export default function App() { const lastBasicBlockIndex = defaultItems.findLastIndex( (item) => item.group === "Basic blocks", ); - // Inserts the Math item as the last item in the "Basic blocks" group. - defaultItems.splice(lastBasicBlockIndex + 1, 0, insertMath(editor)); + // Inserts the Math item as the last item in the "Basic blocks" group, + // followed by the inline Math item. + defaultItems.splice( + lastBasicBlockIndex + 1, + 0, + insertMath(editor), + insertInlineMath(editor), + ); // Returns filtered items based on the query. return filterSuggestionItems(defaultItems, query); diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index e735ca2dbc..dec77f52d3 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -22,7 +22,9 @@ BASIC STYLES .bn-block-content.ProseMirror-selectednode > *, /* Case for node view renderers */ -.ProseMirror-selectednode > .bn-block-content > * { +.ProseMirror-selectednode > .bn-block-content > *, +/* Case for inline content (e.g. while the text cursor is within it) */ +.bn-inline-content .ProseMirror-selectednode { border-radius: 4px; outline: 4px solid rgb(100, 160, 255); } @@ -562,6 +564,57 @@ block's "add file" button styling. */ margin: 0; } +/* INLINE SOURCE CONTENT PREVIEW */ +/* Inline-content counterpart of the code block preview above. Reuses + `.bn-source-block-popup` and `.bn-code-block-source-error` for the popup. */ +.bn-inline-source-content { + /* Positioning context for the absolutely-positioned source popup, while + staying inline with the surrounding text. */ + position: relative; + display: inline-block; +} + +.bn-inline-source-preview { + cursor: pointer; +} + +/* The shared popup stretches edge-to-edge for the (full-width) code block; for + inline content it sits below the preview and sizes to its source instead. */ +.bn-inline-source-content .bn-source-block-popup { + right: auto; + width: max-content; + min-width: 120px; + max-width: min(400px, 90vw); +} + +/* Keeps the popup from collapsing to a sliver while the source is empty. */ +.bn-inline-source-content .bn-source-block-popup > pre { + min-height: 1.5em; + box-sizing: content-box; +} + +.bn-inline-source-content[data-open="true"] .bn-source-block-popup { + opacity: 1; + pointer-events: auto; +} + +/* The "add source" button sits inline among text, so it needs to be compact + rather than the full-size block-preview button. */ +.bn-inline-source-content .bn-add-source-code-button { + display: inline-flex; + gap: 4px; + padding: 1px 6px; +} + +.bn-inline-source-content .bn-add-source-code-button-icon { + width: 1em; + height: 1em; +} + +.bn-inline-source-content .bn-add-source-code-button-text { + font-size: 0.85em; +} + /* PAGE BREAK */ .bn-block-content[data-content-type="pageBreak"] > div { width: 100%; diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 1c13f15c3b..23d0928efa 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -15,6 +15,7 @@ import { FilePanelExtension, FormattingToolbarExtension, HistoryExtension, + InlineContentBoundaryEditExtension, LinkToolbarExtension, NodeSelectionKeyboardExtension, PlaceholderExtension, @@ -176,6 +177,7 @@ export function getDefaultExtensions( SideMenuExtension(options), SuggestionMenu(options), HistoryExtension(), + InlineContentBoundaryEditExtension(), PositionMappingExtension(), ...(options.trailingBlock !== false ? [TrailingNodeExtension()] : []), ] as ExtensionFactoryInstance[]; diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index c49f787f57..e5fb8ec992 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -111,6 +111,20 @@ export class ExtensionManager { this.addExtension(extension); } } + + // Add the extensions from inline content specs (the built-in text/link + // specs carry none). + for (const inlineContent of Object.values( + this.editor.schema.inlineContentSpecs, + )) { + for (const extension of ( + inlineContent as { + extensions?: (Extension | ExtensionFactoryInstance)[]; + } + ).extensions ?? []) { + this.addExtension(extension); + } + } } /** diff --git a/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts b/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts new file mode 100644 index 0000000000..01c0357d03 --- /dev/null +++ b/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts @@ -0,0 +1,109 @@ +import { Plugin, PluginKey, Selection, TextSelection } from "prosemirror-state"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; + +const PLUGIN_KEY = new PluginKey("inline-content-boundary-edit"); + +// Whether a Backspace/Delete at `selection` would remove the entire content +// range `[content.from, content.to)` of an inline content node. +function emptiesInlineContent( + selection: Selection, + key: string, + content: { from: number; to: number }, +) { + if (!selection.empty) { + return selection.from <= content.from && selection.to >= content.to; + } + + const isSingleChar = content.to - content.from === 1; + + return key === "Backspace" + ? isSingleChar && selection.from === content.to + : isSingleChar && selection.from === content.from; +} + +// Fixes editing at the boundary of an empty custom inline content node (i.e. an +// inline node with editable content, like a mention or inline math). +// +// An empty inline node can't hold a text cursor, so ProseMirror can't reconcile +// edits across the empty boundary from the DOM: typing into an empty node +// inserts text next to it rather than inside, and deleting the last character +// leaves an un-reconcilable empty node that corrupts/freezes the editor. Both +// boundary edits are handled here via transactions so the caret stays inside +// the node, which is kept alive and editable in its empty state. +// +// The cursor is inside such a node exactly when its directly-enclosing node is +// inline (`inline: true` in the spec) - regular text blocks aren't inline, and +// atomic inline content can't hold a cursor - so the handling applies to any +// inline content type without needing to know it by name. +export const InlineContentBoundaryEditExtension = createExtension( + () => + ({ + key: "inlineContentBoundaryEdit", + prosemirrorPlugins: [ + new Plugin({ + key: PLUGIN_KEY, + props: { + handleKeyDown: (view, event) => { + if (!view.editable) { + return false; + } + + const isTypedChar = + event.key.length === 1 && !event.ctrlKey && !event.metaKey; + + if ( + !isTypedChar && + event.key !== "Backspace" && + event.key !== "Delete" + ) { + return false; + } + + const { selection } = view.state; + const node = selection.$from.node(); + if (!node.type.spec.inline) { + return false; + } + + const pos = selection.$from.before(); + const contentFrom = pos + 1; + const contentTo = pos + 1 + node.content.size; + + // Empty content: redirect the typed character into the node. + if (isTypedChar && node.content.size === 0) { + const tr = view.state.tr.insert( + contentFrom, + view.state.schema.text(event.key), + ); + tr.setSelection( + TextSelection.create(tr.doc, contentFrom + event.key.length), + ); + view.dispatch(tr); + + return true; + } + + // Backspace/Delete that would empty the content: delete it all in + // one transaction, keeping the now-empty node (and the caret + // inside it) so it stays editable. + if ( + node.content.size > 0 && + emptiesInlineContent(selection, event.key, { + from: contentFrom, + to: contentTo, + }) + ) { + const tr = view.state.tr.delete(contentFrom, contentTo); + tr.setSelection(TextSelection.create(tr.doc, contentFrom)); + view.dispatch(tr); + + return true; + } + + return false; + }, + }, + }), + ], + }) as const, +); diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index 12c3f85d02..f797658c80 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -3,6 +3,7 @@ export * from "./DropCursor/DropCursor.js"; export * from "./FilePanel/FilePanel.js"; export * from "./FormattingToolbar/FormattingToolbar.js"; export * from "./History/History.js"; +export * from "./InlineContentBoundaryEdit/InlineContentBoundaryEdit.js"; export * from "./LinkToolbar/LinkToolbar.js"; export * from "./LinkToolbar/protocols.js"; export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js"; diff --git a/packages/core/src/schema/inlineContent/createSpec.ts b/packages/core/src/schema/inlineContent/createSpec.ts index f4522936a4..835c47a3f8 100644 --- a/packages/core/src/schema/inlineContent/createSpec.ts +++ b/packages/core/src/schema/inlineContent/createSpec.ts @@ -1,6 +1,6 @@ import { Node } from "@tiptap/core"; -import { TagParseRule } from "@tiptap/pm/model"; +import { Node as ProsemirrorNode, TagParseRule } from "@tiptap/pm/model"; import { inlineContentToNodes } from "../../api/nodeConversions/blockToNode.js"; import { nodeToCustomInlineContent } from "../../api/nodeConversions/nodeToBlock.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; @@ -54,6 +54,16 @@ export type CustomInlineContentImplementation< editor: BlockNoteEditor, // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics + /** + * The ProseMirror node backing this inline content. + */ + node: ProsemirrorNode, + /** + * Returns this inline content's position in the document. When rendered + * outside the editor (i.e. serialized to HTML), this is a no-op that returns + * `undefined`. + */ + getPos: () => number | undefined, ) => { dom: HTMLElement; contentDOM?: HTMLElement; @@ -170,6 +180,8 @@ export function createInlineContentSpec< // No-op }, editor, + node, + () => undefined, ); return addInlineContentAttributes( @@ -206,6 +218,8 @@ export function createInlineContentSpec< ); }, editor, + node, + getPos, ); return addInlineContentAttributes( @@ -225,10 +239,19 @@ export function createInlineContentSpec< ...inlineContentImplementation, toExternalHTML: inlineContentImplementation.toExternalHTML, render(inlineContent, updateInlineContent, editor) { + // Rendered outside the editor (serialization), so there's no live node + // view - derive the node from the content and stub out `getPos`. + const node = inlineContentToNodes( + [inlineContent] as any, + editor.pmSchema, + )[0]; + const output = inlineContentImplementation.render( inlineContent, updateInlineContent, editor, + node, + () => undefined, ); return addInlineContentAttributes( diff --git a/packages/core/src/schema/inlineContent/internal.ts b/packages/core/src/schema/inlineContent/internal.ts index 9d10c7cb4e..d15b8ba28a 100644 --- a/packages/core/src/schema/inlineContent/internal.ts +++ b/packages/core/src/schema/inlineContent/internal.ts @@ -1,5 +1,9 @@ import { KeyboardShortcutCommand, Node } from "@tiptap/core"; +import { + Extension, + ExtensionFactoryInstance, +} from "../../editor/BlockNoteExtension.js"; import { camelToDataKebab } from "../../util/string.js"; import { PropSchema, Props } from "../propTypes.js"; import { @@ -77,10 +81,12 @@ export function createInternalInlineContentSpec< >( config: T, implementation: InlineContentImplementation>, + extensions?: (Extension | ExtensionFactoryInstance)[], ): InlineContentSpec { return { config, implementation, + extensions, } as const; } diff --git a/packages/core/src/schema/inlineContent/types.ts b/packages/core/src/schema/inlineContent/types.ts index b8e922502a..c4e9042031 100644 --- a/packages/core/src/schema/inlineContent/types.ts +++ b/packages/core/src/schema/inlineContent/types.ts @@ -2,6 +2,10 @@ import { Node } from "@tiptap/core"; import { PropSchema, Props } from "../propTypes.js"; import { StyleSchema, Styles } from "../styles/types.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { + Extension, + ExtensionFactoryInstance, +} from "../../editor/BlockNoteExtension.js"; import { ViewMutationRecord } from "prosemirror-view"; export type CustomInlineContentConfig = { @@ -57,6 +61,7 @@ export type InlineContentSchemaWithInlineContent< export type InlineContentSpec = { config: T; implementation: InlineContentImplementation; + extensions?: (Extension | ExtensionFactoryInstance)[]; }; // A Schema contains all the types (Configs) supported in an editor diff --git a/packages/math-block/package.json b/packages/math-block/package.json index 1352212768..0781172482 100644 --- a/packages/math-block/package.json +++ b/packages/math-block/package.json @@ -57,7 +57,8 @@ "katex": "^0.16.11", "mathml-to-latex": "^1.8.0", "prosemirror-model": "^1.25.4", - "prosemirror-state": "^1.4.4" + "prosemirror-state": "^1.4.4", + "prosemirror-view": "^1.41.4" }, "devDependencies": { "@blocknote/react": "workspace:^", diff --git a/packages/math-block/src/helpers/render/MathPreview.tsx b/packages/math-block/src/helpers/render/MathPreview.tsx index d5cc682e00..0ea80c8ac7 100644 --- a/packages/math-block/src/helpers/render/MathPreview.tsx +++ b/packages/math-block/src/helpers/render/MathPreview.tsx @@ -4,35 +4,13 @@ import { useExtension, useExtensionState, } from "@blocknote/react"; -import katex from "katex"; -import "katex/dist/katex.min.css"; import { MouseEvent, useEffect, useRef } from "react"; import type { MathBlockConfig } from "../../block.js"; import { getMathSource } from "../getMathSource.js"; +import { useKatexPreview } from "./useKatexPreview.js"; -// Renders the LaTeX source to a KaTeX HTML string. Uses `throwOnError: true` -// first so we can surface a syntax error, then falls back to a best-effort -// render so the preview still shows something while the source is invalid. -const renderMath = (source: string): { html: string; error: string | null } => { - 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 { html, error }; -}; - -// Shown in place of the preview when the block has no source yet. -const AddSourceButton = (props: { text: string }) => ( +// Shown in place of the preview when the math content has no source yet. +export const AddSourceButton = (props: { text: string }) => (
(null); - if (!error || lastHtmlRef.current === null) { - lastHtmlRef.current = html; - } + const { html, error } = useKatexPreview(source, true); // Opens the popup when clicking the preview. const handleMouseDown = (event: MouseEvent) => { @@ -109,7 +80,7 @@ export const MathPreview = ( onMouseDown={handleMouseDown} > {source.length > 0 ? ( - + ) : ( { - const source = getMathSource(block); - - // Render with `throwOnError: true` first so we can check for syntax errors. - let html: string; - let error: string | null = null; - try { - html = katex.renderToString(source, { - throwOnError: true, - displayMode: true, - }); - } catch (e) { - error = e instanceof Error ? e.message : String(e); - html = katex.renderToString(source, { - throwOnError: false, - displayMode: true, - }); - } - - const template = document.createElement("template"); - template.innerHTML = html; - const dom = template.content.firstElementChild as HTMLElement; - - return { dom, error }; -}; +export const createMathPreview: CodeBlockPreview = (block) => + renderKatex(getMathSource(block), true); diff --git a/packages/math-block/src/helpers/render/renderKatex.ts b/packages/math-block/src/helpers/render/renderKatex.ts new file mode 100644 index 0000000000..eb6c2fd77f --- /dev/null +++ b/packages/math-block/src/helpers/render/renderKatex.ts @@ -0,0 +1,37 @@ +import katex from "katex"; +import "katex/dist/katex.min.css"; + +/** + * Renders a LaTeX source string to KaTeX HTML (and an equivalent DOM element). + * + * Renders with `throwOnError: true` first so we can surface syntax errors, + * then falls back to KaTeX's own error rendering so the output is never empty. + * + * @param source The LaTeX source to render. + * @param displayMode Whether to render in display (block) or inline mode. + */ +export const renderKatex = ( + source: string, + displayMode: boolean, +): { html: string; dom: HTMLElement; error: string | null } => { + let html: string; + let error: string | null = null; + try { + html = katex.renderToString(source, { + throwOnError: true, + displayMode, + }); + } catch (e) { + error = e instanceof Error ? e.message : String(e); + html = katex.renderToString(source, { + throwOnError: false, + displayMode, + }); + } + + const template = document.createElement("template"); + template.innerHTML = html; + const dom = template.content.firstElementChild as HTMLElement; + + return { html, dom, error }; +}; diff --git a/packages/math-block/src/helpers/render/useKatexPreview.ts b/packages/math-block/src/helpers/render/useKatexPreview.ts new file mode 100644 index 0000000000..249c20bf5d --- /dev/null +++ b/packages/math-block/src/helpers/render/useKatexPreview.ts @@ -0,0 +1,26 @@ +import { useRef } from "react"; +import { renderKatex } from "./renderKatex.js"; + +/** + * Renders a LaTeX source string to KaTeX HTML for use in a React preview. + * + * Keeps the last error-free render so a transient syntax error (while the user + * is mid-edit) doesn't blank the preview - mirroring the in-place update of the + * vanilla `createSourceBlockWithPreview`. + * + * @param source The LaTeX source to render. + * @param displayMode Whether to render in display (block) or inline mode. + */ +export const useKatexPreview = ( + source: string, + displayMode: boolean, +): { html: string; error: string | null } => { + const { html, error } = renderKatex(source, displayMode); + + const lastHtmlRef = useRef(null); + if (!error || lastHtmlRef.current === null) { + lastHtmlRef.current = html; + } + + return { html: lastHtmlRef.current, error }; +}; diff --git a/packages/math-block/src/helpers/toExternalHTML/MathML.tsx b/packages/math-block/src/helpers/toExternalHTML/MathML.tsx index 235731ddfa..2e4575e188 100644 --- a/packages/math-block/src/helpers/toExternalHTML/MathML.tsx +++ b/packages/math-block/src/helpers/toExternalHTML/MathML.tsx @@ -1,42 +1,12 @@ import { ReactCustomBlockRenderProps } from "@blocknote/react"; -import katex from "katex"; -import { createElement } from "react"; import type { MathBlockConfig } from "../../block.js"; import { getMathSource } from "../getMathSource.js"; +import { MathMLContent } from "./MathMLContent.js"; /** * Renders the block's LaTeX source as MathML for external HTML export. The * React equivalent of `createMathML`. */ -export const MathML = (props: ReactCustomBlockRenderProps) => { - const mathml = katex.renderToString(getMathSource(props.block), { - displayMode: true, - output: "mathml", - throwOnError: false, - }); - - const wrapper = document.createElement("div"); - wrapper.innerHTML = mathml; - - // KaTeX wraps its MathML in a ``; export the bare - // `` element as the top-level node. - const el = (wrapper.querySelector("math") ?? - wrapper.firstElementChild) as HTMLElement | null; - - if (!el) { - return null; - } - - const attributes = Object.fromEntries( - Array.from(el.attributes).map((attr) => [ - // React expects `className` rather than the `class` DOM attribute. - attr.name === "class" ? "className" : attr.name, - attr.value, - ]), - ); - - return createElement(el.tagName.toLowerCase(), { - ...attributes, - dangerouslySetInnerHTML: { __html: el.innerHTML }, - }); -}; +export const MathML = (props: ReactCustomBlockRenderProps) => ( + +); diff --git a/packages/math-block/src/helpers/toExternalHTML/MathMLContent.tsx b/packages/math-block/src/helpers/toExternalHTML/MathMLContent.tsx new file mode 100644 index 0000000000..1d21e0c70e --- /dev/null +++ b/packages/math-block/src/helpers/toExternalHTML/MathMLContent.tsx @@ -0,0 +1,33 @@ +import { createElement } from "react"; +import { katexToMathML } from "./createMathML.js"; + +/** + * Renders a LaTeX source string as a bare `` (MathML) element for + * external HTML export. The React equivalent of `katexToMathML`. + * + * @param source The LaTeX source to render. + * @param displayMode Whether to render in display (block) or inline mode. + */ +export const MathMLContent = (props: { + source: string; + displayMode: boolean; +}) => { + const { dom } = katexToMathML(props.source, props.displayMode); + + if (!dom) { + return null; + } + + const attributes = Object.fromEntries( + Array.from(dom.attributes).map((attr) => [ + // React expects `className` rather than the `class` DOM attribute. + attr.name === "class" ? "className" : attr.name, + attr.value, + ]), + ); + + return createElement(dom.tagName.toLowerCase(), { + ...attributes, + dangerouslySetInnerHTML: { __html: dom.innerHTML }, + }); +}; diff --git a/packages/math-block/src/helpers/toExternalHTML/createMathML.ts b/packages/math-block/src/helpers/toExternalHTML/createMathML.ts index 23b13de1ab..a05a25bf41 100644 --- a/packages/math-block/src/helpers/toExternalHTML/createMathML.ts +++ b/packages/math-block/src/helpers/toExternalHTML/createMathML.ts @@ -2,9 +2,16 @@ import type { BlockFromConfig } from "@blocknote/core"; import katex from "katex"; import { getMathSource } from "../getMathSource.js"; -export const createMathML = (block: BlockFromConfig) => { - const mathml = katex.renderToString(getMathSource(block), { - displayMode: true, +/** + * Renders a LaTeX source string to a bare `` (MathML) element for use + * outside the editor. + * + * @param source The LaTeX source to render. + * @param displayMode Whether to render in display (block) or inline mode. + */ +export const katexToMathML = (source: string, displayMode: boolean) => { + const mathml = katex.renderToString(source, { + displayMode, output: "mathml", throwOnError: false, }); @@ -18,3 +25,6 @@ export const createMathML = (block: BlockFromConfig) => { return { dom: (math ?? wrapper.firstElementChild) as HTMLElement }; }; + +export const createMathML = (block: BlockFromConfig) => + katexToMathML(getMathSource(block), true); diff --git a/packages/math-block/src/index.ts b/packages/math-block/src/index.ts index 31cdd67c20..28a4aede4e 100644 --- a/packages/math-block/src/index.ts +++ b/packages/math-block/src/index.ts @@ -1,5 +1,10 @@ export * from "./block.js"; export * from "./reactBlock.js"; +export * from "./inlineContent/inlineMathConfig.js"; +export * from "./inlineContent/createReactInlineMathSpec.js"; +export * from "./inlineContent/InlineMathPreview.js"; +export * from "./inlineContent/InlineMathML.js"; +export * from "./inlineContent/SourceInlineContentWithPreviewExtension.js"; export * from "./helpers/getMathSource.js"; export * from "./helpers/parse/parseMathML.js"; export * from "./helpers/render/createMathPreview.js"; diff --git a/packages/math-block/src/inlineContent/InlineMathML.tsx b/packages/math-block/src/inlineContent/InlineMathML.tsx new file mode 100644 index 0000000000..327b2c2f92 --- /dev/null +++ b/packages/math-block/src/inlineContent/InlineMathML.tsx @@ -0,0 +1,21 @@ +import { StyleSchema } from "@blocknote/core"; +import { ReactCustomInlineContentRenderProps } from "@blocknote/react"; +import { getMathSource } from "../helpers/getMathSource.js"; +import { MathMLContent } from "../helpers/toExternalHTML/MathMLContent.js"; +import { inlineMathConfig } from "./inlineMathConfig.js"; + +/** + * Renders the inline content's LaTeX source as inline MathML for external HTML + * export. + */ +export const InlineMathML = ( + props: ReactCustomInlineContentRenderProps< + typeof inlineMathConfig, + StyleSchema + >, +) => ( + +); diff --git a/packages/math-block/src/inlineContent/InlineMathPreview.tsx b/packages/math-block/src/inlineContent/InlineMathPreview.tsx new file mode 100644 index 0000000000..9645a5b678 --- /dev/null +++ b/packages/math-block/src/inlineContent/InlineMathPreview.tsx @@ -0,0 +1,98 @@ +import { StyleSchema } from "@blocknote/core"; +import { + ReactCustomInlineContentRenderProps, + useExtension, + useExtensionState, +} from "@blocknote/react"; +import { TextSelection } from "prosemirror-state"; +import { MouseEvent, useEffect, useRef } from "react"; +import { getMathSource } from "../helpers/getMathSource.js"; +import { AddSourceButton } from "../helpers/render/MathPreview.js"; +import { useKatexPreview } from "../helpers/render/useKatexPreview.js"; +import { inlineMathConfig } from "./inlineMathConfig.js"; +import { SourceInlineContentWithPreviewExtension } from "./SourceInlineContentWithPreviewExtension.js"; + +export const InlineMathPreview = ( + props: ReactCustomInlineContentRenderProps< + typeof inlineMathConfig, + StyleSchema + >, +) => { + const { inlineContent, editor, contentRef, node, getPos } = props; + const pos = getPos(); + + const source = getMathSource(inlineContent); + + const { store } = useExtension(SourceInlineContentWithPreviewExtension, { + editor, + }); + // The popup is open exactly when the selection is inside this inline content, + // which is the same condition that marks it as selected. + const selected = useExtensionState(SourceInlineContentWithPreviewExtension, { + editor, + selector: (state) => state.selected === pos, + }); + + const rootRef = useRef(null); + useEffect(() => { + rootRef.current + ?.closest(".bn-inline-content-section") + ?.classList.toggle("ProseMirror-selectednode", selected); + }, [selected]); + + const { html, error } = useKatexPreview(source, true); + + // Opens the popup when clicking the preview. + const handleMouseDown = (event: MouseEvent) => { + if (!editor.isEditable || !pos) { + return; + } + + store.setState({ selected: pos }); + + event.preventDefault(); + event.stopPropagation(); + + const view = editor.prosemirrorView!; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.tr.doc, pos + node.nodeSize - 1), + ), + ); + editor.focus(); + }; + + return ( + + + {source.length > 0 ? ( + + ) : ( + + )} + +
+
+          
+        
+
+ {error} +
+
+
+ ); +}; diff --git a/packages/math-block/src/inlineContent/SourceInlineContentWithPreviewExtension.ts b/packages/math-block/src/inlineContent/SourceInlineContentWithPreviewExtension.ts new file mode 100644 index 0000000000..4917e01e3e --- /dev/null +++ b/packages/math-block/src/inlineContent/SourceInlineContentWithPreviewExtension.ts @@ -0,0 +1,82 @@ +import { BlockNoteEditor, createExtension, createStore } from "@blocknote/core"; +import { Selection } from "prosemirror-state"; + +/** + * Inline-content counterpart of {@link SourceBlockWithPreviewExtension}. Drives + * the source popup for inline content with a preview. + * + * Unlike the block version, the popup isn't toggled with a separate state flag: + * it's open exactly when the selection is inside the inline content's source. + * The store therefore only tracks which inline content (by its position) holds + * the selection - moving the selection in opens its popup, moving it out closes + * it. Since the source popup is always laid out (just hidden via opacity), the + * cursor can navigate into and out of it with the arrow keys as usual. + */ +export const SourceInlineContentWithPreviewExtension = createExtension( + ({ + editor, + options: { key, inlineContentType, runsBefore = [] }, + }: { + editor: BlockNoteEditor; + options: { + key: string; + inlineContentType: string; + runsBefore?: readonly string[]; + }; + }) => { + const store = createStore<{ + selected: number | undefined; + }>({ + selected: undefined, + }); + + // Moves the selection out of (just after) the inline content, which closes + // the popup via the selection-change handler below. Lets the keyboard + // commit-and-exit the source the same way arrowing past its end does, and + // keeps Enter from splitting the block while editing the source. + const moveSelectionOut = ({ + editor, + }: { + editor: BlockNoteEditor; + }) => { + const { $from } = editor.prosemirrorState.selection; + const node = $from.node(); + if (node.type.name !== inlineContentType) { + return false; + } + + const view = editor.prosemirrorView!; + const selection = Selection.near( + view.state.doc.resolve($from.after()), + 1, + ); + view.dispatch(view.state.tr.setSelection(selection)); + + return true; + }; + + return { + key, + store, + runsBefore, + keyboardShortcuts: { + Enter: moveSelectionOut, + Escape: moveSelectionOut, + }, + mount: ({ signal }) => { + // The popup is open exactly when the selection is inside the inline + // content, so we just track which inline content (if any) holds it. + const unsubscribeSelectionChange = editor.onSelectionChange(() => { + const { $from } = editor.prosemirrorState.selection; + const node = $from.node(); + + store.setState({ + selected: + node.type.name === inlineContentType ? $from.before() : undefined, + }); + }); + signal.addEventListener("abort", unsubscribeSelectionChange); + }, + }; + }, +); diff --git a/packages/math-block/src/inlineContent/createReactInlineMathSpec.tsx b/packages/math-block/src/inlineContent/createReactInlineMathSpec.tsx new file mode 100644 index 0000000000..94a62f22fd --- /dev/null +++ b/packages/math-block/src/inlineContent/createReactInlineMathSpec.tsx @@ -0,0 +1,27 @@ +import { createReactInlineContentSpec } from "@blocknote/react"; +import { InlineMathML } from "./InlineMathML.js"; +import { InlineMathPreview } from "./InlineMathPreview.js"; +import { inlineMathConfig } from "./inlineMathConfig.js"; +import { SourceInlineContentWithPreviewExtension } from "./SourceInlineContentWithPreviewExtension.js"; + +const INLINE_MATH_PREVIEW_KEY = "inline-math-preview"; + +/** + * Inline equivalent of the Math block, implemented with React. Renders a KaTeX + * preview inline that can be clicked (or navigated into with the keyboard) to + * edit its LaTeX source in a popup. + */ +export const createReactInlineMathSpec = () => + createReactInlineContentSpec( + inlineMathConfig, + { + render: InlineMathPreview, + toExternalHTML: InlineMathML, + }, + [ + SourceInlineContentWithPreviewExtension({ + key: INLINE_MATH_PREVIEW_KEY, + inlineContentType: "inlineMath", + }), + ], + ); diff --git a/packages/math-block/src/inlineContent/inlineMathConfig.ts b/packages/math-block/src/inlineContent/inlineMathConfig.ts new file mode 100644 index 0000000000..e154d3e59d --- /dev/null +++ b/packages/math-block/src/inlineContent/inlineMathConfig.ts @@ -0,0 +1,5 @@ +export const inlineMathConfig = { + type: "inlineMath" as const, + propSchema: {}, + content: "styled" as const, +}; diff --git a/packages/react/src/schema/ReactInlineContentSpec.tsx b/packages/react/src/schema/ReactInlineContentSpec.tsx index 84df6cefb7..deb77e1cd0 100644 --- a/packages/react/src/schema/ReactInlineContentSpec.tsx +++ b/packages/react/src/schema/ReactInlineContentSpec.tsx @@ -6,6 +6,8 @@ import { createInternalInlineContentSpec, CustomInlineContentConfig, CustomInlineContentImplementation, + Extension, + ExtensionFactoryInstance, getInlineContentParseRules, InlineContentFromConfig, InlineContentSchemaWithInlineContent, @@ -44,6 +46,16 @@ export type ReactCustomInlineContentRenderProps< S >; contentRef: (node: HTMLElement | null) => void; + /** + * The ProseMirror node backing this inline content. + */ + node: NodeViewProps["node"]; + /** + * Returns this inline content's position in the document. When rendered + * outside the editor (i.e. serialized to HTML) this is a no-op that returns + * `undefined`. + */ + getPos: NodeViewProps["getPos"]; }; // extend BlockConfig but use a React render function @@ -105,6 +117,8 @@ export function InlineContentWrapper< * @param inlineContentImplementation - The React implementation, including a * `render` component and optionally a `toExternalHTML` component and `parse` * rules. + * @param extensions - Optional editor extensions registered alongside this + * inline content (e.g. for keyboard handling), mirroring block specs. * @returns An `InlineContentSpec` that can be passed to the editor's schema. */ export function createReactInlineContentSpec< @@ -114,6 +128,7 @@ export function createReactInlineContentSpec< >( inlineContentConfig: T, inlineContentImplementation: ReactInlineContentImplementation, + extensions?: (Extension | ExtensionFactoryInstance)[], ): InlineContentSpec { const node = Node.create({ name: inlineContentConfig.type as T["type"], @@ -166,6 +181,8 @@ export function createReactInlineContentSpec< // No-op }} editor={editor} + node={node} + getPos={() => undefined} /> ), editor, @@ -205,6 +222,8 @@ export function createReactInlineContentSpec< } }} editor={editor} + node={props.node} + getPos={props.getPos} inlineContent={ nodeToCustomInlineContent( props.node, @@ -248,6 +267,12 @@ export function createReactInlineContentSpec< node, render(inlineContent, updateInlineContent, editor) { const Content = inlineContentImplementation.render; + // Rendered outside the editor (serialization), so there's no live node + // view - derive the node from the content and stub out `getPos`. + const node = inlineContentToNodes( + [inlineContent] as any, + editor.pmSchema, + )[0]; const output = renderToDOMSpec((ref) => { return ( undefined} /> ); @@ -275,6 +302,12 @@ export function createReactInlineContentSpec< const Content = inlineContentImplementation.toExternalHTML || inlineContentImplementation.render; + // Rendered outside the editor (serialization), so there's no live node + // view - derive the node from the content and stub out `getPos`. + const node = inlineContentToNodes( + [inlineContent] as any, + editor.pmSchema, + )[0]; const output = renderToDOMSpec((ref) => { return ( { // no-op }} + node={node} + getPos={() => undefined} /> ); @@ -301,5 +336,6 @@ export function createReactInlineContentSpec< return output; }, }, + extensions, ) as any; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03e1477e24..b3a59af954 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4836,6 +4836,9 @@ importers: prosemirror-state: specifier: ^1.4.4 version: 1.4.4 + prosemirror-view: + specifier: ^1.41.4 + version: 1.41.8 devDependencies: '@blocknote/react': specifier: workspace:^ From 98eddb3ca9119eae975975f6d5ab0a9feeedfe4a Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 1 Jul 2026 00:34:36 +0200 Subject: [PATCH 17/21] Refactored math block/inline content to have the same structure --- .../09-math-block/src/App.tsx | 24 ++++----- packages/core/src/editor/Block.css | 46 +++++++---------- packages/math-block/src/block.ts | 51 ------------------- .../src/block/createMathBlockConfig.ts | 12 +++++ .../block/react/createReactMathBlockSpec.tsx | 35 +++++++++++++ .../render/MathBlockPreviewWithPopup.tsx} | 30 +++-------- .../toExternalHTML/BlockMathMLElement.tsx | 33 ++++++++++++ .../shared/parse/parseBlockMathMLElement.ts | 32 ++++++++++++ .../vanilla/createMathBlockSpec.test.ts} | 2 +- .../src/block/vanilla/createMathBlockSpec.ts | 37 ++++++++++++++ .../createMathBlockPreviewWithPopup.tsx | 20 ++++++++ .../createBlockMathMLElement.tsx | 12 +++++ .../math-block/src/helpers/getMathSource.ts | 16 ------ .../src/helpers/parse/parseMathML.ts | 33 ------------ .../src/helpers/render/createMathPreview.ts | 6 --- .../src/helpers/render/renderKatex.ts | 37 -------------- .../src/helpers/render/useKatexPreview.ts | 26 ---------- .../src/helpers/toExternalHTML/MathML.tsx | 12 ----- .../helpers/toExternalHTML/MathMLContent.tsx | 33 ------------ .../helpers/toExternalHTML/createMathML.ts | 30 ----------- packages/math-block/src/index.ts | 28 +++++----- .../src/inlineContent/InlineMathML.tsx | 21 -------- .../createReactInlineMathSpec.tsx | 27 ---------- .../src/inlineContent/inlineMathConfig.ts | 5 -- .../inlineContent/mathInlineContentConfig.ts | 9 ++++ .../createReactMathInlineContentSpec.tsx | 23 +++++++++ .../render/MathInlinePreviewWithPopup.tsx} | 25 ++++----- .../toExternalHTML/InlineMathMLElement.tsx | 38 ++++++++++++++ packages/math-block/src/reactBlock.tsx | 37 -------------- .../src/shared/getMathPlainTextContent.ts | 16 ++++++ .../src/shared/react/AddSourceButton.tsx | 15 ++++++ .../src/shared/react/useLatexToMathML.ts | 14 +++++ .../src/shared/vanilla/latexToMathML.ts | 33 ++++++++++++ 33 files changed, 396 insertions(+), 422 deletions(-) delete mode 100644 packages/math-block/src/block.ts create mode 100644 packages/math-block/src/block/createMathBlockConfig.ts create mode 100644 packages/math-block/src/block/react/createReactMathBlockSpec.tsx rename packages/math-block/src/{helpers/render/MathPreview.tsx => block/react/render/MathBlockPreviewWithPopup.tsx} (68%) create mode 100644 packages/math-block/src/block/react/toExternalHTML/BlockMathMLElement.tsx create mode 100644 packages/math-block/src/block/shared/parse/parseBlockMathMLElement.ts rename packages/math-block/src/{block.test.ts => block/vanilla/createMathBlockSpec.test.ts} (99%) create mode 100644 packages/math-block/src/block/vanilla/createMathBlockSpec.ts create mode 100644 packages/math-block/src/block/vanilla/render/createMathBlockPreviewWithPopup.tsx create mode 100644 packages/math-block/src/block/vanilla/toExternalHTML/createBlockMathMLElement.tsx delete mode 100644 packages/math-block/src/helpers/getMathSource.ts delete mode 100644 packages/math-block/src/helpers/parse/parseMathML.ts delete mode 100644 packages/math-block/src/helpers/render/createMathPreview.ts delete mode 100644 packages/math-block/src/helpers/render/renderKatex.ts delete mode 100644 packages/math-block/src/helpers/render/useKatexPreview.ts delete mode 100644 packages/math-block/src/helpers/toExternalHTML/MathML.tsx delete mode 100644 packages/math-block/src/helpers/toExternalHTML/MathMLContent.tsx delete mode 100644 packages/math-block/src/helpers/toExternalHTML/createMathML.ts delete mode 100644 packages/math-block/src/inlineContent/InlineMathML.tsx delete mode 100644 packages/math-block/src/inlineContent/createReactInlineMathSpec.tsx delete mode 100644 packages/math-block/src/inlineContent/inlineMathConfig.ts create mode 100644 packages/math-block/src/inlineContent/mathInlineContentConfig.ts create mode 100644 packages/math-block/src/inlineContent/react/createReactMathInlineContentSpec.tsx rename packages/math-block/src/inlineContent/{InlineMathPreview.tsx => react/render/MathInlinePreviewWithPopup.tsx} (73%) create mode 100644 packages/math-block/src/inlineContent/react/toExternalHTML/InlineMathMLElement.tsx delete mode 100644 packages/math-block/src/reactBlock.tsx create mode 100644 packages/math-block/src/shared/getMathPlainTextContent.ts create mode 100644 packages/math-block/src/shared/react/AddSourceButton.tsx create mode 100644 packages/math-block/src/shared/react/useLatexToMathML.ts create mode 100644 packages/math-block/src/shared/vanilla/latexToMathML.ts diff --git a/examples/06-custom-schema/09-math-block/src/App.tsx b/examples/06-custom-schema/09-math-block/src/App.tsx index 2145d40de8..ac9ccaa903 100644 --- a/examples/06-custom-schema/09-math-block/src/App.tsx +++ b/examples/06-custom-schema/09-math-block/src/App.tsx @@ -33,31 +33,30 @@ const schema = BlockNoteSchema.create().extend({ // Slash menu item to insert a Math block. const insertMath = (editor: typeof schema.BlockNoteEditor) => ({ - title: "Math", + title: "Math Block", subtext: "Insert a LaTeX math formula", onItemClick: () => insertOrUpdateBlockForSlashMenu(editor, { type: "math", }), aliases: ["math", "latex", "formula", "equation"], - group: "Basic blocks", + group: "Advanced", icon: , }); // Slash menu item to insert an inline Math equation. const insertInlineMath = (editor: typeof schema.BlockNoteEditor) => ({ - title: "Inline Math", - subtext: "Insert an inline LaTeX math formula", + title: "Inline Equation", + subtext: "Insert an inline LaTeX equation", onItemClick: () => { editor.insertInlineContent([ - // Inserts an empty inline equation, ready to be edited. { type: "inlineMath", content: "" }, // Adds a trailing space so the cursor can leave the equation. " ", ]); }, - aliases: ["inline math", "inline latex", "inline formula", "inline equation"], - group: "Inline", + aliases: ["math", "latex", "formula", "equation"], + group: "Advanced", icon: , }); @@ -108,14 +107,13 @@ export default function App() { getItems={async (query) => { // Gets all default slash menu items. const defaultItems = getDefaultReactSlashMenuItems(editor); - // Finds index of last item in "Basic blocks" group. - const lastBasicBlockIndex = defaultItems.findLastIndex( - (item) => item.group === "Basic blocks", + // Finds index of last item in "Advanced" group. + const lastAdvancedIndex = defaultItems.findLastIndex( + (item) => item.group === "Advanced", ); - // Inserts the Math item as the last item in the "Basic blocks" group, - // followed by the inline Math item. + // Inserts the Math items at the end of the "Advanced" group. defaultItems.splice( - lastBasicBlockIndex + 1, + lastAdvancedIndex + 1, 0, insertMath(editor), insertInlineMath(editor), diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index dec77f52d3..597e937505 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -528,8 +528,6 @@ NESTED BLOCKS white-space: pre-wrap; } -/* Shown in place of the preview when the source is empty. Mirrors the file -block's "add file" button styling. */ .bn-add-source-code-button { align-items: center; background-color: rgb(242, 241, 238); @@ -555,6 +553,7 @@ block's "add file" button styling. */ } .bn-add-source-code-button-icon { + display: flex; width: 24px; height: 24px; } @@ -564,54 +563,45 @@ block's "add file" button styling. */ margin: 0; } -/* INLINE SOURCE CONTENT PREVIEW */ -/* Inline-content counterpart of the code block preview above. Reuses - `.bn-source-block-popup` and `.bn-code-block-source-error` for the popup. */ -.bn-inline-source-content { - /* Positioning context for the absolutely-positioned source popup, while - staying inline with the surrounding text. */ - position: relative; +/* INLINE MATH PREVIEW */ +/* Shares the block-preview markup/classes; these rules override the + block-preview defaults that don't suit inline use. */ +.bn-inline-content-section .bn-preview-with-source-popup { display: inline-block; + vertical-align: middle; } -.bn-inline-source-preview { +.bn-inline-content-section .bn-preview-container { + padding: 0; + min-height: 0; cursor: pointer; } -/* The shared popup stretches edge-to-edge for the (full-width) code block; for - inline content it sits below the preview and sizes to its source instead. */ -.bn-inline-source-content .bn-source-block-popup { - right: auto; - width: max-content; - min-width: 120px; - max-width: min(400px, 90vw); +.bn-inline-content-section .bn-source-block-popup { + width: 300px; } /* Keeps the popup from collapsing to a sliver while the source is empty. */ -.bn-inline-source-content .bn-source-block-popup > pre { +.bn-inline-content-section .bn-source-block-popup > pre { min-height: 1.5em; box-sizing: content-box; } -.bn-inline-source-content[data-open="true"] .bn-source-block-popup { - opacity: 1; - pointer-events: auto; -} - /* The "add source" button sits inline among text, so it needs to be compact rather than the full-size block-preview button. */ -.bn-inline-source-content .bn-add-source-code-button { +.bn-inline-content-section .bn-add-source-code-button { display: inline-flex; gap: 4px; + margin-inline: 4px; padding: 1px 6px; } -.bn-inline-source-content .bn-add-source-code-button-icon { - width: 1em; - height: 1em; +.bn-inline-content-section .bn-add-source-code-button-icon { + width: 16px; + height: 16px; } -.bn-inline-source-content .bn-add-source-code-button-text { +.bn-inline-content-section .bn-add-source-code-button-text { font-size: 0.85em; } diff --git a/packages/math-block/src/block.ts b/packages/math-block/src/block.ts deleted file mode 100644 index 9cdd1fcccb..0000000000 --- a/packages/math-block/src/block.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - createBlockConfig, - createBlockSpec, - createSourceBlockWithPreview, - SourceBlockWithPreviewExtension, -} 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"; - -const MATH_BLOCK_PREVIEW_KEY = "math-block-preview"; - -export type MathBlockConfig = ReturnType; - -export const createMathBlockConfig = createBlockConfig( - () => - ({ - type: "math" as const, - propSchema: {}, - content: "inline" as const, - }) as const, -); - -export const createMathBlockSpec = createBlockSpec( - createMathBlockConfig, - { - meta: { - code: true, - defining: true, - isolating: false, - }, - parse: (el) => parseMathML(el), - parseContent: ({ el, schema }) => parseMathMLContent({ el, schema }), - render: (block, editor) => - createSourceBlockWithPreview(block, editor, { - createPreview: createMathPreview, - }), - toExternalHTML: (block) => createMathML(block), - }, - [ - // Math blocks always render a preview. - SourceBlockWithPreviewExtension({ - key: MATH_BLOCK_PREVIEW_KEY, - blockType: "math", - hasPreview: () => true, - }), - ], -); diff --git a/packages/math-block/src/block/createMathBlockConfig.ts b/packages/math-block/src/block/createMathBlockConfig.ts new file mode 100644 index 0000000000..c9f887e9cf --- /dev/null +++ b/packages/math-block/src/block/createMathBlockConfig.ts @@ -0,0 +1,12 @@ +import { createBlockConfig } from "@blocknote/core"; + +export const createMathBlockConfig = createBlockConfig( + () => + ({ + type: "math" as const, + propSchema: {}, + content: "inline" as const, + }) as const, +); + +export type MathBlockConfig = ReturnType; diff --git a/packages/math-block/src/block/react/createReactMathBlockSpec.tsx b/packages/math-block/src/block/react/createReactMathBlockSpec.tsx new file mode 100644 index 0000000000..b19b06242a --- /dev/null +++ b/packages/math-block/src/block/react/createReactMathBlockSpec.tsx @@ -0,0 +1,35 @@ +import { SourceBlockWithPreviewExtension } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; + +import { createMathBlockConfig } from "../createMathBlockConfig.js"; +import { + parseBlockMathMLElement, + parseBlockMathMLContent, +} from "../shared/parse/parseBlockMathMLElement.js"; +import { MathBlockPreviewWithPopup } from "./render/MathBlockPreviewWithPopup.js"; +import { BlockMathMLElement } from "./toExternalHTML/BlockMathMLElement.js"; + +const MATH_BLOCK_PREVIEW_KEY = "math-block-preview"; + +export const createReactMathBlockSpec = createReactBlockSpec( + createMathBlockConfig, + { + meta: { + code: true, + defining: true, + isolating: false, + }, + parse: parseBlockMathMLElement, + parseContent: parseBlockMathMLContent, + render: MathBlockPreviewWithPopup, + toExternalHTML: BlockMathMLElement, + }, + [ + // Math blocks always render a preview. + SourceBlockWithPreviewExtension({ + key: MATH_BLOCK_PREVIEW_KEY, + blockType: "math", + hasPreview: () => true, + }), + ], +); diff --git a/packages/math-block/src/helpers/render/MathPreview.tsx b/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx similarity index 68% rename from packages/math-block/src/helpers/render/MathPreview.tsx rename to packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx index 0ea80c8ac7..33d088c64e 100644 --- a/packages/math-block/src/helpers/render/MathPreview.tsx +++ b/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx @@ -5,32 +5,18 @@ import { useExtensionState, } from "@blocknote/react"; import { MouseEvent, useEffect, useRef } from "react"; -import type { MathBlockConfig } from "../../block.js"; -import { getMathSource } from "../getMathSource.js"; -import { useKatexPreview } from "./useKatexPreview.js"; -// Shown in place of the preview when the math content has no source yet. -export const AddSourceButton = (props: { text: string }) => ( -
-
- - - -
-

{props.text}

-
-); +import { MathBlockConfig } from "../../createMathBlockConfig.js"; +import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; +import { AddSourceButton } from "../../../shared/react/AddSourceButton.js"; +import { useLatexToMathMLString } from "../../../shared/react/useLatexToMathML.js"; -export const MathPreview = ( +export const MathBlockPreviewWithPopup = ( props: ReactCustomBlockRenderProps, ) => { const { block, editor, contentRef } = props; - const source = getMathSource(block); + const source = getMathPlainTextContent(block.content); const { store } = useExtension(SourceBlockWithPreviewExtension, { editor }); const popupOpen = useExtensionState(SourceBlockWithPreviewExtension, { @@ -51,7 +37,7 @@ export const MathPreview = ( ?.classList.toggle("ProseMirror-selectednode", selected); }, [selected]); - const { html, error } = useKatexPreview(source, true); + const { mathMLString, error } = useLatexToMathMLString(source); // Opens the popup when clicking the preview. const handleMouseDown = (event: MouseEvent) => { @@ -80,7 +66,7 @@ export const MathPreview = ( onMouseDown={handleMouseDown} > {source.length > 0 ? ( - + ) : ( ) => { + const { mathMLElement } = latexToMathMLElement( + getMathPlainTextContent(block.content), + ); + if (!mathMLElement) { + return null; + } + + // `math` isn't part of React's built-in JSX types, so we alias it to a + // component type to render it as a JSX element. + const Math = "math" as unknown as ComponentType<{ + xmlns: string; + display: string; + dangerouslySetInnerHTML: { __html: string }; + }>; + + return ( + + ); +}; diff --git a/packages/math-block/src/block/shared/parse/parseBlockMathMLElement.ts b/packages/math-block/src/block/shared/parse/parseBlockMathMLElement.ts new file mode 100644 index 0000000000..c815761f4b --- /dev/null +++ b/packages/math-block/src/block/shared/parse/parseBlockMathMLElement.ts @@ -0,0 +1,32 @@ +import { MathMLToLaTeX } from "mathml-to-latex"; +import type { Node, Schema } from "prosemirror-model"; + +export const parseBlockMathMLElement = (el: HTMLElement) => + el.nodeName.toLowerCase() === "math" ? {} : undefined; + +export const parseBlockMathMLContent = ({ + el, + schema, +}: { + el: HTMLElement; + schema: Schema; +}) => { + let contentNode: Node | null = null; + + const annotations = Array.from(el.getElementsByTagName("annotation")); + const texAnnotation = annotations.find( + (annotation) => annotation.getAttribute("encoding") === "application/x-tex", + ); + + // Prioritize getting source from annotation (guaranteed lossless), else parse MathML elements to + // LaTeX. If parsing errors, don't return any content. + if (texAnnotation?.textContent) { + contentNode = schema.text(texAnnotation.textContent.trim()); + } else { + try { + contentNode = schema.text(MathMLToLaTeX.convert(el.outerHTML).trim()); + } catch {} + } + + return schema.nodes["math"].create(null, contentNode).content; +}; diff --git a/packages/math-block/src/block.test.ts b/packages/math-block/src/block/vanilla/createMathBlockSpec.test.ts similarity index 99% rename from packages/math-block/src/block.test.ts rename to packages/math-block/src/block/vanilla/createMathBlockSpec.test.ts index 56443b2488..fcda3052ba 100644 --- a/packages/math-block/src/block.test.ts +++ b/packages/math-block/src/block/vanilla/createMathBlockSpec.test.ts @@ -1,6 +1,6 @@ import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import { createMathBlockSpec } from "./block.js"; +import { createMathBlockSpec } from "./createMathBlockSpec.js"; /** * @vitest-environment jsdom diff --git a/packages/math-block/src/block/vanilla/createMathBlockSpec.ts b/packages/math-block/src/block/vanilla/createMathBlockSpec.ts new file mode 100644 index 0000000000..2ff13cd513 --- /dev/null +++ b/packages/math-block/src/block/vanilla/createMathBlockSpec.ts @@ -0,0 +1,37 @@ +import { + createBlockSpec, + SourceBlockWithPreviewExtension, +} from "@blocknote/core"; + +import { createMathBlockConfig } from "../createMathBlockConfig.js"; +import { + parseBlockMathMLElement, + parseBlockMathMLContent, +} from "../shared/parse/parseBlockMathMLElement.js"; +import { createBlockMathMLElement } from "./toExternalHTML/createBlockMathMLElement.js"; +import { createMathBlockPreviewWithPopup } from "./render/createMathBlockPreviewWithPopup.js"; + +const MATH_BLOCK_PREVIEW_KEY = "math-block-preview"; + +export const createMathBlockSpec = createBlockSpec( + createMathBlockConfig, + { + meta: { + code: true, + defining: true, + isolating: false, + }, + parse: parseBlockMathMLElement, + parseContent: parseBlockMathMLContent, + render: createMathBlockPreviewWithPopup, + toExternalHTML: createBlockMathMLElement, + }, + [ + // Math blocks always render a preview. + SourceBlockWithPreviewExtension({ + key: MATH_BLOCK_PREVIEW_KEY, + blockType: "math", + hasPreview: () => true, + }), + ], +); diff --git a/packages/math-block/src/block/vanilla/render/createMathBlockPreviewWithPopup.tsx b/packages/math-block/src/block/vanilla/render/createMathBlockPreviewWithPopup.tsx new file mode 100644 index 0000000000..3f4da9f117 --- /dev/null +++ b/packages/math-block/src/block/vanilla/render/createMathBlockPreviewWithPopup.tsx @@ -0,0 +1,20 @@ +import { + BlockFromConfig, + BlockNoteEditor, + createSourceBlockWithPreview, +} from "@blocknote/core"; + +import { MathBlockConfig } from "../../createMathBlockConfig.js"; +import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; +import { latexToMathMLElement } from "../../../shared/vanilla/latexToMathML.js"; + +export const createMathBlockPreviewWithPopup = ( + block: BlockFromConfig, + editor: BlockNoteEditor<{ math: MathBlockConfig }, any, any>, +) => + createSourceBlockWithPreview(block, editor, { + createPreview: (block) => ({ + dom: latexToMathMLElement(getMathPlainTextContent(block), true) + .mathMLElement as HTMLElement, + }), + }); diff --git a/packages/math-block/src/block/vanilla/toExternalHTML/createBlockMathMLElement.tsx b/packages/math-block/src/block/vanilla/toExternalHTML/createBlockMathMLElement.tsx new file mode 100644 index 0000000000..96f97bb16d --- /dev/null +++ b/packages/math-block/src/block/vanilla/toExternalHTML/createBlockMathMLElement.tsx @@ -0,0 +1,12 @@ +import { BlockFromConfig } from "@blocknote/core"; + +import { MathBlockConfig } from "../../createMathBlockConfig.js"; +import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; +import { latexToMathMLElement } from "../../../shared/vanilla/latexToMathML.js"; + +export const createBlockMathMLElement = ( + block: BlockFromConfig, +) => ({ + dom: latexToMathMLElement(getMathPlainTextContent(block)) + .mathMLElement as HTMLElement, +}); diff --git a/packages/math-block/src/helpers/getMathSource.ts b/packages/math-block/src/helpers/getMathSource.ts deleted file mode 100644 index 779724ddff..0000000000 --- a/packages/math-block/src/helpers/getMathSource.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** 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) => - node && typeof node === "object" && "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 deleted file mode 100644 index d9d35dfafe..0000000000 --- a/packages/math-block/src/helpers/parse/parseMathML.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { MathMLToLaTeX } from "mathml-to-latex"; -import type { Schema } from "prosemirror-model"; - -const mathMLElementToLaTeX = (el: HTMLElement): string => { - const annotations = Array.from(el.getElementsByTagName("annotation")); - const texAnnotation = annotations.find( - (annotation) => annotation.getAttribute("encoding") === "application/x-tex", - ); - if (texAnnotation?.textContent) { - return texAnnotation.textContent.trim(); - } - - try { - return MathMLToLaTeX.convert(el.outerHTML).trim(); - } catch { - return ""; - } -}; - -export const parseMathML = (el: HTMLElement) => - el.nodeName.toLowerCase() === "math" ? {} : undefined; - -export const parseMathMLContent = ({ - el, - schema, -}: { - el: HTMLElement; - schema: Schema; -}) => { - const source = mathMLElementToLaTeX(el); - return schema.nodes["math"].create(null, source ? schema.text(source) : null) - .content; -}; diff --git a/packages/math-block/src/helpers/render/createMathPreview.ts b/packages/math-block/src/helpers/render/createMathPreview.ts deleted file mode 100644 index 46c4ba9d7d..0000000000 --- a/packages/math-block/src/helpers/render/createMathPreview.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { CodeBlockPreview } from "@blocknote/core"; -import { getMathSource } from "../getMathSource.js"; -import { renderKatex } from "./renderKatex.js"; - -export const createMathPreview: CodeBlockPreview = (block) => - renderKatex(getMathSource(block), true); diff --git a/packages/math-block/src/helpers/render/renderKatex.ts b/packages/math-block/src/helpers/render/renderKatex.ts deleted file mode 100644 index eb6c2fd77f..0000000000 --- a/packages/math-block/src/helpers/render/renderKatex.ts +++ /dev/null @@ -1,37 +0,0 @@ -import katex from "katex"; -import "katex/dist/katex.min.css"; - -/** - * Renders a LaTeX source string to KaTeX HTML (and an equivalent DOM element). - * - * Renders with `throwOnError: true` first so we can surface syntax errors, - * then falls back to KaTeX's own error rendering so the output is never empty. - * - * @param source The LaTeX source to render. - * @param displayMode Whether to render in display (block) or inline mode. - */ -export const renderKatex = ( - source: string, - displayMode: boolean, -): { html: string; dom: HTMLElement; error: string | null } => { - let html: string; - let error: string | null = null; - try { - html = katex.renderToString(source, { - throwOnError: true, - displayMode, - }); - } catch (e) { - error = e instanceof Error ? e.message : String(e); - html = katex.renderToString(source, { - throwOnError: false, - displayMode, - }); - } - - const template = document.createElement("template"); - template.innerHTML = html; - const dom = template.content.firstElementChild as HTMLElement; - - return { html, dom, error }; -}; diff --git a/packages/math-block/src/helpers/render/useKatexPreview.ts b/packages/math-block/src/helpers/render/useKatexPreview.ts deleted file mode 100644 index 249c20bf5d..0000000000 --- a/packages/math-block/src/helpers/render/useKatexPreview.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useRef } from "react"; -import { renderKatex } from "./renderKatex.js"; - -/** - * Renders a LaTeX source string to KaTeX HTML for use in a React preview. - * - * Keeps the last error-free render so a transient syntax error (while the user - * is mid-edit) doesn't blank the preview - mirroring the in-place update of the - * vanilla `createSourceBlockWithPreview`. - * - * @param source The LaTeX source to render. - * @param displayMode Whether to render in display (block) or inline mode. - */ -export const useKatexPreview = ( - source: string, - displayMode: boolean, -): { html: string; error: string | null } => { - const { html, error } = renderKatex(source, displayMode); - - const lastHtmlRef = useRef(null); - if (!error || lastHtmlRef.current === null) { - lastHtmlRef.current = html; - } - - return { html: lastHtmlRef.current, error }; -}; diff --git a/packages/math-block/src/helpers/toExternalHTML/MathML.tsx b/packages/math-block/src/helpers/toExternalHTML/MathML.tsx deleted file mode 100644 index 2e4575e188..0000000000 --- a/packages/math-block/src/helpers/toExternalHTML/MathML.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { ReactCustomBlockRenderProps } from "@blocknote/react"; -import type { MathBlockConfig } from "../../block.js"; -import { getMathSource } from "../getMathSource.js"; -import { MathMLContent } from "./MathMLContent.js"; - -/** - * Renders the block's LaTeX source as MathML for external HTML export. The - * React equivalent of `createMathML`. - */ -export const MathML = (props: ReactCustomBlockRenderProps) => ( - -); diff --git a/packages/math-block/src/helpers/toExternalHTML/MathMLContent.tsx b/packages/math-block/src/helpers/toExternalHTML/MathMLContent.tsx deleted file mode 100644 index 1d21e0c70e..0000000000 --- a/packages/math-block/src/helpers/toExternalHTML/MathMLContent.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { createElement } from "react"; -import { katexToMathML } from "./createMathML.js"; - -/** - * Renders a LaTeX source string as a bare `` (MathML) element for - * external HTML export. The React equivalent of `katexToMathML`. - * - * @param source The LaTeX source to render. - * @param displayMode Whether to render in display (block) or inline mode. - */ -export const MathMLContent = (props: { - source: string; - displayMode: boolean; -}) => { - const { dom } = katexToMathML(props.source, props.displayMode); - - if (!dom) { - return null; - } - - const attributes = Object.fromEntries( - Array.from(dom.attributes).map((attr) => [ - // React expects `className` rather than the `class` DOM attribute. - attr.name === "class" ? "className" : attr.name, - attr.value, - ]), - ); - - return createElement(dom.tagName.toLowerCase(), { - ...attributes, - dangerouslySetInnerHTML: { __html: dom.innerHTML }, - }); -}; diff --git a/packages/math-block/src/helpers/toExternalHTML/createMathML.ts b/packages/math-block/src/helpers/toExternalHTML/createMathML.ts deleted file mode 100644 index a05a25bf41..0000000000 --- a/packages/math-block/src/helpers/toExternalHTML/createMathML.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { BlockFromConfig } from "@blocknote/core"; -import katex from "katex"; -import { getMathSource } from "../getMathSource.js"; - -/** - * Renders a LaTeX source string to a bare `` (MathML) element for use - * outside the editor. - * - * @param source The LaTeX source to render. - * @param displayMode Whether to render in display (block) or inline mode. - */ -export const katexToMathML = (source: string, displayMode: boolean) => { - const mathml = katex.renderToString(source, { - displayMode, - output: "mathml", - throwOnError: false, - }); - - const wrapper = document.createElement("div"); - wrapper.innerHTML = mathml; - - // KaTeX wraps its MathML in a ``; export the bare - // `` element as the top-level node. - const math = wrapper.querySelector("math"); - - return { dom: (math ?? wrapper.firstElementChild) as HTMLElement }; -}; - -export const createMathML = (block: BlockFromConfig) => - katexToMathML(getMathSource(block), true); diff --git a/packages/math-block/src/index.ts b/packages/math-block/src/index.ts index 28a4aede4e..d2dee45297 100644 --- a/packages/math-block/src/index.ts +++ b/packages/math-block/src/index.ts @@ -1,13 +1,17 @@ -export * from "./block.js"; -export * from "./reactBlock.js"; -export * from "./inlineContent/inlineMathConfig.js"; -export * from "./inlineContent/createReactInlineMathSpec.js"; -export * from "./inlineContent/InlineMathPreview.js"; -export * from "./inlineContent/InlineMathML.js"; +export * from "./block/createMathBlockConfig.js"; +export * from "./block/react/createReactMathBlockSpec.js"; +export * from "./block/react/render/MathBlockPreviewWithPopup.js"; +export * from "./block/react/toExternalHTML/BlockMathMLElement.js"; +export * from "./block/shared/parse/parseBlockMathMLElement.js"; +export * from "./block/vanilla/createMathBlockSpec.js"; +export * from "./block/vanilla/render/createMathBlockPreviewWithPopup.js"; +export * from "./block/vanilla/toExternalHTML/createBlockMathMLElement.js"; +export * from "./inlineContent/mathInlineContentConfig.js"; export * from "./inlineContent/SourceInlineContentWithPreviewExtension.js"; -export * from "./helpers/getMathSource.js"; -export * from "./helpers/parse/parseMathML.js"; -export * from "./helpers/render/createMathPreview.js"; -export * from "./helpers/render/MathPreview.js"; -export * from "./helpers/toExternalHTML/createMathML.js"; -export * from "./helpers/toExternalHTML/MathML.js"; +export * from "./inlineContent/react/createReactMathInlineContentSpec.js"; +export * from "./inlineContent/react/render/MathInlinePreviewWithPopup.js"; +export * from "./inlineContent/react/toExternalHTML/InlineMathMLElement.js"; +export * from "./shared/getMathPlainTextContent.js"; +export * from "./shared/react/AddSourceButton.js"; +export * from "./shared/react/useLatexToMathML.js"; +export * from "./shared/vanilla/latexToMathML.js"; diff --git a/packages/math-block/src/inlineContent/InlineMathML.tsx b/packages/math-block/src/inlineContent/InlineMathML.tsx deleted file mode 100644 index 327b2c2f92..0000000000 --- a/packages/math-block/src/inlineContent/InlineMathML.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { StyleSchema } from "@blocknote/core"; -import { ReactCustomInlineContentRenderProps } from "@blocknote/react"; -import { getMathSource } from "../helpers/getMathSource.js"; -import { MathMLContent } from "../helpers/toExternalHTML/MathMLContent.js"; -import { inlineMathConfig } from "./inlineMathConfig.js"; - -/** - * Renders the inline content's LaTeX source as inline MathML for external HTML - * export. - */ -export const InlineMathML = ( - props: ReactCustomInlineContentRenderProps< - typeof inlineMathConfig, - StyleSchema - >, -) => ( - -); diff --git a/packages/math-block/src/inlineContent/createReactInlineMathSpec.tsx b/packages/math-block/src/inlineContent/createReactInlineMathSpec.tsx deleted file mode 100644 index 94a62f22fd..0000000000 --- a/packages/math-block/src/inlineContent/createReactInlineMathSpec.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { createReactInlineContentSpec } from "@blocknote/react"; -import { InlineMathML } from "./InlineMathML.js"; -import { InlineMathPreview } from "./InlineMathPreview.js"; -import { inlineMathConfig } from "./inlineMathConfig.js"; -import { SourceInlineContentWithPreviewExtension } from "./SourceInlineContentWithPreviewExtension.js"; - -const INLINE_MATH_PREVIEW_KEY = "inline-math-preview"; - -/** - * Inline equivalent of the Math block, implemented with React. Renders a KaTeX - * preview inline that can be clicked (or navigated into with the keyboard) to - * edit its LaTeX source in a popup. - */ -export const createReactInlineMathSpec = () => - createReactInlineContentSpec( - inlineMathConfig, - { - render: InlineMathPreview, - toExternalHTML: InlineMathML, - }, - [ - SourceInlineContentWithPreviewExtension({ - key: INLINE_MATH_PREVIEW_KEY, - inlineContentType: "inlineMath", - }), - ], - ); diff --git a/packages/math-block/src/inlineContent/inlineMathConfig.ts b/packages/math-block/src/inlineContent/inlineMathConfig.ts deleted file mode 100644 index e154d3e59d..0000000000 --- a/packages/math-block/src/inlineContent/inlineMathConfig.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const inlineMathConfig = { - type: "inlineMath" as const, - propSchema: {}, - content: "styled" as const, -}; diff --git a/packages/math-block/src/inlineContent/mathInlineContentConfig.ts b/packages/math-block/src/inlineContent/mathInlineContentConfig.ts new file mode 100644 index 0000000000..5e6075bfce --- /dev/null +++ b/packages/math-block/src/inlineContent/mathInlineContentConfig.ts @@ -0,0 +1,9 @@ +import { CustomInlineContentConfig } from "@blocknote/core"; + +export const mathInlineContentConfig = { + type: "inlineMath" as const, + propSchema: {}, + content: "styled" as const, +} satisfies CustomInlineContentConfig; + +export type MathInlineContentConfig = typeof mathInlineContentConfig; diff --git a/packages/math-block/src/inlineContent/react/createReactMathInlineContentSpec.tsx b/packages/math-block/src/inlineContent/react/createReactMathInlineContentSpec.tsx new file mode 100644 index 0000000000..0fe190a065 --- /dev/null +++ b/packages/math-block/src/inlineContent/react/createReactMathInlineContentSpec.tsx @@ -0,0 +1,23 @@ +import { createReactInlineContentSpec } from "@blocknote/react"; + +import { SourceInlineContentWithPreviewExtension } from "../SourceInlineContentWithPreviewExtension.js"; +import { mathInlineContentConfig } from "../mathInlineContentConfig.js"; +import { MathInlinePreviewWithPopup } from "./render/MathInlinePreviewWithPopup.js"; +import { InlineMathMLElement } from "./toExternalHTML/InlineMathMLElement.js"; + +const INLINE_MATH_PREVIEW_KEY = "inline-math-preview"; + +export const createReactInlineMathSpec = () => + createReactInlineContentSpec( + mathInlineContentConfig, + { + render: MathInlinePreviewWithPopup, + toExternalHTML: InlineMathMLElement, + }, + [ + SourceInlineContentWithPreviewExtension({ + key: INLINE_MATH_PREVIEW_KEY, + inlineContentType: "inlineMath", + }), + ], + ); diff --git a/packages/math-block/src/inlineContent/InlineMathPreview.tsx b/packages/math-block/src/inlineContent/react/render/MathInlinePreviewWithPopup.tsx similarity index 73% rename from packages/math-block/src/inlineContent/InlineMathPreview.tsx rename to packages/math-block/src/inlineContent/react/render/MathInlinePreviewWithPopup.tsx index 9645a5b678..08c054ea0a 100644 --- a/packages/math-block/src/inlineContent/InlineMathPreview.tsx +++ b/packages/math-block/src/inlineContent/react/render/MathInlinePreviewWithPopup.tsx @@ -6,22 +6,23 @@ import { } from "@blocknote/react"; import { TextSelection } from "prosemirror-state"; import { MouseEvent, useEffect, useRef } from "react"; -import { getMathSource } from "../helpers/getMathSource.js"; -import { AddSourceButton } from "../helpers/render/MathPreview.js"; -import { useKatexPreview } from "../helpers/render/useKatexPreview.js"; -import { inlineMathConfig } from "./inlineMathConfig.js"; -import { SourceInlineContentWithPreviewExtension } from "./SourceInlineContentWithPreviewExtension.js"; -export const InlineMathPreview = ( +import { MathInlineContentConfig } from "../../mathInlineContentConfig.js"; +import { SourceInlineContentWithPreviewExtension } from "../../SourceInlineContentWithPreviewExtension.js"; +import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; +import { AddSourceButton } from "../../../shared/react/AddSourceButton.js"; +import { useLatexToMathMLString } from "../../../shared/react/useLatexToMathML.js"; + +export const MathInlinePreviewWithPopup = ( props: ReactCustomInlineContentRenderProps< - typeof inlineMathConfig, + MathInlineContentConfig, StyleSchema >, ) => { const { inlineContent, editor, contentRef, node, getPos } = props; const pos = getPos(); - const source = getMathSource(inlineContent); + const source = getMathPlainTextContent(inlineContent.content); const { store } = useExtension(SourceInlineContentWithPreviewExtension, { editor, @@ -40,7 +41,7 @@ export const InlineMathPreview = ( ?.classList.toggle("ProseMirror-selectednode", selected); }, [selected]); - const { html, error } = useKatexPreview(source, true); + const { mathMLString, error } = useLatexToMathMLString(source, true); // Opens the popup when clicking the preview. const handleMouseDown = (event: MouseEvent) => { @@ -65,16 +66,16 @@ export const InlineMathPreview = ( return ( {source.length > 0 ? ( - + ) : ( ) => { + const { mathMLElement } = latexToMathMLElement( + getMathPlainTextContent(inlineContent.content), + true, + ); + if (!mathMLElement) { + return null; + } + + // `math` isn't part of React's built-in JSX types, so we alias it to a + // component type to render it as a JSX element. + const Math = "math" as unknown as ComponentType<{ + xmlns: string; + display: string; + dangerouslySetInnerHTML: { __html: string }; + }>; + + return ( + + ); +}; diff --git a/packages/math-block/src/reactBlock.tsx b/packages/math-block/src/reactBlock.tsx deleted file mode 100644 index 11627a729f..0000000000 --- a/packages/math-block/src/reactBlock.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { SourceBlockWithPreviewExtension } from "@blocknote/core"; -import { createReactBlockSpec } from "@blocknote/react"; -import { createMathBlockConfig } from "./block.js"; -import { - parseMathML, - parseMathMLContent, -} from "./helpers/parse/parseMathML.js"; -import { MathPreview } from "./helpers/render/MathPreview.js"; -import { MathML } from "./helpers/toExternalHTML/MathML.js"; - -const MATH_BLOCK_PREVIEW_KEY = "math-block-preview"; - -// React equivalent of `createMathBlockSpec`. Renders the preview and external -// HTML with React components (`MathPreview` and `MathML`) via -// `createReactBlockSpec`, but is otherwise identical. -export const createReactMathBlockSpec = createReactBlockSpec( - createMathBlockConfig, - { - meta: { - code: true, - defining: true, - isolating: false, - }, - parse: (el) => parseMathML(el), - parseContent: ({ el, schema }) => parseMathMLContent({ el, schema }), - render: MathPreview, - toExternalHTML: MathML, - }, - [ - // Math blocks always render a preview. - SourceBlockWithPreviewExtension({ - key: MATH_BLOCK_PREVIEW_KEY, - blockType: "math", - hasPreview: () => true, - }), - ], -); diff --git a/packages/math-block/src/shared/getMathPlainTextContent.ts b/packages/math-block/src/shared/getMathPlainTextContent.ts new file mode 100644 index 0000000000..fe1f558fce --- /dev/null +++ b/packages/math-block/src/shared/getMathPlainTextContent.ts @@ -0,0 +1,16 @@ +// Converts rich text content in math blocks/inline content to plain text. +// Should be removed once we add plain text support for blocks/inline content +export const getMathPlainTextContent = (content: unknown): string => { + if (typeof content === "string") { + return content; + } + + if (Array.isArray(content)) { + return content + .map((node) => + node && typeof node === "object" && "text" in node ? node.text : "", + ) + .join(""); + } + return ""; +}; diff --git a/packages/math-block/src/shared/react/AddSourceButton.tsx b/packages/math-block/src/shared/react/AddSourceButton.tsx new file mode 100644 index 0000000000..90b38f11aa --- /dev/null +++ b/packages/math-block/src/shared/react/AddSourceButton.tsx @@ -0,0 +1,15 @@ +// Shown in place of the preview when the math content has no source yet. +export const AddSourceButton = (props: { text: string }) => ( +
+
+ + + +
+

{props.text}

+
+); diff --git a/packages/math-block/src/shared/react/useLatexToMathML.ts b/packages/math-block/src/shared/react/useLatexToMathML.ts new file mode 100644 index 0000000000..6bcff1fe44 --- /dev/null +++ b/packages/math-block/src/shared/react/useLatexToMathML.ts @@ -0,0 +1,14 @@ +import { useRef } from "react"; + +import { latexToMathMLString } from "../vanilla/latexToMathML.js"; + +export const useLatexToMathMLString = (latex: string, inline = false) => { + const lastValidMathMLStringRef = useRef(""); + + const { mathMLString, error } = latexToMathMLString(latex, inline); + if (!error || lastValidMathMLStringRef.current === "") { + lastValidMathMLStringRef.current = mathMLString; + } + + return { mathMLString: lastValidMathMLStringRef.current, error }; +}; diff --git a/packages/math-block/src/shared/vanilla/latexToMathML.ts b/packages/math-block/src/shared/vanilla/latexToMathML.ts new file mode 100644 index 0000000000..23858a7c94 --- /dev/null +++ b/packages/math-block/src/shared/vanilla/latexToMathML.ts @@ -0,0 +1,33 @@ +import katex from "katex"; +import "katex/dist/katex.min.css"; + +export const latexToMathMLString = (latex: string, inline = false) => { + try { + return { + mathMLString: katex.renderToString(latex, { + throwOnError: true, + displayMode: !inline, + }), + error: undefined, + }; + } catch (error) { + return { + mathMLString: katex.renderToString(latex, { + throwOnError: false, + displayMode: !inline, + }), + error: error instanceof Error ? error.message : String(error), + }; + } +}; + +export const latexToMathMLElement = (latex: string, inline = false) => { + const { mathMLString, error } = latexToMathMLString(latex, inline); + + // Katex wraps the `math` element in a `span`, which we don't need. + const wrapper = document.createElement("div"); + wrapper.innerHTML = mathMLString; + const mathMLElement = wrapper.querySelector("math") as MathMLElement; + + return { mathMLElement, error }; +}; From 2d53854d1c11d36f1df0f8f2a1af8c4402cd6451 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 1 Jul 2026 00:54:53 +0200 Subject: [PATCH 18/21] Small fixes --- .../render/MathBlockPreviewWithPopup.tsx | 4 +-- .../toExternalHTML/BlockMathMLElement.tsx | 2 +- .../createMathBlockPreviewWithPopup.tsx | 2 +- .../createBlockMathMLElement.tsx | 2 +- packages/math-block/src/index.ts | 7 ++-- .../render/MathInlinePreviewWithPopup.tsx | 4 +-- .../toExternalHTML/InlineMathMLElement.tsx | 4 +-- .../src/shared/latexToHTMLString.ts | 28 ++++++++++++++++ .../react/{ => render}/AddSourceButton.tsx | 0 .../react/{ => render}/useLatexToMathML.ts | 4 +-- .../src/shared/vanilla/latexToMathML.ts | 33 ------------------- .../toExternalHTML/latexToMathMLElement.ts | 16 +++++++++ 12 files changed, 59 insertions(+), 47 deletions(-) create mode 100644 packages/math-block/src/shared/latexToHTMLString.ts rename packages/math-block/src/shared/react/{ => render}/AddSourceButton.tsx (100%) rename packages/math-block/src/shared/react/{ => render}/useLatexToMathML.ts (70%) delete mode 100644 packages/math-block/src/shared/vanilla/latexToMathML.ts create mode 100644 packages/math-block/src/shared/vanilla/toExternalHTML/latexToMathMLElement.ts diff --git a/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx b/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx index 33d088c64e..ba751ceb8a 100644 --- a/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx +++ b/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx @@ -8,8 +8,8 @@ import { MouseEvent, useEffect, useRef } from "react"; import { MathBlockConfig } from "../../createMathBlockConfig.js"; import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; -import { AddSourceButton } from "../../../shared/react/AddSourceButton.js"; -import { useLatexToMathMLString } from "../../../shared/react/useLatexToMathML.js"; +import { AddSourceButton } from "../../../shared/react/render/AddSourceButton.js"; +import { useLatexToMathMLString } from "../../../shared/react/render/useLatexToMathML.js"; export const MathBlockPreviewWithPopup = ( props: ReactCustomBlockRenderProps, diff --git a/packages/math-block/src/block/react/toExternalHTML/BlockMathMLElement.tsx b/packages/math-block/src/block/react/toExternalHTML/BlockMathMLElement.tsx index cb15da2ad0..1737ccc8c3 100644 --- a/packages/math-block/src/block/react/toExternalHTML/BlockMathMLElement.tsx +++ b/packages/math-block/src/block/react/toExternalHTML/BlockMathMLElement.tsx @@ -3,7 +3,7 @@ import type { ComponentType } from "react"; import { MathBlockConfig } from "../../createMathBlockConfig.js"; import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; -import { latexToMathMLElement } from "../../../shared/vanilla/latexToMathML.js"; +import { latexToMathMLElement } from "../../../shared/vanilla/toExternalHTML/latexToMathMLElement.js"; export const BlockMathMLElement = ({ block, diff --git a/packages/math-block/src/block/vanilla/render/createMathBlockPreviewWithPopup.tsx b/packages/math-block/src/block/vanilla/render/createMathBlockPreviewWithPopup.tsx index 3f4da9f117..9fb4afb014 100644 --- a/packages/math-block/src/block/vanilla/render/createMathBlockPreviewWithPopup.tsx +++ b/packages/math-block/src/block/vanilla/render/createMathBlockPreviewWithPopup.tsx @@ -6,7 +6,7 @@ import { import { MathBlockConfig } from "../../createMathBlockConfig.js"; import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; -import { latexToMathMLElement } from "../../../shared/vanilla/latexToMathML.js"; +import { latexToMathMLElement } from "../../../shared/vanilla/toExternalHTML/latexToMathMLElement.js"; export const createMathBlockPreviewWithPopup = ( block: BlockFromConfig, diff --git a/packages/math-block/src/block/vanilla/toExternalHTML/createBlockMathMLElement.tsx b/packages/math-block/src/block/vanilla/toExternalHTML/createBlockMathMLElement.tsx index 96f97bb16d..efb2af41ae 100644 --- a/packages/math-block/src/block/vanilla/toExternalHTML/createBlockMathMLElement.tsx +++ b/packages/math-block/src/block/vanilla/toExternalHTML/createBlockMathMLElement.tsx @@ -2,7 +2,7 @@ import { BlockFromConfig } from "@blocknote/core"; import { MathBlockConfig } from "../../createMathBlockConfig.js"; import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; -import { latexToMathMLElement } from "../../../shared/vanilla/latexToMathML.js"; +import { latexToMathMLElement } from "../../../shared/vanilla/toExternalHTML/latexToMathMLElement.js"; export const createBlockMathMLElement = ( block: BlockFromConfig, diff --git a/packages/math-block/src/index.ts b/packages/math-block/src/index.ts index d2dee45297..6ad52982f4 100644 --- a/packages/math-block/src/index.ts +++ b/packages/math-block/src/index.ts @@ -12,6 +12,7 @@ export * from "./inlineContent/react/createReactMathInlineContentSpec.js"; export * from "./inlineContent/react/render/MathInlinePreviewWithPopup.js"; export * from "./inlineContent/react/toExternalHTML/InlineMathMLElement.js"; export * from "./shared/getMathPlainTextContent.js"; -export * from "./shared/react/AddSourceButton.js"; -export * from "./shared/react/useLatexToMathML.js"; -export * from "./shared/vanilla/latexToMathML.js"; +export * from "./shared/latexToHTMLString.js"; +export * from "./shared/react/render/AddSourceButton.js"; +export * from "./shared/react/render/useLatexToMathML.js"; +export * from "./shared/vanilla/toExternalHTML/latexToMathMLElement.js"; diff --git a/packages/math-block/src/inlineContent/react/render/MathInlinePreviewWithPopup.tsx b/packages/math-block/src/inlineContent/react/render/MathInlinePreviewWithPopup.tsx index 08c054ea0a..1f44aba106 100644 --- a/packages/math-block/src/inlineContent/react/render/MathInlinePreviewWithPopup.tsx +++ b/packages/math-block/src/inlineContent/react/render/MathInlinePreviewWithPopup.tsx @@ -10,8 +10,8 @@ import { MouseEvent, useEffect, useRef } from "react"; import { MathInlineContentConfig } from "../../mathInlineContentConfig.js"; import { SourceInlineContentWithPreviewExtension } from "../../SourceInlineContentWithPreviewExtension.js"; import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; -import { AddSourceButton } from "../../../shared/react/AddSourceButton.js"; -import { useLatexToMathMLString } from "../../../shared/react/useLatexToMathML.js"; +import { AddSourceButton } from "../../../shared/react/render/AddSourceButton.js"; +import { useLatexToMathMLString } from "../../../shared/react/render/useLatexToMathML.js"; export const MathInlinePreviewWithPopup = ( props: ReactCustomInlineContentRenderProps< diff --git a/packages/math-block/src/inlineContent/react/toExternalHTML/InlineMathMLElement.tsx b/packages/math-block/src/inlineContent/react/toExternalHTML/InlineMathMLElement.tsx index f23257cc48..a86edda384 100644 --- a/packages/math-block/src/inlineContent/react/toExternalHTML/InlineMathMLElement.tsx +++ b/packages/math-block/src/inlineContent/react/toExternalHTML/InlineMathMLElement.tsx @@ -2,9 +2,9 @@ import { StyleSchema } from "@blocknote/core"; import { ReactCustomInlineContentRenderProps } from "@blocknote/react"; import type { ComponentType } from "react"; -import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; -import { latexToMathMLElement } from "../../../shared/vanilla/latexToMathML.js"; import { MathInlineContentConfig } from "../../mathInlineContentConfig.js"; +import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; +import { latexToMathMLElement } from "../../../shared/vanilla/toExternalHTML/latexToMathMLElement.js"; export const InlineMathMLElement = ({ inlineContent, diff --git a/packages/math-block/src/shared/latexToHTMLString.ts b/packages/math-block/src/shared/latexToHTMLString.ts new file mode 100644 index 0000000000..c87575d51d --- /dev/null +++ b/packages/math-block/src/shared/latexToHTMLString.ts @@ -0,0 +1,28 @@ +import katex from "katex"; +import "katex/dist/katex.min.css"; + +export const latexToHTMLString = ( + latex: string, + inline = false, + external = false, +) => { + try { + return { + htmlString: katex.renderToString(latex, { + throwOnError: true, + displayMode: !inline, + output: external ? "mathml" : "htmlAndMathml", + }), + error: undefined, + }; + } catch (error) { + return { + htmlString: katex.renderToString(latex, { + throwOnError: false, + displayMode: !inline, + output: external ? "mathml" : "htmlAndMathml", + }), + error: error instanceof Error ? error.message : String(error), + }; + } +}; diff --git a/packages/math-block/src/shared/react/AddSourceButton.tsx b/packages/math-block/src/shared/react/render/AddSourceButton.tsx similarity index 100% rename from packages/math-block/src/shared/react/AddSourceButton.tsx rename to packages/math-block/src/shared/react/render/AddSourceButton.tsx diff --git a/packages/math-block/src/shared/react/useLatexToMathML.ts b/packages/math-block/src/shared/react/render/useLatexToMathML.ts similarity index 70% rename from packages/math-block/src/shared/react/useLatexToMathML.ts rename to packages/math-block/src/shared/react/render/useLatexToMathML.ts index 6bcff1fe44..f6314561e2 100644 --- a/packages/math-block/src/shared/react/useLatexToMathML.ts +++ b/packages/math-block/src/shared/react/render/useLatexToMathML.ts @@ -1,11 +1,11 @@ import { useRef } from "react"; -import { latexToMathMLString } from "../vanilla/latexToMathML.js"; +import { latexToHTMLString } from "../../latexToHTMLString.js"; export const useLatexToMathMLString = (latex: string, inline = false) => { const lastValidMathMLStringRef = useRef(""); - const { mathMLString, error } = latexToMathMLString(latex, inline); + const { htmlString: mathMLString, error } = latexToHTMLString(latex, inline); if (!error || lastValidMathMLStringRef.current === "") { lastValidMathMLStringRef.current = mathMLString; } diff --git a/packages/math-block/src/shared/vanilla/latexToMathML.ts b/packages/math-block/src/shared/vanilla/latexToMathML.ts deleted file mode 100644 index 23858a7c94..0000000000 --- a/packages/math-block/src/shared/vanilla/latexToMathML.ts +++ /dev/null @@ -1,33 +0,0 @@ -import katex from "katex"; -import "katex/dist/katex.min.css"; - -export const latexToMathMLString = (latex: string, inline = false) => { - try { - return { - mathMLString: katex.renderToString(latex, { - throwOnError: true, - displayMode: !inline, - }), - error: undefined, - }; - } catch (error) { - return { - mathMLString: katex.renderToString(latex, { - throwOnError: false, - displayMode: !inline, - }), - error: error instanceof Error ? error.message : String(error), - }; - } -}; - -export const latexToMathMLElement = (latex: string, inline = false) => { - const { mathMLString, error } = latexToMathMLString(latex, inline); - - // Katex wraps the `math` element in a `span`, which we don't need. - const wrapper = document.createElement("div"); - wrapper.innerHTML = mathMLString; - const mathMLElement = wrapper.querySelector("math") as MathMLElement; - - return { mathMLElement, error }; -}; diff --git a/packages/math-block/src/shared/vanilla/toExternalHTML/latexToMathMLElement.ts b/packages/math-block/src/shared/vanilla/toExternalHTML/latexToMathMLElement.ts new file mode 100644 index 0000000000..dd684f2aa2 --- /dev/null +++ b/packages/math-block/src/shared/vanilla/toExternalHTML/latexToMathMLElement.ts @@ -0,0 +1,16 @@ +import { latexToHTMLString } from "../../latexToHTMLString.js"; + +export const latexToMathMLElement = (latex: string, inline = false) => { + const { htmlString: mathMLString, error } = latexToHTMLString( + latex, + inline, + true, + ); + + // Katex wraps the `math` element in a `span`, which we don't need. + const wrapper = document.createElement("div"); + wrapper.innerHTML = mathMLString; + const mathMLElement = wrapper.querySelector("math") as MathMLElement; + + return { mathMLElement, error }; +}; From 3e18e85b8d051a8e383289f2cb18434695fb1f7f Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 1 Jul 2026 01:24:59 +0200 Subject: [PATCH 19/21] Added input rules and removed old katex dep from code block --- packages/code-block/package.json | 4 +-- packages/math-block/package.json | 1 + .../src/block/MathBlockInputRulesExtension.ts | 24 ++++++++++++++++++ .../block/react/createReactMathBlockSpec.tsx | 2 ++ .../src/block/vanilla/createMathBlockSpec.ts | 2 ++ packages/math-block/src/index.ts | 1 + ...SourceInlineContentWithPreviewExtension.ts | 25 +++++++++++++++++++ pnpm-lock.yaml | 9 +++---- 8 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 packages/math-block/src/block/MathBlockInputRulesExtension.ts diff --git a/packages/code-block/package.json b/packages/code-block/package.json index 871f6ef4db..3da6a3199c 100644 --- a/packages/code-block/package.json +++ b/packages/code-block/package.json @@ -52,14 +52,12 @@ "@shikijs/core": "^4", "@shikijs/engine-javascript": "^4", "@shikijs/langs-precompiled": "^4", - "@shikijs/themes": "^4", - "katex": "^0.16.11" + "@shikijs/themes": "^4" }, "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/math-block/package.json b/packages/math-block/package.json index 0781172482..5e208a9b3b 100644 --- a/packages/math-block/package.json +++ b/packages/math-block/package.json @@ -54,6 +54,7 @@ "clean": "rimraf dist && rimraf types" }, "dependencies": { + "@handlewithcare/prosemirror-inputrules": "^0.1.4", "katex": "^0.16.11", "mathml-to-latex": "^1.8.0", "prosemirror-model": "^1.25.4", diff --git a/packages/math-block/src/block/MathBlockInputRulesExtension.ts b/packages/math-block/src/block/MathBlockInputRulesExtension.ts new file mode 100644 index 0000000000..1c80b4b09c --- /dev/null +++ b/packages/math-block/src/block/MathBlockInputRulesExtension.ts @@ -0,0 +1,24 @@ +import { createExtension } from "@blocknote/core"; + +/** + * Converts the current block into a math block when a LaTeX display-math + * delimiter is typed at its start: + * - `$$ ` (TeX display math) + * - `\[ ` (LaTeX display math) + * + * The matched delimiter is removed and the block is replaced with an empty + * math block, ready for the LaTeX source to be typed in. + */ +export const MathBlockInputRulesExtension = createExtension({ + key: "math-block-input-rules", + inputRules: [ + { + find: /^\$\$\s$/, + replace: () => ({ type: "math", props: {}, content: [] }), + }, + { + find: /^\\\[\s$/, + replace: () => ({ type: "math", props: {}, content: [] }), + }, + ], +}); diff --git a/packages/math-block/src/block/react/createReactMathBlockSpec.tsx b/packages/math-block/src/block/react/createReactMathBlockSpec.tsx index b19b06242a..b09e81ab27 100644 --- a/packages/math-block/src/block/react/createReactMathBlockSpec.tsx +++ b/packages/math-block/src/block/react/createReactMathBlockSpec.tsx @@ -2,6 +2,7 @@ import { SourceBlockWithPreviewExtension } from "@blocknote/core"; import { createReactBlockSpec } from "@blocknote/react"; import { createMathBlockConfig } from "../createMathBlockConfig.js"; +import { MathBlockInputRulesExtension } from "../MathBlockInputRulesExtension.js"; import { parseBlockMathMLElement, parseBlockMathMLContent, @@ -31,5 +32,6 @@ export const createReactMathBlockSpec = createReactBlockSpec( blockType: "math", hasPreview: () => true, }), + MathBlockInputRulesExtension, ], ); diff --git a/packages/math-block/src/block/vanilla/createMathBlockSpec.ts b/packages/math-block/src/block/vanilla/createMathBlockSpec.ts index 2ff13cd513..36d51aa268 100644 --- a/packages/math-block/src/block/vanilla/createMathBlockSpec.ts +++ b/packages/math-block/src/block/vanilla/createMathBlockSpec.ts @@ -4,6 +4,7 @@ import { } from "@blocknote/core"; import { createMathBlockConfig } from "../createMathBlockConfig.js"; +import { MathBlockInputRulesExtension } from "../MathBlockInputRulesExtension.js"; import { parseBlockMathMLElement, parseBlockMathMLContent, @@ -33,5 +34,6 @@ export const createMathBlockSpec = createBlockSpec( blockType: "math", hasPreview: () => true, }), + MathBlockInputRulesExtension, ], ); diff --git a/packages/math-block/src/index.ts b/packages/math-block/src/index.ts index 6ad52982f4..72af447ef3 100644 --- a/packages/math-block/src/index.ts +++ b/packages/math-block/src/index.ts @@ -1,4 +1,5 @@ export * from "./block/createMathBlockConfig.js"; +export * from "./block/MathBlockInputRulesExtension.js"; export * from "./block/react/createReactMathBlockSpec.js"; export * from "./block/react/render/MathBlockPreviewWithPopup.js"; export * from "./block/react/toExternalHTML/BlockMathMLElement.js"; diff --git a/packages/math-block/src/inlineContent/SourceInlineContentWithPreviewExtension.ts b/packages/math-block/src/inlineContent/SourceInlineContentWithPreviewExtension.ts index 4917e01e3e..9eace36388 100644 --- a/packages/math-block/src/inlineContent/SourceInlineContentWithPreviewExtension.ts +++ b/packages/math-block/src/inlineContent/SourceInlineContentWithPreviewExtension.ts @@ -1,4 +1,8 @@ import { BlockNoteEditor, createExtension, createStore } from "@blocknote/core"; +import { + InputRule, + inputRules as inputRulesPlugin, +} from "@handlewithcare/prosemirror-inputrules"; import { Selection } from "prosemirror-state"; /** @@ -63,6 +67,27 @@ export const SourceInlineContentWithPreviewExtension = createExtension( Enter: moveSelectionOut, Escape: moveSelectionOut, }, + // Cannot use `inputRules` field as it only allows for converting matched content to blocks. + prosemirrorPlugins: [ + inputRulesPlugin({ + rules: [/\$([^$]+)\$$/, /\\\((.+?)\\\)$/].map( + (find) => + new InputRule(find, (state, match, start, end) => { + const source = match[1]?.trim(); + const nodeType = state.schema.nodes[inlineContentType]; + if (!source || !nodeType) { + return null; + } + + return state.tr.replaceRangeWith( + start, + end, + nodeType.create(null, state.schema.text(source)), + ); + }), + ), + }), + ], mount: ({ signal }) => { // The popup is open exactly when the selection is inside the inline // content, so we just track which inline content (if any) holds it. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3a59af954..8f3eaa49b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4619,13 +4619,7 @@ 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 @@ -4824,6 +4818,9 @@ importers: '@blocknote/core': specifier: workspace:^ version: link:../core + '@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) katex: specifier: ^0.16.11 version: 0.16.47 From f5ba0b15e7c521cc73a105814750ad47fecb54d8 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 1 Jul 2026 02:52:13 +0200 Subject: [PATCH 20/21] Changed `Prosemirror-selectednode` element --- .../render/createSourceBlockWithPreview.ts | 22 ++++++++----------- packages/core/src/editor/Block.css | 11 ++++------ .../render/MathBlockPreviewWithPopup.tsx | 17 +++++--------- .../render/MathInlinePreviewWithPopup.tsx | 17 ++++++-------- 4 files changed, 25 insertions(+), 42 deletions(-) diff --git a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts index b56c6ec2c0..fb4b889848 100644 --- a/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts +++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts @@ -116,21 +116,17 @@ export const createSourceBlockWithPreview = ( const store = editor.getExtension(SourceBlockWithPreviewExtension)?.store; - // Sync the popup's initial open state so it survives a re-render (the block's - // DOM isn't mounted yet, so the selected class is left to `updateFromStore`). - previewWithSourcePopup.dataset.open = - store?.state.popupOpen === block.id ? "true" : "false"; - - const unsubscribeFromStore = store?.subscribe(() => { + const updateFromStore = () => { previewWithSourcePopup.dataset.open = store?.state.popupOpen === block.id ? "true" : "false"; - previewWithSourcePopup - .closest(".bn-block-content") - ?.classList.toggle( - "ProseMirror-selectednode", - store?.state.selected === block.id, - ); - }); + previewWithSourcePopup.classList.toggle( + "ProseMirror-selectednode", + store?.state.selected === block.id, + ); + }; + updateFromStore(); + + const unsubscribeFromStore = store?.subscribe(updateFromStore); // Opens the popup when clicking the preview. const handleMouseDown = (event: MouseEvent) => { diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 597e937505..7bc36a8b5e 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -23,7 +23,8 @@ BASIC STYLES .bn-block-content.ProseMirror-selectednode > *, /* Case for node view renderers */ .ProseMirror-selectednode > .bn-block-content > *, -/* Case for inline content (e.g. while the text cursor is within it) */ +/* Case for blocks/inline content where class is set manually */ +.bn-block-content .ProseMirror-selectednode, .bn-inline-content .ProseMirror-selectednode { border-radius: 4px; outline: 4px solid rgb(100, 160, 255); @@ -566,11 +567,6 @@ NESTED BLOCKS /* INLINE MATH PREVIEW */ /* Shares the block-preview markup/classes; these rules override the block-preview defaults that don't suit inline use. */ -.bn-inline-content-section .bn-preview-with-source-popup { - display: inline-block; - vertical-align: middle; -} - .bn-inline-content-section .bn-preview-container { padding: 0; min-height: 0; @@ -592,8 +588,9 @@ NESTED BLOCKS .bn-inline-content-section .bn-add-source-code-button { display: inline-flex; gap: 4px; + height: 19px; margin-inline: 4px; - padding: 1px 6px; + padding: 0 6px; } .bn-inline-content-section .bn-add-source-code-button-icon { diff --git a/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx b/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx index ba751ceb8a..49b700e7de 100644 --- a/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx +++ b/packages/math-block/src/block/react/render/MathBlockPreviewWithPopup.tsx @@ -4,7 +4,7 @@ import { useExtension, useExtensionState, } from "@blocknote/react"; -import { MouseEvent, useEffect, useRef } from "react"; +import { MouseEvent } from "react"; import { MathBlockConfig } from "../../createMathBlockConfig.js"; import { getMathPlainTextContent } from "../../../shared/getMathPlainTextContent.js"; @@ -28,15 +28,6 @@ export const MathBlockPreviewWithPopup = ( selector: (state) => state.selected === block.id, }); - // The source is hidden, so highlight the whole block while the cursor is in - // it. Mirrors the vanilla `createSourceBlockWithPreview` store sync. - const rootRef = useRef(null); - useEffect(() => { - rootRef.current - ?.closest(".bn-block-content") - ?.classList.toggle("ProseMirror-selectednode", selected); - }, [selected]); - const { mathMLString, error } = useLatexToMathMLString(source); // Opens the popup when clicking the preview. @@ -56,8 +47,10 @@ export const MathBlockPreviewWithPopup = ( return (
state.selected === pos, }); - const rootRef = useRef(null); - useEffect(() => { - rootRef.current - ?.closest(".bn-inline-content-section") - ?.classList.toggle("ProseMirror-selectednode", selected); - }, [selected]); - const { mathMLString, error } = useLatexToMathMLString(source, true); // Opens the popup when clicking the preview. @@ -65,8 +58,12 @@ export const MathInlinePreviewWithPopup = ( return ( Date: Wed, 1 Jul 2026 03:24:09 +0200 Subject: [PATCH 21/21] Added exporters --- .../src/docx/defaultSchema/blocks.ts | 47 ++++++---- .../src/react-email/defaultSchema/blocks.tsx | 56 +++++++---- .../src/odt/defaultSchema/blocks.tsx | 50 ++++++---- .../src/pdf/defaultSchema/blocks.tsx | 94 +++++++++++-------- 4 files changed, 149 insertions(+), 98 deletions(-) diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts index 280a557725..f926915448 100644 --- a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts +++ b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts @@ -1,4 +1,6 @@ import { + BlockConfig, + BlockFromConfigNoChildren, BlockMapping, COLORS_DEFAULT, createPageBreakBlockConfig, @@ -25,6 +27,11 @@ import { import { Table } from "../util/Table.js"; import { multiColumnSchema } from "@blocknote/xl-multi-column"; +type BSchema = DefaultBlockSchema & { + pageBreak: ReturnType; + math: BlockConfig<"math", {}, "inline">; +} & typeof multiColumnSchema.blockSchema; + function blockPropsToStyles( props: Partial, colors: typeof COLORS_DEFAULT, @@ -69,10 +76,27 @@ function blockPropsToStyles( })(), }; } + +const codeMapping = ( + block: BlockFromConfigNoChildren, +) => { + const textContent = (block.content as StyledText[])[0]?.text || ""; + + return new Paragraph({ + style: "SourceCode", + children: [ + ...textContent.split("\n").map((line, index) => { + return new TextRun({ + text: line, + break: index > 0 ? 1 : 0, + }); + }), + ], + }); +}; + export const docxBlockMappingForDefaultSchema: BlockMapping< - DefaultBlockSchema & { - pageBreak: ReturnType; - } & typeof multiColumnSchema.blockSchema, + BSchema, any, any, | Promise @@ -162,21 +186,8 @@ export const docxBlockMappingForDefaultSchema: BlockMapping< ...caption(block.props, exporter), ]; }, - codeBlock: (block) => { - const textContent = (block.content as StyledText[])[0]?.text || ""; - - return new Paragraph({ - style: "SourceCode", - children: [ - ...textContent.split("\n").map((line, index) => { - return new TextRun({ - text: line, - break: index > 0 ? 1 : 0, - }); - }), - ], - }); - }, + codeBlock: codeMapping, + math: codeMapping, pageBreak: () => { return new Paragraph({ children: [new PageBreak()], diff --git a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx index a0befbcb32..21afc61c1b 100644 --- a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx +++ b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx @@ -1,4 +1,6 @@ import { + BlockConfig, + BlockFromConfigNoChildren, BlockMapping, createPageBreakBlockConfig, DefaultBlockSchema, @@ -115,12 +117,40 @@ export const defaultReactEmailTextStyles = { }, } satisfies ReactEmailTextStyles; +type BSchema = DefaultBlockSchema & { + pageBreak: ReturnType; + math: BlockConfig<"math", {}, "inline">; +}; + +// Renders a block's inline content as a code block. Used for both code blocks +// and math blocks (which store their LaTeX source as content); math has no +// language, so it's passed explicitly. +const codeMapping = ( + block: BlockFromConfigNoChildren, + language: PrismLanguage, + textStyles: ReactEmailTextStyles, +) => { + const textContent = (block.content as StyledText[])[0]?.text || ""; + + return ( + + ); +}; + export const createReactEmailBlockMappingForDefaultSchema = ( textStyles: ReactEmailTextStyles = defaultReactEmailTextStyles, ): BlockMapping< - DefaultBlockSchema & { - pageBreak: ReturnType; - }, + BSchema, any, any, React.ReactElement, @@ -259,23 +289,9 @@ export const createReactEmailBlockMappingForDefaultSchema = ( ); }, - codeBlock: (block) => { - const textContent = (block.content as StyledText[])[0]?.text || ""; - - return ( - - ); - }, + codeBlock: (block) => + codeMapping(block, block.props.language as PrismLanguage, textStyles), + math: (block) => codeMapping(block, "latex" as PrismLanguage, textStyles), audio: (block) => { // Audio icon SVG const icon = ( diff --git a/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx index 0889c2e2ac..08f9bb325b 100644 --- a/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx +++ b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx @@ -1,5 +1,7 @@ import { + BlockConfig, BlockFromConfig, + BlockFromConfigNoChildren, BlockMapping, createPageBreakBlockConfig, DefaultBlockSchema, @@ -11,6 +13,32 @@ import { import { ODTExporter } from "../odtExporter.js"; import { multiColumnSchema } from "@blocknote/xl-multi-column"; +type BSchema = DefaultBlockSchema & { + pageBreak: ReturnType; + math: BlockConfig<"math", {}, "inline">; +} & typeof multiColumnSchema.blockSchema; + +// Renders a block's inline content as a code paragraph. Used for both code +// blocks and math blocks (which store their LaTeX source as content). +const codeMapping = ( + block: BlockFromConfigNoChildren, +) => { + const textContent = (block.content as StyledText[])[0]?.text || ""; + + return ( + + {...textContent.split("\n").map((line, index) => { + return ( + <> + {index !== 0 && } + {line} + + ); + })} + + ); +}; + export const getTabs = (nestingLevel: number) => { return Array.from({ length: nestingLevel }, (_, i) => ); }; @@ -164,9 +192,7 @@ const wrapWithLists = ( }; export const odtBlockMappingForDefaultSchema: BlockMapping< - DefaultBlockSchema & { - pageBreak: ReturnType; - } & typeof multiColumnSchema.blockSchema, + BSchema, any, any, React.ReactNode, @@ -500,22 +526,8 @@ export const odtBlockMappingForDefaultSchema: BlockMapping< ); }, - codeBlock: (block) => { - const textContent = (block.content as StyledText[])[0]?.text || ""; - - return ( - - {...textContent.split("\n").map((line, index) => { - return ( - <> - {index !== 0 && } - {line} - - ); - })} - - ); - }, + codeBlock: codeMapping, + math: codeMapping, file: async (block) => { return ( diff --git a/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx b/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx index 34985a1753..5a8ca88124 100644 --- a/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx +++ b/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx @@ -1,4 +1,6 @@ import { + BlockConfig, + BlockFromConfigNoChildren, BlockMapping, DefaultBlockSchema, DefaultProps, @@ -19,10 +21,56 @@ import { Table } from "../util/table/Table.js"; const PIXELS_PER_POINT = 0.75; const FONT_SIZE = 16; +type BSchema = DefaultBlockSchema & { + pageBreak: ReturnType; + math: BlockConfig<"math", {}, "inline">; +} & typeof multiColumnSchema.blockSchema; + +// Renders a block's inline content as monospaced source code. Used for both +// code blocks and math blocks (which store their LaTeX source as content). +const codeMapping = ( + block: BlockFromConfigNoChildren, +) => { + // Code blocks should always contain a single `StyledText` inline content. + // However, if this is not the case for whatever reason, we can merge the + // text content of all `StyledText` content in them. + const textContent = (block.content as StyledText[]) + .map((item) => item.text) + .join(""); + const lines = textContent.split("\n").map((line, index) => { + const indent = line.match(/^\s*/)?.[0].length || 0; + + return ( + + {line.trimStart() || <> } + + ); + }); + + return ( + + {lines} + + ); +}; + export const pdfBlockMappingForDefaultSchema: BlockMapping< - DefaultBlockSchema & { - pageBreak: ReturnType; - } & typeof multiColumnSchema.blockSchema, + BSchema, any, any, React.ReactElement, @@ -113,44 +161,8 @@ export const pdfBlockMappingForDefaultSchema: BlockMapping< ); }, - codeBlock: (block) => { - // Code blocks should always contain a single `StyledText` inline content. - // However, if this is not the case for whatever reason, we can merge the - // text content of all `StyledText` content in them. - const textContent = (block.content as StyledText[]) - .map((item) => item.text) - .join(""); - const lines = textContent.split("\n").map((line, index) => { - const indent = line.match(/^\s*/)?.[0].length || 0; - - return ( - - {line.trimStart() || <> } - - ); - }); - - return ( - - {lines} - - ); - }, + codeBlock: codeMapping, + math: codeMapping, pageBreak: () => { return ; },