diff --git a/examples/katex-example/README.md b/examples/katex-example/README.md
new file mode 100644
index 000000000..e4bfb2753
--- /dev/null
+++ b/examples/katex-example/README.md
@@ -0,0 +1,39 @@
+# BlockNote KaTeX Equation Block
+
+This example demonstrates the implementation of a custom block for KaTeX equations in BlockNote. It allows users to write and render LaTeX mathematical equations directly in the editor.
+
+## Features
+
+- Dedicated block for inputting and displaying LaTeX equations
+- Display mode toggle for inline vs. block equations
+- Real-time rendering preview as you type
+- Keyboard shortcut: Type `$$` at the beginning of a line and press space to create an equation block
+- Full LaTeX syntax support via KaTeX
+
+## Usage
+
+To use the KaTeX block in your BlockNote editor:
+
+1. Create a new equation block via the block menu or by typing `$$` at the beginning of a line
+2. Enter your LaTeX equation in the input field
+3. Toggle "Display mode" to switch between inline and block equation display
+4. The equation will be rendered in real-time as you type
+
+## Example LaTeX Equations
+
+Try these examples:
+
+- Simple: `E = mc^2`
+- Fractions: `\frac{1}{2} + \frac{1}{3} = \frac{5}{6}`
+- Integrals: `\int_{0}^{\infty} e^{-x^2} dx = \frac{\sqrt{\pi}}{2}`
+- Matrices: `\begin{pmatrix} a & b \\ c & d \end{pmatrix}`
+- Aligned equations: `\begin{align} a &= b + c \\ &= d + e \end{align}`
+
+## Implementation
+
+This block uses KaTeX for rendering LaTeX equations. The implementation adds:
+
+1. A dedicated KaTeX block type
+2. Configuration options for KaTeX rendering
+3. CSS styling for proper equation display
+4. Input handling for LaTeX content
diff --git a/examples/katex-example/index.html b/examples/katex-example/index.html
new file mode 100644
index 000000000..13b4a0558
--- /dev/null
+++ b/examples/katex-example/index.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+ BlockNote KaTeX Example
+
+
+
+
+
+
diff --git a/packages/core/package.json b/packages/core/package.json
index c9539c15d..fcdf243be 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -91,8 +91,10 @@
"@tiptap/extension-text": "^2.11.5",
"@tiptap/extension-underline": "^2.11.5",
"@tiptap/pm": "^2.12.0",
+ "@types/katex": "^0.16.7",
"emoji-mart": "^5.6.0",
"hast-util-from-dom": "^5.0.1",
+ "katex": "^0.16.22",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-highlight": "^0.13.0",
"prosemirror-model": "^1.25.1",
diff --git a/packages/core/src/blocks/KaTeXBlockContent/KaTeXBlockContent.ts b/packages/core/src/blocks/KaTeXBlockContent/KaTeXBlockContent.ts
new file mode 100644
index 000000000..3f024e038
--- /dev/null
+++ b/packages/core/src/blocks/KaTeXBlockContent/KaTeXBlockContent.ts
@@ -0,0 +1,332 @@
+import { InputRule, isTextSelection } from "@tiptap/core";
+import { TextSelection } from "@tiptap/pm/state";
+import katex from "katex";
+import {
+ PropSchema,
+ createBlockSpecFromStronglyTypedTiptapNode,
+ createStronglyTypedTiptapNode,
+} from "../../schema/index.js";
+import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js";
+
+export type KaTeXBlockOptions = {
+ /**
+ * Whether to indent lines with a tab when the user presses `Tab` in a KaTeX block.
+ *
+ * @default true
+ */
+ indentLineWithTab?: boolean;
+ /**
+ * KaTeX rendering options
+ */
+ katexOptions?: katex.KatexOptions;
+};
+
+// KaTeX block options are handled directly in the implementation
+
+export const defaultKaTeXBlockPropSchema = {
+ equation: {
+ default: "",
+ },
+ displayMode: {
+ default: true,
+ },
+} satisfies PropSchema;
+
+const KaTeXBlockContent = createStronglyTypedTiptapNode({
+ name: "katexBlock",
+ content: "inline*",
+ group: "blockContent",
+ marks: "insertion deletion modification",
+ defining: true,
+ addOptions() {
+ return {
+ indentLineWithTab: true,
+ katexOptions: {
+ throwOnError: false,
+ errorColor: "#f44336",
+ },
+ };
+ },
+ addAttributes() {
+ return {
+ equation: {
+ default: "",
+ parseHTML: (element) => {
+ const katexElement = element as HTMLElement;
+ return katexElement.getAttribute("data-equation") || "";
+ },
+ renderHTML: (attributes) => {
+ return {
+ "data-equation": attributes.equation,
+ };
+ },
+ },
+ displayMode: {
+ default: true,
+ parseHTML: (element) => {
+ const katexElement = element as HTMLElement;
+ return katexElement.getAttribute("data-display-mode") === "true";
+ },
+ renderHTML: (attributes) => {
+ return {
+ "data-display-mode": String(attributes.displayMode),
+ };
+ },
+ },
+ };
+ },
+ parseHTML() {
+ return [
+ // Parse from internal HTML.
+ {
+ tag: "div[data-content-type=" + this.name + "]",
+ contentElement: ".bn-inline-content",
+ },
+ // Parse from external HTML.
+ {
+ tag: "div.katex-block",
+ },
+ ];
+ },
+ renderHTML({ HTMLAttributes }) {
+ const { dom, contentDOM } = createDefaultBlockDOMOutputSpec(
+ this.name,
+ "div",
+ this.options.domAttributes?.blockContent || {},
+ {
+ ...(this.options.domAttributes?.inlineContent || {}),
+ ...HTMLAttributes,
+ }
+ );
+
+ return {
+ dom,
+ contentDOM,
+ };
+ },
+ addNodeView() {
+ return ({ editor, node, getPos, HTMLAttributes }) => {
+
+ // Create elements
+ const wrapper = document.createElement("div");
+ const katexOutput = document.createElement("div");
+ const input = document.createElement("textarea");
+ const displayModeToggle = document.createElement("label");
+ const displayModeCheckbox = document.createElement("input");
+ const controlsWrapper = document.createElement("div");
+
+ // Set up initial values
+ const equation = node.attrs.equation || "";
+ const displayMode = node.attrs.displayMode !== false;
+
+ // Create DOM structure
+ const { dom, contentDOM } = createDefaultBlockDOMOutputSpec(
+ this.name,
+ "div",
+ {
+ ...(this.options.domAttributes?.blockContent || {}),
+ ...HTMLAttributes,
+ class: "katex-block",
+ },
+ this.options.domAttributes?.inlineContent || {}
+ );
+
+ // Style elements
+ wrapper.style.display = "flex";
+ wrapper.style.flexDirection = "column";
+ wrapper.style.width = "100%";
+
+ katexOutput.className = "katex-output";
+ katexOutput.style.minHeight = "2em";
+ katexOutput.style.padding = "8px";
+ katexOutput.style.margin = "4px 0";
+ katexOutput.style.border = "1px solid #ddd";
+ katexOutput.style.borderRadius = "4px";
+ katexOutput.style.backgroundColor = "#f9f9f9";
+
+ input.style.width = "100%";
+ input.style.padding = "8px";
+ input.style.border = "1px solid #ddd";
+ input.style.borderRadius = "4px";
+ input.style.fontFamily = "monospace";
+ input.style.minHeight = "2em";
+ input.placeholder = "Enter LaTeX equation...";
+ input.value = equation;
+
+ controlsWrapper.style.display = "flex";
+ controlsWrapper.style.alignItems = "center";
+ controlsWrapper.style.marginTop = "4px";
+
+ displayModeCheckbox.type = "checkbox";
+ displayModeCheckbox.checked = displayMode;
+ displayModeCheckbox.id = "katex-display-mode-" + Math.random().toString(36).substring(2);
+
+ displayModeToggle.htmlFor = displayModeCheckbox.id;
+ displayModeToggle.appendChild(displayModeCheckbox);
+ displayModeToggle.appendChild(document.createTextNode(" Display mode"));
+ displayModeToggle.style.marginLeft = "4px";
+ displayModeToggle.style.userSelect = "none";
+ displayModeToggle.style.fontSize = "0.9em";
+
+ // Hide content DOM as we're using our own input
+ contentDOM.style.display = "none";
+
+ // Set up event handlers
+ const updateKatex = () => {
+ try {
+ const katexOptions = {
+ ...(this.options.katexOptions),
+ displayMode: displayModeCheckbox.checked,
+ throwOnError: false,
+ errorColor: "#f44336",
+ };
+
+ katex.render(input.value, katexOutput, katexOptions);
+
+ // Update the node attributes
+ editor.commands.command(({ tr }) => {
+ if (typeof getPos === "function") {
+ tr.setNodeAttribute(getPos(), "equation", input.value);
+ tr.setNodeAttribute(getPos(), "displayMode", displayModeCheckbox.checked);
+ return true;
+ }
+ return false;
+ });
+ } catch (error: any) {
+ katexOutput.innerHTML = `Error: ${error?.message || 'Error rendering equation'}`;
+ }
+ };
+
+ input.addEventListener("input", updateKatex);
+ displayModeCheckbox.addEventListener("change", updateKatex);
+
+ // Initial render
+ updateKatex();
+
+ // Assemble the DOM
+ controlsWrapper.appendChild(displayModeToggle);
+ wrapper.appendChild(katexOutput);
+ wrapper.appendChild(input);
+ wrapper.appendChild(controlsWrapper);
+ dom.appendChild(wrapper);
+
+ return {
+ dom,
+ contentDOM,
+ update: (updatedNode) => {
+ if (updatedNode.type !== this.type) {
+ return false;
+ }
+
+ // Update input value if it's changed externally
+ if (updatedNode.attrs.equation !== input.value) {
+ input.value = updatedNode.attrs.equation;
+ }
+
+ // Update display mode if it's changed externally
+ if (updatedNode.attrs.displayMode !== displayModeCheckbox.checked) {
+ displayModeCheckbox.checked = updatedNode.attrs.displayMode;
+ }
+
+ updateKatex();
+ return true;
+ },
+ destroy: () => {
+ input.removeEventListener("input", updateKatex);
+ displayModeCheckbox.removeEventListener("change", updateKatex);
+ },
+ };
+ };
+ },
+ addInputRules() {
+ return [
+ new InputRule({
+ find: /^\$\$\s$/,
+ handler: ({ state, range }) => {
+ const $start = state.doc.resolve(range.from);
+ const attributes = {
+ equation: "",
+ displayMode: true,
+ };
+
+ if (
+ !$start
+ .node(-1)
+ .canReplaceWith(
+ $start.index(-1),
+ $start.indexAfter(-1),
+ this.type
+ )
+ ) {
+ return null;
+ }
+
+ state.tr
+ .delete(range.from, range.to)
+ .setBlockType(range.from, range.from, this.type, attributes)
+ .setSelection(TextSelection.create(state.tr.doc, range.from));
+
+ return;
+ },
+ }),
+ ];
+ },
+ addKeyboardShortcuts() {
+ return {
+ Delete: ({ editor }) => {
+ const { selection } = editor.state;
+ const { $from } = selection;
+
+ // When inside empty KaTeX block, on `DELETE` key press, delete the block
+ if (
+ editor.isActive(this.name) &&
+ !$from.parent.textContent &&
+ isTextSelection(selection)
+ ) {
+ // Get the start position of the block for node selection
+ const from = $from.pos - $from.parentOffset - 2;
+
+ editor.chain().setNodeSelection(from).deleteSelection().run();
+
+ return true;
+ }
+
+ return false;
+ },
+ Tab: ({ editor }) => {
+ if (!this.options.indentLineWithTab) {
+ return false;
+ }
+ if (editor.isActive(this.name)) {
+ editor.commands.insertContent(" ");
+ return true;
+ }
+
+ return false;
+ },
+ Enter: ({ editor }) => {
+ const { $from } = editor.state.selection;
+
+ if (!editor.isActive(this.name)) {
+ return false;
+ }
+
+ return editor
+ .chain()
+ .command(({ tr }) => {
+ // Create a new paragraph below
+ const pos = $from.after();
+ tr.insert(pos, editor.schema.nodes.paragraph.create());
+ tr.setSelection(TextSelection.create(tr.doc, pos + 1));
+ return true;
+ })
+ .run();
+ },
+ };
+ },
+});
+
+export const KaTeXBlock = createBlockSpecFromStronglyTypedTiptapNode(
+ KaTeXBlockContent,
+ defaultKaTeXBlockPropSchema
+);
diff --git a/packages/core/src/blocks/KaTeXBlockContent/katex.css b/packages/core/src/blocks/KaTeXBlockContent/katex.css
new file mode 100644
index 000000000..985d4a609
--- /dev/null
+++ b/packages/core/src/blocks/KaTeXBlockContent/katex.css
@@ -0,0 +1,12 @@
+/* Import KaTeX CSS */
+@import 'katex/dist/katex.min.css';
+
+/* Additional custom styles for KaTeX block */
+.katex-block {
+ margin: 0.5em 0;
+}
+
+.katex-output {
+ overflow-x: auto;
+ text-align: center;
+}
diff --git a/packages/core/src/blocks/defaultBlocks.ts b/packages/core/src/blocks/defaultBlocks.ts
index 5e6deeb46..4e9aa447e 100644
--- a/packages/core/src/blocks/defaultBlocks.ts
+++ b/packages/core/src/blocks/defaultBlocks.ts
@@ -25,6 +25,7 @@ import { CodeBlock } from "./CodeBlockContent/CodeBlockContent.js";
import { FileBlock } from "./FileBlockContent/FileBlockContent.js";
import { Heading } from "./HeadingBlockContent/HeadingBlockContent.js";
import { ImageBlock } from "./ImageBlockContent/ImageBlockContent.js";
+import { KaTeXBlock } from "./KaTeXBlockContent/KaTeXBlockContent.js";
import { BulletListItem } from "./ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.js";
import { CheckListItem } from "./ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.js";
import { NumberedListItem } from "./ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.js";
@@ -38,6 +39,7 @@ export const defaultBlockSpecs = {
heading: Heading,
quote: Quote,
codeBlock: CodeBlock,
+ katexBlock: KaTeXBlock,
bulletListItem: BulletListItem,
numberedListItem: NumberedListItem,
checkListItem: CheckListItem,
diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts
index 8008312c3..790560d8f 100644
--- a/packages/core/src/editor/BlockNoteEditor.ts
+++ b/packages/core/src/editor/BlockNoteEditor.ts
@@ -113,6 +113,7 @@ import {
} from "../api/nodeUtil.js";
import { nestedListsToBlockNoteStructure } from "../api/parsers/html/util/nestedLists.js";
import { CodeBlockOptions } from "../blocks/CodeBlockContent/CodeBlockContent.js";
+import { KaTeXBlockOptions } from "../blocks/KaTeXBlockContent/KaTeXBlockContent.js";
import type { ThreadStore, User } from "../comments/index.js";
import type { CursorPlugin } from "../extensions/Collaboration/CursorPlugin.js";
import type { ForkYDocPlugin } from "../extensions/Collaboration/ForkYDocPlugin.js";
@@ -190,6 +191,11 @@ export type BlockNoteEditorOptions<
*/
codeBlock?: CodeBlockOptions;
+ /**
+ * Options for KaTeX equation blocks.
+ */
+ katexBlock?: KaTeXBlockOptions;
+
comments: {
threadStore: ThreadStore;
};
@@ -527,6 +533,7 @@ export class BlockNoteEditor<
headers: boolean;
};
codeBlock: CodeBlockOptions;
+ katexBlock: KaTeXBlockOptions;
};
public static create<
@@ -580,6 +587,13 @@ export class BlockNoteEditor<
supportedLanguages: options?.codeBlock?.supportedLanguages ?? {},
createHighlighter: options?.codeBlock?.createHighlighter ?? undefined,
},
+ katexBlock: {
+ indentLineWithTab: options?.katexBlock?.indentLineWithTab ?? true,
+ katexOptions: options?.katexBlock?.katexOptions ?? {
+ throwOnError: false,
+ errorColor: "#f44336"
+ },
+ },
};
// apply defaults
diff --git a/packages/core/src/style.css b/packages/core/src/style.css
index 8d073cf1e..7334ec76d 100644
--- a/packages/core/src/style.css
+++ b/packages/core/src/style.css
@@ -1,2 +1,3 @@
@import url("./editor/Block.css");
@import url("./editor/editor.css");
+@import url("./blocks/KaTeXBlockContent/katex.css");
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 38fa08518..b3fa27c10 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3262,12 +3262,18 @@ importers:
'@tiptap/pm':
specifier: ^2.12.0
version: 2.12.0
+ '@types/katex':
+ specifier: ^0.16.7
+ version: 0.16.7
emoji-mart:
specifier: ^5.6.0
version: 5.6.0
hast-util-from-dom:
specifier: ^5.0.1
version: 5.0.1
+ katex:
+ specifier: ^0.16.22
+ version: 0.16.22
prosemirror-dropcursor:
specifier: ^1.8.1
version: 1.8.1
@@ -11574,6 +11580,10 @@ packages:
resolution: {integrity: sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==}
hasBin: true
+ katex@0.16.22:
+ resolution: {integrity: sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==}
+ hasBin: true
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -23227,6 +23237,10 @@ snapshots:
dependencies:
commander: 8.3.0
+ katex@0.16.22:
+ dependencies:
+ commander: 8.3.0
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1