diff --git a/docs/content/docs/features/blocks/code-blocks.mdx b/docs/content/docs/features/blocks/code-blocks.mdx index 8f5d1816b3..5a94af0498 100644 --- a/docs/content/docs/features/blocks/code-blocks.mdx +++ b/docs/content/docs/features/blocks/code-blocks.mdx @@ -34,7 +34,6 @@ type CodeBlockOptions = { aliases?: string[]; } >; - createHighlighter?: () => Promise>; }; ``` @@ -44,15 +43,38 @@ type CodeBlockOptions = { `supportedLanguages:` The syntax highlighting languages supported by the code block, which is an empty array by default. -`createHighlighter:` The [Shiki highliter](https://shiki.style/guide/load-theme) to use for syntax highlighting. +**Syntax Highlighting** -BlockNote also provides a generic set of options for syntax highlighting in the `@blocknote/code-block` package, which support a wide range of languages: +Syntax highlighting is handled by a separate editor extension, configured at the editor level via the `syntaxHighlighting` option (not on the code block itself), so it can highlight any block's content: ```ts -import { createCodeBlockSpec } from "@blocknote/core"; -import { codeBlockOptions } from "@blocknote/code-block"; +type SyntaxHighlightingOptions = { + createHighlighter?: () => Promise>; + highlightBlock?: (block: { + type: string; + props: Record; + }) => string | undefined; +}; +``` + +`createHighlighter:` The [Shiki highlighter](https://shiki.style/guide/load-theme) to use for syntax highlighting. -const codeBlock = createCodeBlockSpec(codeBlockOptions); +`highlightBlock:` Picks the language to highlight a block's content as (return the language key, or `undefined` to leave it un-highlighted). This is how you enable highlighting for specific blocks. Defaults to the block's `language` prop (`(block) => block.props.language`), which covers the code block. For a block with a fixed language, return it directly — e.g. for a math block: `(block) => (block.type === "math" ? "latex" : block.props.language)`. + +BlockNote provides a generic, ready-to-use set of these in the `@blocknote/code-block` package, which supports a wide range of languages. The code block options and the highlighter are exported separately: + +```ts +import { createCodeBlockSpec } from "@blocknote/core"; +import { codeBlockOptions, createHighlighter } from "@blocknote/code-block"; + +const editor = useCreateBlockNote({ + syntaxHighlighting: { createHighlighter }, + schema: BlockNoteSchema.create().extend({ + blockSpecs: { + codeBlock: createCodeBlockSpec(codeBlockOptions), + }, + }), +}); ``` See [this example](/examples/theming/code-block) to see it in action. @@ -92,6 +114,15 @@ import { createHighlighter } from "./shiki.bundle.js"; export default function App() { const editor = useCreateBlockNote({ + // The highlighter is configured at the editor level, separately from the + // code block's own options. + syntaxHighlighting: { + createHighlighter: () => + createHighlighter({ + themes: ["light-plus", "dark-plus"], + langs: [], + }), + }, schema: BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec({ @@ -103,11 +134,6 @@ export default function App() { aliases: ["ts"], }, }, - createHighlighter: () => - createHighlighter({ - themes: ["light-plus", "dark-plus"], - langs: [], - }), }), }, }), diff --git a/docs/package.json b/docs/package.json index 5797d7488a..ff2e637b2e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -96,7 +96,8 @@ "tailwind-merge": "^3.4.0", "y-partykit": "^0.0.25", "yjs": "^13.6.27", - "zod": "^4.3.5" + "zod": "^4.3.5", + "@blocknote/math-block": "workspace:*" }, "devDependencies": { "@blocknote/code-block": "workspace:*", diff --git a/examples/04-theming/06-code-block/src/App.tsx b/examples/04-theming/06-code-block/src/App.tsx index 82d10bae9e..a757bada0d 100644 --- a/examples/04-theming/06-code-block/src/App.tsx +++ b/examples/04-theming/06-code-block/src/App.tsx @@ -4,11 +4,14 @@ import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; // This packages some of the most used languages in on-demand bundle -import { codeBlockOptions } from "@blocknote/code-block"; +import { codeBlockOptions, createHighlighter } from "@blocknote/code-block"; export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ + // The Shiki highlighter is configured at the editor level, separately from + // the code block's own options (default language & language menu). + syntaxHighlighting: { createHighlighter }, schema: BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec(codeBlockOptions), diff --git a/examples/04-theming/07-custom-code-block/src/App.tsx b/examples/04-theming/07-custom-code-block/src/App.tsx index 8a9c74eac1..dbeb84b367 100644 --- a/examples/04-theming/07-custom-code-block/src/App.tsx +++ b/examples/04-theming/07-custom-code-block/src/App.tsx @@ -9,6 +9,16 @@ import { createHighlighter } from "./shiki.bundle"; export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ + // The Shiki highlighter is configured at the editor level, separately from + // the code block's own options (default language & language menu). + syntaxHighlighting: { + // This creates a highlighter, it can be asynchronous to load it afterwards + createHighlighter: () => + createHighlighter({ + themes: ["dark-plus", "light-plus"], + langs: [], + }), + }, schema: BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec({ @@ -27,12 +37,6 @@ export default function App() { name: "Vue", }, }, - // This creates a highlighter, it can be asynchronous to load it afterwards - createHighlighter: () => - createHighlighter({ - themes: ["dark-plus", "light-plus"], - langs: [], - }), }), }, }), diff --git a/examples/06-custom-schema/09-math-block/.bnexample.json b/examples/06-custom-schema/09-math-block/.bnexample.json new file mode 100644 index 0000000000..d7b46b399c --- /dev/null +++ b/examples/06-custom-schema/09-math-block/.bnexample.json @@ -0,0 +1,17 @@ +{ + "playground": true, + "docs": true, + "author": "matthewlipski", + "tags": [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu" + ], + "dependencies": { + "@blocknote/code-block": "latest", + "@blocknote/math-block": "latest", + "react-icons": "^5.5.0" + } +} 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..6ecaf4db94 --- /dev/null +++ b/examples/06-custom-schema/09-math-block/package.json @@ -0,0 +1,33 @@ +{ + "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", + "react-icons": "^5.5.0" + }, + "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..fa7b806954 --- /dev/null +++ b/examples/06-custom-schema/09-math-block/src/App.tsx @@ -0,0 +1,92 @@ +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 { + 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() { + const editor = useCreateBlockNote({ + // 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, + 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", + content: "Press the '/' key to open the Slash Menu and add another", + }, + ], + }); + + // Renders the editor instance using a React component. + 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/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-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/examples/06-custom-schema/09-math-block/vite.config.ts b/examples/06-custom-schema/09-math-block/vite.config.ts new file mode 100644 index 0000000000..0133a6da9e --- /dev/null +++ b/examples/06-custom-schema/09-math-block/vite.config.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/packages/code-block/package.json b/packages/code-block/package.json index 3da6a3199c..871f6ef4db 100644 --- a/packages/code-block/package.json +++ b/packages/code-block/package.json @@ -52,12 +52,14 @@ "@shikijs/core": "^4", "@shikijs/engine-javascript": "^4", "@shikijs/langs-precompiled": "^4", - "@shikijs/themes": "^4" + "@shikijs/themes": "^4", + "katex": "^0.16.11" }, "optionalDependencies": { "@shikijs/types": "^4" }, "devDependencies": { + "@types/katex": "^0.16.7", "rimraf": "^5.0.10", "rollup-plugin-webpack-stats": "^0.2.6", "typescript": "^5.9.3", diff --git a/packages/code-block/src/index.test.ts b/packages/code-block/src/index.test.ts index f8a87bbdf4..5eef47b172 100644 --- a/packages/code-block/src/index.test.ts +++ b/packages/code-block/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { codeBlockOptions } from "./index.js"; +import { codeBlockOptions, createHighlighter } from "./index.js"; describe("codeBlock", () => { it("should exist", () => { @@ -11,7 +11,7 @@ describe("codeBlock", () => { it("should have supportedLanguages", () => { expect(codeBlockOptions.supportedLanguages).toBeDefined(); }); - it("should have createHighlighter", () => { - expect(codeBlockOptions.createHighlighter).toBeDefined(); + it("exports a separate createHighlighter", () => { + expect(createHighlighter).toBeDefined(); }); }); diff --git a/packages/code-block/src/index.ts b/packages/code-block/src/index.ts index 2cb588092d..1b5db6d952 100644 --- a/packages/code-block/src/index.ts +++ b/packages/code-block/src/index.ts @@ -1,6 +1,13 @@ import type { CodeBlockOptions } from "@blocknote/core"; -import { createHighlighter } from "./shiki.bundle.js"; +import { createHighlighter as createShikiHighlighter } from "./shiki.bundle.js"; +export const createHighlighter = () => + createShikiHighlighter({ + themes: ["github-dark", "github-light"], + langs: [], + }); + +// TODO: Should this be here or in the core code block? export const codeBlockOptions = { defaultLanguage: "javascript", supportedLanguages: { @@ -197,9 +204,4 @@ export const codeBlockOptions = { aliases: ["objective-c", "objc"], }, }, - createHighlighter: () => - createHighlighter({ - themes: ["github-dark", "github-light"], - langs: [], - }), } satisfies CodeBlockOptions; diff --git a/packages/core/package.json b/packages/core/package.json index 72b58d02c3..a5f6087d78 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -90,6 +90,7 @@ }, "dependencies": { "@emoji-mart/data": "^1.2.1", + "@floating-ui/dom": "^1.7.6", "@handlewithcare/prosemirror-inputrules": "^0.1.4", "@shikijs/types": "^4", "@tanstack/store": "^0.7.7", diff --git a/packages/core/src/blocks/Code/CodeBlockOptions.ts b/packages/core/src/blocks/Code/CodeBlockOptions.ts new file mode 100644 index 0000000000..946a55dba4 --- /dev/null +++ b/packages/core/src/blocks/Code/CodeBlockOptions.ts @@ -0,0 +1,84 @@ +// 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 | DocumentFragment; + error?: string | null; +}; + +export type CodeBlockOptions = { + /** + * Whether to indent lines with a tab when the user presses `Tab` in a code block. + * + * @default true + */ + indentLineWithTab?: boolean; + /** + * The default language to use for code blocks. + * + * @default "text" + */ + defaultLanguage?: string; + /** + * The languages that are supported in the editor. + * + * @example + * { + * javascript: { + * name: "JavaScript", + * aliases: ["js"], + * }, + * typescript: { + * name: "TypeScript", + * aliases: ["ts"], + * }, + * } + */ + supportedLanguages?: Record< + string, + { + /** + * The display name of the language. + */ + name: string; + /** + * Aliases for this language. + */ + aliases?: string[]; + /** + * Renders a preview of the result of the code (e.g. rendered LaTeX). When + * defined, the code block displays this preview instead of the raw source + * by default, and shows the editable source in a popup when selected. + */ + createPreview?: CodeBlockPreview; + } + >; +}; + +export function getLanguageId( + options: CodeBlockOptions, + languageName: string, +): string | undefined { + const normalizedLanguage = languageName.trim().toLowerCase(); + return Object.entries(options.supportedLanguages ?? {}).find( + ([id, { aliases }]) => { + return ( + id.toLowerCase() === normalizedLanguage || + aliases?.some((alias) => alias.toLowerCase() === normalizedLanguage) + ); + }, + )?.[0]; +} diff --git a/packages/core/src/blocks/Code/block.test.ts b/packages/core/src/blocks/Code/block.test.ts index edc26da8b7..fd208c4cf0 100644 --- a/packages/core/src/blocks/Code/block.test.ts +++ b/packages/core/src/blocks/Code/block.test.ts @@ -8,7 +8,7 @@ import { } from "vite-plus/test"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { PartialBlock } from "../defaultBlocks.js"; -import { getLanguageId, type CodeBlockOptions } from "./block.js"; +import { getLanguageId, type CodeBlockOptions } from "./CodeBlockOptions.js"; /** * @vitest-environment jsdom diff --git a/packages/core/src/blocks/Code/block.ts b/packages/core/src/blocks/Code/block.ts index dbb7fc33a9..6010ef96dc 100644 --- a/packages/core/src/blocks/Code/block.ts +++ b/packages/core/src/blocks/Code/block.ts @@ -1,55 +1,12 @@ -import type { HighlighterGeneric } from "@shikijs/types"; -import { createExtension } from "../../editor/BlockNoteExtension.js"; import { createBlockConfig, createBlockSpec } from "../../schema/index.js"; -import { lazyShikiPlugin } from "./shiki.js"; -import { DOMParser } from "@tiptap/pm/model"; - -export type CodeBlockOptions = { - /** - * Whether to indent lines with a tab when the user presses `Tab` in a code block. - * - * @default true - */ - indentLineWithTab?: boolean; - /** - * The default language to use for code blocks. - * - * @default "text" - */ - defaultLanguage?: string; - /** - * The languages that are supported in the editor. - * - * @example - * { - * javascript: { - * name: "JavaScript", - * aliases: ["js"], - * }, - * typescript: { - * name: "TypeScript", - * aliases: ["ts"], - * }, - * } - */ - supportedLanguages?: Record< - string, - { - /** - * The display name of the language. - */ - name: string; - /** - * Aliases for this language. - */ - aliases?: string[]; - } - >; - /** - * The highlighter to use for code blocks. - */ - createHighlighter?: () => Promise>; -}; +import { + parsePreCode, + parsePreCodeContent, +} from "./helpers/parse/parsePreCode.js"; +import { 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; @@ -74,235 +31,25 @@ export const createCodeBlockSpec = createBlockSpec( defining: true, isolating: false, }, - parse: (e) => { - if (e.tagName !== "PRE") { - return undefined; - } - - if ( - e.childElementCount !== 1 || - e.firstElementChild?.tagName !== "CODE" - ) { - return undefined; - } - - const code = e.firstElementChild!; - const language = - code.getAttribute("data-language") || - code.className - .split(" ") - .find((name) => name.includes("language-")) - ?.replace("language-", ""); - - return { language }; - }, - - parseContent: ({ el, schema }) => { - const parser = DOMParser.fromSchema(schema); - const code = el.firstElementChild!; - - return parser.parse(code, { - preserveWhitespace: "full", - topNode: schema.nodes["codeBlock"].create(), - }).content; - }, - - render(block, editor) { - const wrapper = document.createDocumentFragment(); - const pre = document.createElement("pre"); - const code = document.createElement("code"); - pre.appendChild(code); - - let removeSelectChangeListener = undefined; - - if (options.supportedLanguages) { - const select = document.createElement("select"); - - Object.entries(options.supportedLanguages ?? {}).forEach( - ([id, { name }]) => { - const option = document.createElement("option"); - - option.value = id; - option.text = name; - select.appendChild(option); - }, - ); - select.value = - block.props.language || options.defaultLanguage || "text"; - - if (editor.isEditable) { - const handleLanguageChange = (event: Event) => { - const language = (event.target as HTMLSelectElement).value; - - editor.updateBlock(block.id, { props: { language } }); - }; - select.addEventListener("change", handleLanguageChange); - removeSelectChangeListener = () => - select.removeEventListener("change", handleLanguageChange); - } else { - select.disabled = true; - } - - const selectWrapper = document.createElement("div"); - selectWrapper.contentEditable = "false"; - - selectWrapper.appendChild(select); - wrapper.appendChild(selectWrapper); - } - wrapper.appendChild(pre); - - return { - dom: wrapper, - contentDOM: code, - destroy: () => { - removeSelectChangeListener?.(); + parse: (el) => parsePreCode(el), + parseContent: (opts) => parsePreCodeContent(opts, "codeBlock"), + render: (block, editor) => + createSourceBlockWithPreview( + block, + editor, + options.supportedLanguages && { + selectedLanguage: block.props.language, + supportedLanguages: options.supportedLanguages, }, - }; - }, - 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, - }; - }, + ), + 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/parse/parsePreCode.ts b/packages/core/src/blocks/Code/helpers/parse/parsePreCode.ts new file mode 100644 index 0000000000..237462fdb6 --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/parse/parsePreCode.ts @@ -0,0 +1,45 @@ +import { DOMParser, Schema } from "@tiptap/pm/model"; + +export const parsePreCode = (el: HTMLElement) => { + { + if (el.tagName !== "PRE") { + return undefined; + } + + if ( + el.childElementCount !== 1 || + el.firstElementChild?.tagName !== "CODE" + ) { + return undefined; + } + + const code = el.firstElementChild!; + const language = + code.getAttribute("data-language") || + code.className + .split(" ") + .find((name) => name.startsWith("language-")) + ?.replace("language-", ""); + + return { language }; + } +}; + +export const parsePreCodeContent = ( + { + el, + schema, + }: { + el: HTMLElement; + schema: Schema; + }, + blockType: string, +) => { + const parser = DOMParser.fromSchema(schema); + const code = el.firstElementChild!; + + return parser.parse(code, { + preserveWhitespace: "full", + topNode: schema.nodes[blockType].create(), + }).content; +}; diff --git a/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts b/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts new file mode 100644 index 0000000000..1f96a600a9 --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlock.ts @@ -0,0 +1,98 @@ +import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import type { BlockFromConfig } from "../../../../schema/index.js"; + +// 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) => { + if (!editor.isEditable) { + return; + } + + 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); + } + + sourceBlock.appendChild(pre); + + 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..2f5ca733cb --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/render/createSourceBlockWithPreview.ts @@ -0,0 +1,408 @@ +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; + }, +) => { + if ( + options && + "selectedLanguage" in options && + !(options.selectedLanguage in options.supportedLanguages) + ) { + throw new Error(`Language ${options.selectedLanguage} is not supported.`); + } + + const sourceBlock = createSourceBlock( + block, + editor, + options && "selectedLanguage" in options ? options : undefined, + ); + + 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. + 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.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/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..cf96624cf9 100644 --- a/packages/core/src/blocks/index.ts +++ b/packages/core/src/blocks/index.ts @@ -16,6 +16,11 @@ 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/parse/parsePreCode.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"; export * from "./PageBreak/getPageBreakSlashMenuItems.js"; diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 547e009d6f..9249af676f 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -453,6 +453,112 @@ NESTED BLOCKS transition-delay: 0.1s; } +/* CODE BLOCK PREVIEW */ +.bn-block-content[data-content-type="codeBlock"]:has( + .bn-preview-with-source-popup + ) { + background-color: transparent; + color: inherit; +} + +.bn-preview-with-source-popup { + position: relative; + flex: 1; + min-width: 0; +} + +.bn-preview-container { + padding: 12px; + min-height: 1.5em; + cursor: text; +} + +.bn-source-block-popup { + position: absolute; + z-index: 1; + background-color: var(--bn-colors-menu-background); + color: var(--bn-colors-menu-text); + border: var(--bn-border); + border-radius: var(--bn-border-radius-medium); + box-shadow: var(--bn-shadow-medium); + opacity: 0; + pointer-events: none; +} + +.bn-preview-with-source-popup[data-open="true"] .bn-source-block-popup { + opacity: 1; + pointer-events: auto; +} + +.bn-source-block-popup > div > select { + outline: none !important; + appearance: none; + user-select: none; + border: none; + cursor: pointer; + background-color: transparent; + font-size: 0.8em; + color: var(--bn-colors-menu-text); + padding: 8px 16px 0; +} + +.bn-source-block-popup > div > select > option { + color: black; +} + +.bn-source-block-popup > pre { + white-space: pre; + overflow-x: auto; + margin: 0; + width: 100%; + tab-size: 2; + padding: 16px; +} + +.bn-code-block-source-error { + border-top: var(--bn-border); + color: var(--bn-colors-highlights-red-text); + font-size: 0.8em; + padding: 8px 16px; + white-space: pre-wrap; +} + +/* 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/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 13d65ad83d..9395c8ef70 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -17,6 +17,7 @@ import { DefaultStyleSchema, PartialBlock, } from "../blocks/index.js"; +import type { SyntaxHighlightingOptions } from "../extensions/SyntaxHighlighting/SyntaxHighlighting.js"; import { BlockChangeExtension, DropCursorOptions, @@ -253,6 +254,13 @@ export interface BlockNoteEditorOptions< */ setIdAttribute?: boolean; + /** + * Options for syntax highlighting block content: the Shiki highlighter to use, + * and a `highlightBlock` function picking which blocks to highlight and as + * which language. + */ + syntaxHighlighting?: SyntaxHighlightingOptions; + /** * Determines behavior when pressing Tab (or Shift-Tab) while multiple blocks are selected and a toolbar is open. * - `"prefer-navigate-ui"`: Changes focus to the toolbar. User must press Escape to close toolbar before indenting blocks. Better for keyboard accessibility. diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 2bd6f0b34b..1c13f15c3b 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -23,6 +23,7 @@ import { ShowSelectionExtension, SideMenuExtension, SuggestionMenu, + SyntaxHighlightingExtension, TableHandlesExtension, TrailingNodeExtension, } from "../../../extensions/index.js"; @@ -179,6 +180,10 @@ export function getDefaultExtensions( ...(options.trailingBlock !== false ? [TrailingNodeExtension()] : []), ] as ExtensionFactoryInstance[]; + if (options.syntaxHighlighting) { + extensions.push(SyntaxHighlightingExtension(options.syntaxHighlighting)); + } + if ("table" in editor.schema.blockSpecs) { extensions.push(TableHandlesExtension(options)); } diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts new file mode 100644 index 0000000000..36fd862b3a --- /dev/null +++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; +import { SyntaxHighlightingExtension } from "./SyntaxHighlighting.js"; + +/** + * @vitest-environment jsdom + */ + +describe("SyntaxHighlightingExtension", () => { + // The extension only reads `editor.schema.blockSpecs`, so a minimal stub is + // enough. + const fakeEditor = () => + ({ + schema: { + blockSpecs: { + paragraph: { config: { type: "paragraph", content: "inline" } }, + codeBlock: { config: { type: "codeBlock", content: "inline" } }, + image: { config: { type: "image", content: "none" } }, + }, + }, + }) as any; + + const pluginsFor = (options: any) => + SyntaxHighlightingExtension(options)({ editor: fakeEditor() }) + .prosemirrorPlugins; + + // Whether highlighting is enabled at all is decided by the editor (it only + // instantiates this extension when the `syntaxHighlighting` option is set), so + // the extension itself always installs the plugin once created. + it("installs a highlight plugin when a highlighter is configured", () => { + const plugins = pluginsFor({ createHighlighter: async () => ({}) as any }); + + expect(plugins).toHaveLength(1); + }); + + it("installs the plugin even without a highlighter (it no-ops at parse time)", () => { + expect(pluginsFor({})).toHaveLength(1); + }); +}); diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts new file mode 100644 index 0000000000..fab55bcbec --- /dev/null +++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts @@ -0,0 +1,57 @@ +import type { HighlighterGeneric } from "@shikijs/types"; +import type { Block } from "../../blocks/defaultBlocks.js"; +import { + createExtension, + ExtensionOptions, +} from "../../editor/BlockNoteExtension.js"; +import { lazyShikiPlugin } from "./shiki.js"; + +export type SyntaxHighlightingOptions = { + /** + * Creates the Shiki highlighter used for syntax highlighting. Can be + * asynchronous, so the highlighter is only loaded once it's first needed. + * + * When omitted, content renders without syntax highlighting. + */ + createHighlighter?: () => Promise>; + /** + * Picks the language to highlight a block's content as - return the language + * key, or `undefined` to leave the block un-highlighted. This is where you + * enable highlighting for specific blocks. + * + * Defaults to the block's `language` prop (`(block) => block.props.language`), + * which covers the code block. Provide a custom function for blocks with a + * fixed language, e.g. for the math block: + * `(block) => (block.type === "math" ? "latex" : block.props.language)`. + */ + highlightBlock?: (block: Block) => string | undefined; +}; + +/** Highlights a block as its `language` prop (covers the code block). */ +export const defaultHighlightBlock = (block: Block) => + block.props.language as string | undefined; + +/** + * A single editor-wide extension that syntax-highlights block content. Which + * blocks get highlighted (and as which language) is decided by the + * `highlightBlock` option, so individual blocks don't configure it themselves. + * + * Highlighting is opt-in: this extension is only instantiated when the + * `syntaxHighlighting` option is configured (see `getDefaultExtensions`). + */ +export const SyntaxHighlightingExtension = createExtension( + ({ editor, options }: ExtensionOptions) => { + const highlightBlock = options.highlightBlock ?? defaultHighlightBlock; + + // Every block with inline (text) content is a candidate; `highlightBlock` + // decides per-block whether and how to highlight it. + const nodeTypes = Object.values(editor.schema.blockSpecs) + .filter((blockSpec) => blockSpec.config.content === "inline") + .map((blockSpec) => blockSpec.config.type); + + return { + key: "syntaxHighlighting", + prosemirrorPlugins: [lazyShikiPlugin(options, nodeTypes, highlightBlock)], + }; + }, +); diff --git a/packages/core/src/extensions/SyntaxHighlighting/shiki.ts b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts new file mode 100644 index 0000000000..a7a9a0ffb5 --- /dev/null +++ b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts @@ -0,0 +1,94 @@ +import type { HighlighterGeneric } from "@shikijs/types"; +import { Parser, createHighlightPlugin } from "prosemirror-highlight"; +import { createParser } from "prosemirror-highlight/shiki"; +import type { Block } from "../../blocks/defaultBlocks.js"; +import type { SyntaxHighlightingOptions } from "./SyntaxHighlighting.js"; + +export const shikiParserSymbol = Symbol.for("blocknote.shikiParser"); +export const shikiHighlighterPromiseSymbol = Symbol.for( + "blocknote.shikiHighlighterPromise", +); + +// Languages that represent "no highlighting" - skipped without asking Shiki to +// load a grammar for them. +const PLAIN_TEXT_LANGUAGES = ["text", "none", "plaintext", "txt"]; + +/** + * Creates the syntax highlighting plugin for the given block types, lazily + * loading the highlighter on first use. + * + * `highlightBlock` resolves each block to a language, which is passed straight + * to Shiki - it resolves aliases and loads the grammar from its bundle, so any + * language the provided highlighter bundles can be highlighted. + */ +export function lazyShikiPlugin( + options: SyntaxHighlightingOptions, + nodeTypes: string[], + highlightBlock: (block: Block) => string | undefined, +) { + const globalThisForShiki = globalThis as { + [shikiHighlighterPromiseSymbol]?: Promise>; + [shikiParserSymbol]?: Parser; + }; + + let highlighter: HighlighterGeneric | undefined; + let parser: Parser | undefined; + // Languages the highlighter failed to load (e.g. not in its bundle). Tracked + // so we don't keep retrying - and re-triggering re-highlights - forever. + const unsupportedLanguages = new Set(); + const lazyParser: Parser = (parserOptions) => { + if (!options.createHighlighter) { + return []; + } + if (!highlighter) { + globalThisForShiki[shikiHighlighterPromiseSymbol] = + globalThisForShiki[shikiHighlighterPromiseSymbol] || + options.createHighlighter(); + + return globalThisForShiki[shikiHighlighterPromiseSymbol].then( + (createdHighlighter) => { + highlighter = createdHighlighter; + }, + ); + } + const language = parserOptions.language; + + if ( + !language || + PLAIN_TEXT_LANGUAGES.includes(language) || + unsupportedLanguages.has(language) + ) { + return []; + } + + if (!highlighter.getLoadedLanguages().includes(language)) { + return highlighter.loadLanguage(language as any).catch(() => { + // The highlighter doesn't bundle this language - give up on it so we + // don't loop trying to load it on every re-highlight. + unsupportedLanguages.add(language); + }); + } + + if (!parser) { + parser = + globalThisForShiki[shikiParserSymbol] || + createParser(highlighter as any); + globalThisForShiki[shikiParserSymbol] = parser; + } + + return parser(parserOptions); + }; + + return createHighlightPlugin({ + parser: lazyParser, + // The highlight plugin only gives us the block content node, so we can only + // reconstruct the block's `type` and `props` (which is all `highlightBlock` + // needs to pick a language). + languageExtractor: (node) => + highlightBlock({ + type: node.type.name, + props: node.attrs, + } as Block), + nodeTypes, + }); +} diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index e568462a13..12c3f85d02 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -12,6 +12,7 @@ export * from "./PreviousBlockType/PreviousBlockType.js"; export * from "./ShowSelection/ShowSelection.js"; export * from "./SideMenu/SideMenu.js"; export * from "./SuggestionMenu/DefaultGridSuggestionItem.js"; +export * from "./SyntaxHighlighting/SyntaxHighlighting.js"; export * from "./SuggestionMenu/DefaultSuggestionItem.js"; export * from "./SuggestionMenu/getDefaultEmojiPickerItems.js"; export * from "./SuggestionMenu/getDefaultSlashMenuItems.js"; diff --git a/packages/core/src/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/core/src/index.ts b/packages/core/src/index.ts index 4d59e79ab8..1c8608a99c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,7 +25,10 @@ export * from "./util/string.js"; export * from "./util/table.js"; export * from "./util/typescript.js"; -export type { CodeBlockOptions } from "./blocks/Code/block.js"; +export type { + CodeBlockOptions, + CodeBlockPreview, +} from "./blocks/Code/CodeBlockOptions.js"; export { assertEmpty, UnreachableCaseError } from "./util/typescript.js"; export * from "./util/EventEmitter.js"; diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 6df3e68aa4..410567b509 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -219,10 +219,14 @@ export function addNodeAndExtensionsToSpec< applyNonSelectableBlockFix(typedNodeView, this.editor); } - // See explanation for why `update` is not implemented for NodeViews + // We don't add a default `update` method to the node view - when a + // block doesn't provide one, ProseMirror keeps the node view and + // reconciles its `contentDOM` in place as long as the node type stays + // the same. Blocks that build custom DOM which needs to stay in sync + // with the node (e.g. the code block's preview) can return an `update` + // function from `render` to handle updates in place. // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464 - // TODO: in a future version, we might want to implement updates so that - // vanilla blocks don't always re-render entirely (https://github.com/TypeCellOS/BlockNote/issues/220) + // https://github.com/TypeCellOS/BlockNote/issues/220 return typedNodeView; }; }, diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 97550ee331..18b0404baa 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -1,7 +1,11 @@ /** Define the main block types **/ // import { Extension, Node } from "@tiptap/core"; import type { Node, NodeViewRendererProps } from "@tiptap/core"; -import type { Fragment, Schema } from "prosemirror-model"; +import type { + Fragment, + Node as ProsemirrorNode, + Schema, +} from "prosemirror-model"; import type { ViewMutationRecord } from "prosemirror-view"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { @@ -188,6 +192,7 @@ export type LooseBlockSpec< dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; + update?: (node: ProsemirrorNode) => boolean; destroy?: () => void; }; toExternalHTML?: ( @@ -246,6 +251,7 @@ export type BlockSpecs = { dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; + update?: (node: ProsemirrorNode) => boolean; destroy?: () => void; }; toExternalHTML?: ( @@ -510,6 +516,17 @@ export type BlockImplementation< dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; + /** + * Called by ProseMirror when this block's node is updated (e.g. its content + * or props change). Return `true` to handle the update in place - keeping + * the existing DOM - or `false` to have the node view recreated via + * `render`. When omitted, ProseMirror keeps the node view and reconciles its + * `contentDOM` in place as long as the node type stays the same. + * + * Useful for blocks whose `render` builds custom DOM that needs to stay in + * sync with the node (e.g. a code block rendering a preview of its content). + */ + update?: (node: ProsemirrorNode) => boolean; destroy?: () => void; }; diff --git a/packages/math-block/.gitignore b/packages/math-block/.gitignore new file mode 100644 index 0000000000..58f115c8dc --- /dev/null +++ b/packages/math-block/.gitignore @@ -0,0 +1,23 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/math-block/LICENSE b/packages/math-block/LICENSE new file mode 100644 index 0000000000..fa0086a952 --- /dev/null +++ b/packages/math-block/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. \ No newline at end of file diff --git a/packages/math-block/package.json b/packages/math-block/package.json new file mode 100644 index 0000000000..398083e3c7 --- /dev/null +++ b/packages/math-block/package.json @@ -0,0 +1,73 @@ +{ + "name": "@blocknote/math-block", + "homepage": "https://github.com/TypeCellOS/BlockNote", + "private": false, + "sideEffects": [ + "*.css" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/TypeCellOS/BlockNote.git", + "directory": "packages/math-block" + }, + "license": "MPL-2.0", + "version": "0.51.4", + "files": [ + "dist", + "types", + "src" + ], + "keywords": [ + "react", + "javascript", + "editor", + "typescript", + "prosemirror", + "wysiwyg", + "rich-text-editor", + "notion", + "yjs", + "block-based", + "tiptap", + "math", + "latex", + "mathml" + ], + "description": "A \"Notion-style\" block-based extensible text editor built on top of Prosemirror and Tiptap.", + "type": "module", + "source": "src/index.ts", + "types": "./types/src/index.d.ts", + "main": "./dist/blocknote-math-block.cjs", + "module": "./dist/blocknote-math-block.js", + "exports": { + ".": { + "types": "./types/src/index.d.ts", + "import": "./dist/blocknote-math-block.js", + "require": "./dist/blocknote-math-block.cjs" + } + }, + "scripts": { + "dev": "vp dev", + "lint": "vp lint src", + "test": "vp test --run", + "test-watch": "vp test watch", + "clean": "rimraf dist && rimraf types" + }, + "dependencies": { + "katex": "^0.16.11", + "mathml-to-latex": "^1.8.0", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4" + }, + "devDependencies": { + "@blocknote/xl-multi-column": "workspace:^", + "@types/katex": "^0.16.7", + "rimraf": "^5.0.10", + "rollup-plugin-webpack-stats": "^0.2.6", + "typescript": "^5.9.3", + "vite-plus": "catalog:" + }, + "peerDependencies": { + "@blocknote/core": "workspace:^" + } +} diff --git a/packages/math-block/src/block.test.ts b/packages/math-block/src/block.test.ts new file mode 100644 index 0000000000..56443b2488 --- /dev/null +++ b/packages/math-block/src/block.test.ts @@ -0,0 +1,243 @@ +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +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() }, +}); + +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); + }); + + afterEach(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + div.remove(); + }); + + function setup(blocks: any[]) { + editor.replaceBlocks(editor.document, blocks); + } + + /** 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; + } + + /** Whether the source popup is open (the preview is being edited). */ + function isPopupOpen(blockId: string): boolean { + return previewRoot(blockId)?.getAttribute("data-open") === "true"; + } + + /** 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("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 opens the source popup, keeping the caret in the source", () => { + expect(isPopupOpen("math")).toBe(false); + + expect(pressKey("math", "Enter")).toBe(true); + + expect(isPopupOpen("math")).toBe(true); + expect(editor.getTextCursorPosition().block.id).toBe("math"); + }); + + it("Enter again closes the source popup", () => { + pressKey("math", "Enter"); + expect(isPopupOpen("math")).toBe(true); + + expect(pressKey("math", "Enter")).toBe(true); + + expect(isPopupOpen("math")).toBe(false); + expect(editor.getTextCursorPosition().block.id).toBe("math"); + }); + + it("Escape closes the source popup while editing", () => { + pressKey("math", "Enter"); + expect(isPopupOpen("math")).toBe(true); + + expect(pressKey("math", "Escape")).toBe(true); + + expect(isPopupOpen("math")).toBe(false); + }); + + 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(isPopupOpen("math")).toBe(false); + }); + + it("ArrowRight while the popup is hidden moves to the next block", () => { + expect(pressKey("math", "ArrowRight")).toBe(true); + + expect(editor.getTextCursorPosition().block.id).toBe("after"); + }); + + it("ArrowLeft while the popup is hidden moves to the previous block", () => { + expect(pressKey("math", "ArrowLeft")).toBe(true); + + expect(editor.getTextCursorPosition().block.id).toBe("before"); + }); + + 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("math", "ArrowRight", { metaKey: true })).toBe(false); + expect(editor.getTextCursorPosition().block.id).toBe("math"); + }); + + 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(editor.getTextCursorPosition().block.id).toBe("math"); + expect(isPopupOpen("math")).toBe(true); + }); + + it("blocks character input while the popup is closed", () => { + expect(isPopupOpen("math")).toBe(false); + + // 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); + }); + + it("defers character input to the default while the popup is open", () => { + pressKey("math", "Enter"); + expect(isPopupOpen("math")).toBe(true); + + // 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("blocks deletion and indent keys while the popup is closed", () => { + expect(isPopupOpen("math")).toBe(false); + + // 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("defers Ctrl/Cmd shortcuts to the default while the popup is closed", () => { + expect(isPopupOpen("math")).toBe(false); + + // 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("defers deletion keys to the default while the popup is open", () => { + pressKey("math", "Enter"); + expect(isPopupOpen("math")).toBe(true); + + // The source is visible, so deletion is allowed through to ProseMirror. + expect(pressKey("math", "Backspace")).toBe(false); + }); + }); + + describe("at the document edges", () => { + it("ArrowLeft with no previous block defers to the default", () => { + setup([ + { id: "math", type: "math", content: "a^2" }, + { id: "after", type: "paragraph", content: "after" }, + ]); + editor.setTextCursorPosition("math", "start"); + + // No previous block to jump to, so the arrow isn't hijacked. + pressKey("math", "ArrowLeft"); + expect(editor.getTextCursorPosition().block.id).toBe("math"); + }); + + it("ArrowRight with no next block defers to the default", () => { + setup([ + { id: "before", type: "paragraph", content: "before" }, + { id: "math", type: "math", content: "a^2" }, + ]); + editor.setTextCursorPosition("math", "start"); + + // No next block to jump to, so the arrow isn't hijacked. + pressKey("math", "ArrowRight"); + expect(editor.getTextCursorPosition().block.id).toBe("math"); + }); + }); + + describe("clicking the preview", () => { + beforeEach(() => { + setup([ + { id: "before", type: "paragraph", content: "before" }, + { id: "math", type: "math", content: "a^2" }, + ]); + editor.setTextCursorPosition("before", "start"); + }); + + 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; + + preview.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, cancelable: true }), + ); + + 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); + }); + }); +}); diff --git a/packages/math-block/src/block.ts b/packages/math-block/src/block.ts new file mode 100644 index 0000000000..76c8cbb545 --- /dev/null +++ b/packages/math-block/src/block.ts @@ -0,0 +1,37 @@ +import { + createBlockConfig, + createBlockSpec, + createSourceBlockWithPreview, +} from "@blocknote/core"; +import { + parseMathML, + parseMathMLContent, +} from "./helpers/parse/parseMathML.js"; +import { createMathPreview } from "./helpers/render/createMathPreview.js"; +import { createMathML } from "./helpers/toExternalHTML/createMathML.js"; + +export type MathBlockConfig = ReturnType; + +export const createMathBlockConfig = createBlockConfig( + () => + ({ + type: "math" as const, + propSchema: {}, + content: "inline" as const, + }) as const, +); + +export const createMathBlockSpec = createBlockSpec(createMathBlockConfig, { + meta: { + code: true, + defining: true, + isolating: false, + }, + parse: (el) => parseMathML(el), + parseContent: ({ el, schema }) => parseMathMLContent({ el, schema }), + render: (block, editor) => + createSourceBlockWithPreview(block, editor, { + createPreview: createMathPreview, + }), + toExternalHTML: (block) => createMathML(block), +}); diff --git a/packages/math-block/src/helpers/getMathSource.ts b/packages/math-block/src/helpers/getMathSource.ts new file mode 100644 index 0000000000..779724ddff --- /dev/null +++ b/packages/math-block/src/helpers/getMathSource.ts @@ -0,0 +1,16 @@ +/** 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 new file mode 100644 index 0000000000..d9d35dfafe --- /dev/null +++ b/packages/math-block/src/helpers/parse/parseMathML.ts @@ -0,0 +1,33 @@ +import { MathMLToLaTeX } from "mathml-to-latex"; +import type { Schema } from "prosemirror-model"; + +const mathMLElementToLaTeX = (el: HTMLElement): string => { + const annotations = Array.from(el.getElementsByTagName("annotation")); + const texAnnotation = annotations.find( + (annotation) => annotation.getAttribute("encoding") === "application/x-tex", + ); + if (texAnnotation?.textContent) { + return texAnnotation.textContent.trim(); + } + + try { + return MathMLToLaTeX.convert(el.outerHTML).trim(); + } catch { + return ""; + } +}; + +export const parseMathML = (el: HTMLElement) => + el.nodeName.toLowerCase() === "math" ? {} : undefined; + +export const parseMathMLContent = ({ + el, + schema, +}: { + el: HTMLElement; + schema: Schema; +}) => { + const source = mathMLElementToLaTeX(el); + return schema.nodes["math"].create(null, source ? schema.text(source) : null) + .content; +}; diff --git a/packages/math-block/src/helpers/render/createMathPreview.ts b/packages/math-block/src/helpers/render/createMathPreview.ts new file mode 100644 index 0000000000..54b728f8ed --- /dev/null +++ b/packages/math-block/src/helpers/render/createMathPreview.ts @@ -0,0 +1,30 @@ +import type { CodeBlockPreview } from "@blocknote/core"; +import katex from "katex"; +import "katex/dist/katex.min.css"; +import { getMathSource } from "../getMathSource.js"; + +export const createMathPreview: CodeBlockPreview = (block) => { + const source = getMathSource(block); + + // Render with `throwOnError: true` first so we can check for syntax errors. + let html: string; + let error: string | null = null; + try { + html = katex.renderToString(source, { + throwOnError: true, + displayMode: true, + }); + } catch (e) { + error = e instanceof Error ? e.message : String(e); + html = katex.renderToString(source, { + throwOnError: false, + displayMode: true, + }); + } + + const template = document.createElement("template"); + template.innerHTML = html; + const dom = template.content.firstElementChild as HTMLElement; + + return { dom, error }; +}; diff --git a/packages/math-block/src/helpers/toExternalHTML/createMathML.ts b/packages/math-block/src/helpers/toExternalHTML/createMathML.ts new file mode 100644 index 0000000000..23b13de1ab --- /dev/null +++ b/packages/math-block/src/helpers/toExternalHTML/createMathML.ts @@ -0,0 +1,20 @@ +import type { BlockFromConfig } from "@blocknote/core"; +import katex from "katex"; +import { getMathSource } from "../getMathSource.js"; + +export const createMathML = (block: BlockFromConfig) => { + const mathml = katex.renderToString(getMathSource(block), { + displayMode: true, + output: "mathml", + throwOnError: false, + }); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = mathml; + + // KaTeX wraps its MathML in a ``; export the bare + // `` element as the top-level node. + const math = wrapper.querySelector("math"); + + return { dom: (math ?? wrapper.firstElementChild) as HTMLElement }; +}; diff --git a/packages/math-block/src/index.ts b/packages/math-block/src/index.ts new file mode 100644 index 0000000000..a8ec7c51f9 --- /dev/null +++ b/packages/math-block/src/index.ts @@ -0,0 +1,5 @@ +export * from "./block.js"; +export * from "./helpers/getMathSource.js"; +export * from "./helpers/parse/parseMathML.js"; +export * from "./helpers/render/createMathPreview.js"; +export * from "./helpers/toExternalHTML/createMathML.js"; diff --git a/packages/math-block/src/vite-env.d.ts b/packages/math-block/src/vite-env.d.ts new file mode 100644 index 0000000000..bc2d8a36f3 --- /dev/null +++ b/packages/math-block/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/math-block/tsconfig.json b/packages/math-block/tsconfig.json new file mode 100644 index 0000000000..c74ac34642 --- /dev/null +++ b/packages/math-block/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "noEmit": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "outDir": "dist", + "declaration": true, + "declarationDir": "types", + "composite": true, + "skipLibCheck": true, + "emitDeclarationOnly": true + }, + "include": ["src"] +} diff --git a/packages/math-block/vite.config.ts b/packages/math-block/vite.config.ts new file mode 100644 index 0000000000..99bdfd9d91 --- /dev/null +++ b/packages/math-block/vite.config.ts @@ -0,0 +1,77 @@ +import * as path from "path"; +import { webpackStats } from "rollup-plugin-webpack-stats"; +import { defineConfig, type UserConfig } from "vite-plus"; +import pkg from "./package.json"; + +// https://vitejs.dev/config/ +export default defineConfig( + (conf) => + ({ + run: { + tasks: { + build: { + command: "tsc && vp build", + input: [ + { auto: true }, + { pattern: "!**/*.tsbuildinfo", base: "workspace" }, + ], + }, + }, + }, + test: { + setupFiles: ["./vitestSetup.ts"], + }, + plugins: [webpackStats() as any], + // used so that vitest resolves the core package from the sources instead of the built version + resolve: { + alias: + conf.command === "build" + ? ({} as Record) + : ({ + // load live from sources with live reload working + "@blocknote/core": path.resolve(__dirname, "../core/src/"), + "@blocknote/react": path.resolve(__dirname, "../react/src/"), + } as Record), + }, + build: { + sourcemap: true, + lib: { + entry: { + "blocknote-math-block": path.resolve(__dirname, "src/index.ts"), + }, + name: "blocknote-math-block", + formats: ["es", "cjs"], + fileName: (format, entryName) => + format === "es" ? `${entryName}.js` : `${entryName}.cjs`, + }, + rollupOptions: { + // make sure to externalize deps that shouldn't be bundled + // into your library + external: (source) => { + if ( + Object.keys({ + ...pkg.dependencies, + ...((pkg as any).peerDependencies || {}), + ...pkg.devDependencies, + }).some((dep) => source === dep || source.startsWith(dep + "/")) + ) { + return true; + } + return ( + source.startsWith("react/") || + source.startsWith("react-dom/") || + source.startsWith("prosemirror-") || + source.startsWith("@tiptap/") || + source.startsWith("@blocknote/") || + source.startsWith("node:") + ); + }, + output: { + // Provide global variables to use in the UMD build + // for externalized deps + globals: {}, + }, + }, + }, + }) as UserConfig, +); diff --git a/packages/math-block/vitestSetup.ts b/packages/math-block/vitestSetup.ts new file mode 100644 index 0000000000..dbcf3eb39c --- /dev/null +++ b/packages/math-block/vitestSetup.ts @@ -0,0 +1,10 @@ +import { afterEach, beforeEach } from "vite-plus/test"; + +beforeEach(() => { + globalThis.window = globalThis.window || ({} as any); + (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; +}); + +afterEach(() => { + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; +}); diff --git a/playground/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/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index eb6b499d53..3e69de7ac3 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1445,6 +1445,35 @@ 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", + "Suggestion Menus", + "Slash Menu", + ], + dependencies: { + "@blocknote/code-block": "latest", + "@blocknote/math-block": "latest", + "react-icons": "^5.5.0", + } 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/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 234764f35c..58d1b601e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: '@blocknote/mantine': specifier: workspace:* version: link:../packages/mantine + '@blocknote/math-block': + specifier: workspace:* + version: link:../packages/math-block '@blocknote/react': specifier: workspace:* version: link:../packages/react @@ -3358,6 +3361,58 @@ 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) + react-icons: + specifier: ^5.5.0 + version: 5.6.0(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.3 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)) + vite-plus: + specifier: 'catalog:' + version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)) + examples/06-custom-schema/draggable-inline-content: dependencies: '@blocknote/ariakit': @@ -4564,7 +4619,13 @@ importers: '@shikijs/themes': specifier: ^4 version: 4.0.2 + katex: + specifier: ^0.16.11 + version: 0.16.47 devDependencies: + '@types/katex': + specifier: ^0.16.7 + version: 0.16.8 rimraf: specifier: ^5.0.10 version: 5.0.10 @@ -4587,6 +4648,9 @@ importers: '@emoji-mart/data': specifier: ^1.2.1 version: 1.2.1 + '@floating-ui/dom': + specifier: ^1.7.6 + version: 1.7.6 '@handlewithcare/prosemirror-inputrules': specifier: ^0.1.4 version: 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8) @@ -4758,6 +4822,43 @@ importers: specifier: 'catalog:' version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)) + packages/math-block: + dependencies: + '@blocknote/core': + specifier: workspace:^ + version: link:../core + katex: + specifier: ^0.16.11 + version: 0.16.47 + mathml-to-latex: + specifier: ^1.8.0 + version: 1.8.0 + prosemirror-model: + specifier: ^1.25.4 + version: 1.25.4 + prosemirror-state: + specifier: ^1.4.4 + version: 1.4.4 + devDependencies: + '@blocknote/xl-multi-column': + specifier: workspace:^ + version: link:../xl-multi-column + '@types/katex': + specifier: ^0.16.7 + version: 0.16.8 + rimraf: + specifier: ^5.0.10 + version: 5.0.10 + rollup-plugin-webpack-stats: + specifier: ^0.2.6 + version: 0.2.6(rollup@4.60.1) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite-plus: + specifier: 'catalog:' + version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)) + packages/react: dependencies: '@blocknote/core': @@ -5458,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 @@ -9881,6 +9985,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/lodash.foreach@4.5.9': resolution: {integrity: sha512-vmq0p/FK66PsALXRmK/qsnlLlCpnudvozWYrxJImHujHhXMADdeoPEY10zwmu26437w85wCvdxUqpFi+ALtkiQ==} @@ -10429,6 +10536,10 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -10999,6 +11110,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} @@ -12494,6 +12609,10 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -12706,6 +12825,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mathml-to-latex@1.8.0: + resolution: {integrity: sha512-gQ0uK3zqB8HwlfaXJkEL5rgaZNbKUiBMmBP/B/W+b+t6KcseLSuYb1b0BjLgS9ZiQa24ePkqTX8/6FaQuDL7wQ==} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -19723,6 +19845,8 @@ snapshots: '@types/json5@0.0.29': {} + '@types/katex@0.16.8': {} + '@types/lodash.foreach@4.5.9': dependencies: '@types/lodash': 4.17.24 @@ -20077,7 +20201,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: @@ -20106,7 +20229,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: @@ -20470,6 +20593,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@xmldom/xmldom@0.9.10': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -21006,6 +21131,8 @@ snapshots: commander@4.1.1: {} + commander@8.3.0: {} + commondir@1.0.1: {} compressible@2.0.18: @@ -22705,6 +22832,10 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -22863,6 +22994,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 @@ -26078,7 +26213,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: @@ -26172,7 +26306,6 @@ snapshots: jsdom: 29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0) transitivePeerDependencies: - msw - optional: true w3c-keyname@2.2.8: {} 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 142a5e7771..4b0e8678a1 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -121,14 +121,13 @@ "content": "inline", "propSchema": { "language": { - "default": "text", + "default": "javascript", }, }, "type": "codeBlock", }, "extensions": [ [Function], - [Function], ], "implementation": { "meta": {