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