diff --git a/docs/content/docs/features/blocks/code-blocks.mdx b/docs/content/docs/features/blocks/code-blocks.mdx index 8f5d1816b3..8dc4ce4610 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,36 @@ 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 that you add to the editor's `extensions` (not configured on the code block itself), so it can highlight any block that declares a language — the code block, and blocks like the math block. When the extension isn't added, those blocks render as plain text. + +The extension is configured with a Shiki highlighter: ```ts -import { createCodeBlockSpec } from "@blocknote/core"; -import { codeBlockOptions } from "@blocknote/code-block"; +type SyntaxHighlightingOptions = { + createHighlighter: () => Promise>; +}; +``` + +`createHighlighter:` The [Shiki highlighter](https://shiki.style/guide/load-theme) to use for syntax highlighting. + +Which blocks get highlighted (and as which language) is decided by each block's spec via its `meta.highlight` callback — the code block highlights as its `language` prop, the math block always as `latex` — so you don't configure this on the extension. -const codeBlock = createCodeBlockSpec(codeBlockOptions); +BlockNote provides a generic, ready-to-use highlighter in the `@blocknote/code-block` package, which supports a wide range of languages. It's exported as a pre-configured `syntaxHighlighter` extension, alongside the code block options: + +```ts +import { createCodeBlockSpec } from "@blocknote/core"; +import { codeBlockOptions, syntaxHighlighter } from "@blocknote/code-block"; + +const editor = useCreateBlockNote({ + extensions: [syntaxHighlighter], + schema: BlockNoteSchema.create().extend({ + blockSpecs: { + codeBlock: createCodeBlockSpec(codeBlockOptions), + }, + }), +}); ``` See [this example](/examples/theming/code-block) to see it in action. @@ -88,10 +108,22 @@ This will generate a `shiki.bundle.ts` file that you can use to create a syntax Like this: ```ts +import { SyntaxHighlightingExtension } from "@blocknote/core"; import { createHighlighter } from "./shiki.bundle.js"; +// Build a syntax highlighter extension from your custom Shiki bundle, then add +// it to the editor's `extensions`. +const syntaxHighlighter = SyntaxHighlightingExtension({ + createHighlighter: () => + createHighlighter({ + themes: ["light-plus", "dark-plus"], + langs: [], + }), +}); + export default function App() { const editor = useCreateBlockNote({ + extensions: [syntaxHighlighter], schema: BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec({ @@ -103,11 +135,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..a41c23d152 100644 --- a/docs/package.json +++ b/docs/package.json @@ -48,7 +48,7 @@ "@polar-sh/sdk": "^0.42.2", "@react-email/components": "^1.0.4", "@react-email/render": "^2.0.4", - "@react-pdf/renderer": "^4.3.0", + "@react-pdf/renderer": "^4.5.1", "@sentry/nextjs": "^10.34.0", "@shikijs/core": "^4", "@shikijs/engine-javascript": "^4", @@ -96,7 +96,13 @@ "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:*", + "mermaid": "^11.0.0", + "@blocknote/diagram-block": "workspace:*", + "docx": "^9.6.1", + "@react-pdf/math": "^2.0.1", + "katex": "^0.16.11" }, "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..b5b3fbbd3a 100644 --- a/examples/04-theming/06-code-block/src/App.tsx +++ b/examples/04-theming/06-code-block/src/App.tsx @@ -3,12 +3,16 @@ import "@blocknote/core/fonts/inter.css"; 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"; +// This packages some of the most used languages in on-demand bundle, and a +// ready-to-use syntax highlighter extension configured with them. +import { codeBlockOptions, syntaxHighlighter } from "@blocknote/code-block"; export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ + // Adding the syntax highlighter extension enables syntax highlighting for + // the code block. Without it, code renders as plain text. + extensions: [syntaxHighlighter], 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..387d3463c8 100644 --- a/examples/04-theming/07-custom-code-block/src/App.tsx +++ b/examples/04-theming/07-custom-code-block/src/App.tsx @@ -1,4 +1,8 @@ -import { BlockNoteSchema, createCodeBlockSpec } from "@blocknote/core"; +import { + BlockNoteSchema, + createCodeBlockSpec, + SyntaxHighlightingExtension, +} from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; @@ -6,9 +10,22 @@ import { useCreateBlockNote } from "@blocknote/react"; // Bundle created from `npx shiki-codegen --langs typescript,javascript,react --themes light-plus,dark-plus --engine javascript --precompiled ./shiki.bundle.ts` import { createHighlighter } from "./shiki.bundle"; +// Syntax highlighting is a separate extension, configured with a highlighter. +// Here we build one from our own custom Shiki bundle (with `dark-plus` / +// `light-plus` themes) and pass it to the editor's `extensions` below. +const syntaxHighlighter = SyntaxHighlightingExtension({ + // This creates a highlighter, it can be asynchronous to load it afterwards + createHighlighter: () => + createHighlighter({ + themes: ["dark-plus", "light-plus"], + langs: [], + }), +}); + export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ + extensions: [syntaxHighlighter], schema: BlockNoteSchema.create().extend({ blockSpecs: { codeBlock: createCodeBlockSpec({ @@ -27,12 +44,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/05-interoperability/05-converting-blocks-to-pdf/.bnexample.json b/examples/05-interoperability/05-converting-blocks-to-pdf/.bnexample.json index 6115e659a5..335103b4d6 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/.bnexample.json +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/.bnexample.json @@ -2,11 +2,16 @@ "playground": true, "docs": true, "author": "yousefed", - "tags": ["Interoperability"], + "tags": [ + "Interoperability" + ], "dependencies": { - "@blocknote/xl-pdf-exporter": "latest", + "@blocknote/diagram-block": "latest", + "@blocknote/math-block": "latest", "@blocknote/xl-multi-column": "latest", - "@react-pdf/renderer": "^4.3.0" + "@blocknote/xl-pdf-exporter": "latest", + "@react-pdf/math": "^2.0.1", + "@react-pdf/renderer": "^4.5.1" }, "pro": true } diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/package.json b/examples/05-interoperability/05-converting-blocks-to-pdf/package.json index 73bb3589ab..d5621639fb 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/package.json +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/package.json @@ -20,9 +20,12 @@ "@mantine/hooks": "^9.0.2", "react": "^19.2.3", "react-dom": "^19.2.3", - "@blocknote/xl-pdf-exporter": "latest", + "@blocknote/diagram-block": "latest", + "@blocknote/math-block": "latest", "@blocknote/xl-multi-column": "latest", - "@react-pdf/renderer": "^4.3.0" + "@blocknote/xl-pdf-exporter": "latest", + "@react-pdf/math": "^2.0.1", + "@react-pdf/renderer": "^4.5.1" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/src/App.tsx b/examples/05-interoperability/05-converting-blocks-to-pdf/src/App.tsx index 4aaad9c805..f7ca042f01 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/src/App.tsx +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/src/App.tsx @@ -8,6 +8,11 @@ import "@blocknote/core/fonts/inter.css"; import * as locales from "@blocknote/core/locales"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; +import { createReactDiagramBlockSpec } from "@blocknote/diagram-block"; +import { + createReactInlineMathSpec, + createReactMathBlockSpec, +} from "@blocknote/math-block"; import { SuggestionMenuController, getDefaultReactSlashMenuItems, @@ -24,6 +29,8 @@ import { PDFExporter, pdfDefaultSchemaMappings, } from "@blocknote/xl-pdf-exporter"; +import { diagramBlockMapping } from "@blocknote/xl-pdf-exporter/diagram-block"; +import { mathBlockMapping } from "@blocknote/xl-pdf-exporter/math-block"; import { pdf, PDFViewer } from "@react-pdf/renderer"; import { JSX, useEffect, useMemo, useReducer, useState } from "react"; @@ -38,7 +45,16 @@ export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ // Adds support for page breaks & multi-column blocks. - schema: withMultiColumn(withPageBreak(BlockNoteSchema.create())), + // Adds support for math & diagram blocks. + schema: withMultiColumn(withPageBreak(BlockNoteSchema.create())).extend({ + blockSpecs: { + math: createReactMathBlockSpec(), + diagram: createReactDiagramBlockSpec(), + }, + inlineContentSpecs: { + inlineMath: createReactInlineMathSpec(), + }, + }), dropCursor: multiColumnDropCursor, dictionary: { ...locales.en, @@ -330,6 +346,31 @@ export default function App() { console.log("Hello World", message); };`, }, + { + type: "math", + content: "a^2 = \\sqrt{b^2 + c^2}", + }, + { + type: "diagram", + content: `graph TD + A[Start] --> B{Works?} + B -->|Yes| C[Ship it] + B -->|No| A`, + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Inline math: ", + styles: {}, + }, + { + type: "inlineMath", + content: "e^{i\\pi} + 1 = 0", + }, + ], + }, { type: "columnList", children: [ @@ -402,7 +443,16 @@ export default function App() { // Exports the editor document to PDF whenever it changes. const onChange = async () => { - const exporter = new PDFExporter(editor.schema, pdfDefaultSchemaMappings); + const exporter = new PDFExporter(editor.schema, { + ...pdfDefaultSchemaMappings, + blockMapping: { + ...pdfDefaultSchemaMappings.blockMapping, + // Embeds diagrams as images instead of their Mermaid source. + diagram: diagramBlockMapping, + // Renders math blocks as formulas instead of their LaTeX source. + math: mathBlockMapping, + }, + }); const pdfDocument = await exporter.toReactPDFDocument(editor.document); setPDFDocument(pdfDocument); forceRerender(); diff --git a/examples/05-interoperability/06-converting-blocks-to-docx/.bnexample.json b/examples/05-interoperability/06-converting-blocks-to-docx/.bnexample.json index 17ec620bc9..b00619a661 100644 --- a/examples/05-interoperability/06-converting-blocks-to-docx/.bnexample.json +++ b/examples/05-interoperability/06-converting-blocks-to-docx/.bnexample.json @@ -2,10 +2,15 @@ "playground": true, "docs": true, "author": "yousefed", - "tags": [""], + "tags": [ + "" + ], "dependencies": { + "@blocknote/diagram-block": "latest", + "@blocknote/math-block": "latest", "@blocknote/xl-docx-exporter": "latest", - "@blocknote/xl-multi-column": "latest" + "@blocknote/xl-multi-column": "latest", + "katex": "^0.16.11" }, "pro": true } diff --git a/examples/05-interoperability/06-converting-blocks-to-docx/package.json b/examples/05-interoperability/06-converting-blocks-to-docx/package.json index 4d8be18e97..6c96ae0720 100644 --- a/examples/05-interoperability/06-converting-blocks-to-docx/package.json +++ b/examples/05-interoperability/06-converting-blocks-to-docx/package.json @@ -20,8 +20,11 @@ "@mantine/hooks": "^9.0.2", "react": "^19.2.3", "react-dom": "^19.2.3", + "@blocknote/diagram-block": "latest", + "@blocknote/math-block": "latest", "@blocknote/xl-docx-exporter": "latest", - "@blocknote/xl-multi-column": "latest" + "@blocknote/xl-multi-column": "latest", + "katex": "^0.16.11" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/05-interoperability/06-converting-blocks-to-docx/src/App.tsx b/examples/05-interoperability/06-converting-blocks-to-docx/src/App.tsx index 4b873d9a40..b7a9b522cb 100644 --- a/examples/05-interoperability/06-converting-blocks-to-docx/src/App.tsx +++ b/examples/05-interoperability/06-converting-blocks-to-docx/src/App.tsx @@ -8,6 +8,11 @@ import "@blocknote/core/fonts/inter.css"; import * as locales from "@blocknote/core/locales"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; +import { + createReactInlineMathSpec, + createReactMathBlockSpec, +} from "@blocknote/math-block"; +import { createReactDiagramBlockSpec } from "@blocknote/diagram-block"; import { SuggestionMenuController, getDefaultReactSlashMenuItems, @@ -18,6 +23,11 @@ import { DOCXExporter, docxDefaultSchemaMappings, } from "@blocknote/xl-docx-exporter"; +import { diagramBlockMapping } from "@blocknote/xl-docx-exporter/diagram-block"; +import { + inlineMathMapping, + mathBlockMapping, +} from "@blocknote/xl-docx-exporter/math-block"; import { getMultiColumnSlashMenuItems, multiColumnDropCursor, @@ -32,7 +42,16 @@ export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ // Adds support for page breaks & multi-column blocks. - schema: withMultiColumn(withPageBreak(BlockNoteSchema.create())), + // Adds support for math & diagram blocks. + schema: withMultiColumn(withPageBreak(BlockNoteSchema.create())).extend({ + blockSpecs: { + math: createReactMathBlockSpec(), + diagram: createReactDiagramBlockSpec(), + }, + inlineContentSpecs: { + inlineMath: createReactInlineMathSpec(), + }, + }), dropCursor: multiColumnDropCursor, dictionary: { ...locales.en, @@ -324,6 +343,31 @@ export default function App() { console.log("Hello World", message); };`, }, + { + type: "math", + content: "a^2 = \\sqrt{b^2 + c^2}", + }, + { + type: "diagram", + content: `graph TD + A[Start] --> B{Works?} + B -->|Yes| C[Ship it] + B -->|No| A`, + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Inline math: ", + styles: {}, + }, + { + type: "inlineMath", + content: "e^{i\\pi} + 1 = 0", + }, + ], + }, { type: "columnList", @@ -396,7 +440,23 @@ export default function App() { // Exports the editor content to DOCX and downloads it. const onDownloadClick = async () => { - const exporter = new DOCXExporter(editor.schema, docxDefaultSchemaMappings); + const exporter = new DOCXExporter(editor.schema, { + ...docxDefaultSchemaMappings, + blockMapping: { + ...docxDefaultSchemaMappings.blockMapping, + // Embeds diagrams as images instead of their Mermaid source. + diagram: diagramBlockMapping, + // Renders math blocks as native equations instead of their LaTeX + // source. + math: mathBlockMapping, + }, + inlineContentMapping: { + ...docxDefaultSchemaMappings.inlineContentMapping, + // Renders inline math as native equations instead of its LaTeX + // source. + inlineMath: inlineMathMapping, + }, + }); const blob = await exporter.toBlob(editor.document); const link = document.createElement("a"); diff --git a/examples/05-interoperability/07-converting-blocks-to-odt/.bnexample.json b/examples/05-interoperability/07-converting-blocks-to-odt/.bnexample.json index 7e3174aeea..8a2998aae2 100644 --- a/examples/05-interoperability/07-converting-blocks-to-odt/.bnexample.json +++ b/examples/05-interoperability/07-converting-blocks-to-odt/.bnexample.json @@ -2,10 +2,15 @@ "playground": true, "docs": true, "author": "areknawo", - "tags": [""], + "tags": [ + "" + ], "dependencies": { + "@blocknote/diagram-block": "latest", + "@blocknote/math-block": "latest", + "@blocknote/xl-multi-column": "latest", "@blocknote/xl-odt-exporter": "latest", - "@blocknote/xl-multi-column": "latest" + "katex": "^0.16.11" }, "pro": true } diff --git a/examples/05-interoperability/07-converting-blocks-to-odt/package.json b/examples/05-interoperability/07-converting-blocks-to-odt/package.json index 3877e20d75..db237c1696 100644 --- a/examples/05-interoperability/07-converting-blocks-to-odt/package.json +++ b/examples/05-interoperability/07-converting-blocks-to-odt/package.json @@ -20,8 +20,11 @@ "@mantine/hooks": "^9.0.2", "react": "^19.2.3", "react-dom": "^19.2.3", + "@blocknote/diagram-block": "latest", + "@blocknote/math-block": "latest", + "@blocknote/xl-multi-column": "latest", "@blocknote/xl-odt-exporter": "latest", - "@blocknote/xl-multi-column": "latest" + "katex": "^0.16.11" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/05-interoperability/07-converting-blocks-to-odt/src/App.tsx b/examples/05-interoperability/07-converting-blocks-to-odt/src/App.tsx index 7b884ac658..592b3f3ddd 100644 --- a/examples/05-interoperability/07-converting-blocks-to-odt/src/App.tsx +++ b/examples/05-interoperability/07-converting-blocks-to-odt/src/App.tsx @@ -8,6 +8,11 @@ import * as locales from "@blocknote/core/locales"; import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; +import { + createReactInlineMathSpec, + createReactMathBlockSpec, +} from "@blocknote/math-block"; +import { createReactDiagramBlockSpec } from "@blocknote/diagram-block"; import { SuggestionMenuController, getDefaultReactSlashMenuItems, @@ -18,6 +23,11 @@ import { ODTExporter, odtDefaultSchemaMappings, } from "@blocknote/xl-odt-exporter"; +import { diagramBlockMapping } from "@blocknote/xl-odt-exporter/diagram-block"; +import { + inlineMathMapping, + mathBlockMapping, +} from "@blocknote/xl-odt-exporter/math-block"; import { getMultiColumnSlashMenuItems, multiColumnDropCursor, @@ -32,7 +42,16 @@ export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ // Adds support for page breaks & multi-column blocks. - schema: withMultiColumn(withPageBreak(BlockNoteSchema.create())), + // Adds support for math & diagram blocks. + schema: withMultiColumn(withPageBreak(BlockNoteSchema.create())).extend({ + blockSpecs: { + math: createReactMathBlockSpec(), + diagram: createReactDiagramBlockSpec(), + }, + inlineContentSpecs: { + inlineMath: createReactInlineMathSpec(), + }, + }), dropCursor: multiColumnDropCursor, dictionary: { ...locales.en, @@ -324,6 +343,31 @@ export default function App() { console.log("Hello World", message); };`, }, + { + type: "math", + content: "a^2 = \\sqrt{b^2 + c^2}", + }, + { + type: "diagram", + content: `graph TD + A[Start] --> B{Works?} + B -->|Yes| C[Ship it] + B -->|No| A`, + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Inline math: ", + styles: {}, + }, + { + type: "inlineMath", + content: "e^{i\\pi} + 1 = 0", + }, + ], + }, { type: "columnList", children: [ @@ -395,7 +439,23 @@ export default function App() { // Exports the editor content to ODT and downloads it. const onDownloadClick = async () => { - const exporter = new ODTExporter(editor.schema, odtDefaultSchemaMappings); + const exporter = new ODTExporter(editor.schema, { + ...odtDefaultSchemaMappings, + blockMapping: { + ...odtDefaultSchemaMappings.blockMapping, + // Embeds diagrams as images instead of their Mermaid source. + diagram: diagramBlockMapping, + // Renders math blocks as native equations instead of their LaTeX + // source. + math: mathBlockMapping, + }, + inlineContentMapping: { + ...odtDefaultSchemaMappings.inlineContentMapping, + // Renders inline math as native equations instead of its LaTeX + // source. + inlineMath: inlineMathMapping, + }, + }); const blob = await exporter.toODTDocument(editor.document); const link = document.createElement("a"); 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..34ac218294 --- /dev/null +++ b/examples/06-custom-schema/09-math-block/src/App.tsx @@ -0,0 +1,143 @@ +import "@blocknote/core/fonts/inter.css"; +import { + BlockNoteSchema, + combineByGroup, + SourceBlockWithPreviewExtension, +} from "@blocknote/core"; +import { + filterSuggestionItems, + insertOrUpdateBlockForSlashMenu, +} from "@blocknote/core/extensions"; +import { TextSelection } from "prosemirror-state"; +import { syntaxHighlighter } from "@blocknote/code-block"; +import { + createReactInlineMathSpec, + createReactMathBlockSpec, +} 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: createReactMathBlockSpec(), + }, + inlineContentSpecs: { + // Creates an instance of the inline Math content and adds it to the schema. + inlineMath: createReactInlineMathSpec(), + }, +}); + +// Slash menu item to insert a Math block. +const insertMath = (editor: typeof schema.BlockNoteEditor) => ({ + title: "Math Block", + subtext: "Insert a LaTeX math formula", + onItemClick: () => { + const block = insertOrUpdateBlockForSlashMenu(editor, { + type: "math", + }); + editor + .getExtension(SourceBlockWithPreviewExtension) + ?.store.setState((state) => ({ ...state, popupOpen: block.id })); + requestAnimationFrame(() => { + editor.setTextCursorPosition(block.id, "end"); + editor.focus(); + }); + }, + aliases: ["math", "latex", "formula", "equation"], + group: "Advanced", + icon: , +}); + +// Slash menu item to insert an inline Math equation. +const insertInlineMath = (editor: typeof schema.BlockNoteEditor) => ({ + title: "Inline Equation", + subtext: "Insert an inline LaTeX equation", + onItemClick: () => { + const view = editor.prosemirrorView!; + const insertPos = view.state.selection.from; + + editor.insertInlineContent([ + { type: "inlineMath", content: "" }, + // Adds a trailing space so the cursor can leave the equation. + " ", + ]); + requestAnimationFrame(() => { + const view = editor.prosemirrorView!; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.doc, insertPos + 1), + ), + ); + editor.focus(); + }); + }, + aliases: ["math", "latex", "formula", "equation"], + group: "Advanced", + icon: , +}); + +export default function App() { + const editor = useCreateBlockNote({ + // The syntax highlighter extension highlights the LaTeX source of math + // blocks (they declare `highlight: () => "latex"`). Without it, they render + // as plain text. + extensions: [syntaxHighlighter], + 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: [ + "Equations can also be inline, like ", + { type: "inlineMath", content: "e^{i\\pi} + 1 = 0" }, + ". Click one to edit its LaTeX source.", + ], + }, + { + type: "paragraph", + content: "Press the '/' key to open the Slash Menu and add another", + }, + ], + }); + + // Renders the editor instance using a React component. + return ( + + {/* Replaces the default Slash Menu. */} + { + // Gets the default slash menu items and adds the Math items at the + // end of their group ("Advanced"). + const items = combineByGroup(getDefaultReactSlashMenuItems(editor), [ + insertMath(editor), + insertInlineMath(editor), + ]); + + // Returns filtered items based on the query. + return filterSuggestionItems(items, 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/examples/06-custom-schema/10-diagram-block/.bnexample.json b/examples/06-custom-schema/10-diagram-block/.bnexample.json new file mode 100644 index 0000000000..915c0c3db7 --- /dev/null +++ b/examples/06-custom-schema/10-diagram-block/.bnexample.json @@ -0,0 +1,17 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu" + ], + "dependencies": { + "@blocknote/code-block": "latest", + "@blocknote/diagram-block": "latest", + "react-icons": "^5.5.0" + } +} diff --git a/examples/06-custom-schema/10-diagram-block/README.md b/examples/06-custom-schema/10-diagram-block/README.md new file mode 100644 index 0000000000..5e8ad1523b --- /dev/null +++ b/examples/06-custom-schema/10-diagram-block/README.md @@ -0,0 +1,10 @@ +# Diagram Block + +In this example, we register the `@blocknote/diagram-block` block in a custom schema. The block renders diagrams from [Mermaid](https://mermaid.js.org/) source code, showing the rendered diagram in place of the source and revealing an editable source popup when selected - built from the same `SourceBlockWithPreview` component the math block uses, so the block itself is only a few dozen lines. + +**Try it out:** Click a diagram to edit its Mermaid source! + +**Relevant Docs:** + +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/10-diagram-block/index.html b/examples/06-custom-schema/10-diagram-block/index.html new file mode 100644 index 0000000000..2a2ca2d29b --- /dev/null +++ b/examples/06-custom-schema/10-diagram-block/index.html @@ -0,0 +1,14 @@ + + + + + Diagram Block + + + +
+ + + diff --git a/examples/06-custom-schema/10-diagram-block/main.tsx b/examples/06-custom-schema/10-diagram-block/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/10-diagram-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/10-diagram-block/package.json b/examples/06-custom-schema/10-diagram-block/package.json new file mode 100644 index 0000000000..6d39ea4b51 --- /dev/null +++ b/examples/06-custom-schema/10-diagram-block/package.json @@ -0,0 +1,33 @@ +{ + "name": "@blocknote/example-custom-schema-diagram-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/diagram-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/10-diagram-block/src/App.tsx b/examples/06-custom-schema/10-diagram-block/src/App.tsx new file mode 100644 index 0000000000..87a8b01d27 --- /dev/null +++ b/examples/06-custom-schema/10-diagram-block/src/App.tsx @@ -0,0 +1,91 @@ +import { syntaxHighlighter } from "@blocknote/code-block"; +import { BlockNoteSchema } from "@blocknote/core"; +import { + filterSuggestionItems, + insertOrUpdateBlockForSlashMenu, +} from "@blocknote/core/extensions"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { createReactDiagramBlockSpec } from "@blocknote/diagram-block"; +import { + getDefaultReactSlashMenuItems, + SuggestionMenuController, + useCreateBlockNote, +} from "@blocknote/react"; +import { TbSitemap } 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 Diagram block and adds it to the schema. + // TODO: naing + diagram: createReactDiagramBlockSpec(), + }, +}); + +// Slash menu item to insert a Diagram block. +// TODO: extract? +const insertDiagram = (editor: typeof schema.BlockNoteEditor) => ({ + title: "Diagram", + subtext: "Insert a diagram rendered from Mermaid source", + onItemClick: () => + insertOrUpdateBlockForSlashMenu(editor, { + type: "diagram", + content: "graph TD\n A[Start] --> B[Stop]", + }), + aliases: ["mermaid", "diagram", "flowchart", "chart", "graph"], + group: "Advanced", + icon: , +}); + +export default function App() { + const editor = useCreateBlockNote({ + // The syntax highlighter extension highlights the Diagram block's Mermaid + // source in its popup (the block declares `highlight: () => "mermaid"`). + extensions: [syntaxHighlighter], + schema, + initialContent: [ + { + type: "paragraph", + content: "Click a diagram to edit its Mermaid source:", + }, + { + type: "diagram", + content: `graph TD + A[Write docs] --> B{Diagram needed?} + B -->|Yes| C[Type /diagram] + B -->|No| D[Keep writing] + C --> D`, + }, + { + 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 "Advanced" group. + const lastAdvancedIndex = defaultItems.findLastIndex( + (item) => item.group === "Advanced", + ); + // Inserts the Diagram item at the end of the "Advanced" group. + defaultItems.splice(lastAdvancedIndex + 1, 0, insertDiagram(editor)); + + // Returns filtered items based on the query. + return filterSuggestionItems(defaultItems, query); + }} + /> + + ); +} diff --git a/examples/06-custom-schema/10-diagram-block/tsconfig.json b/examples/06-custom-schema/10-diagram-block/tsconfig.json new file mode 100644 index 0000000000..93fa81bee8 --- /dev/null +++ b/examples/06-custom-schema/10-diagram-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/10-diagram-block/vite-env.d.ts b/examples/06-custom-schema/10-diagram-block/vite-env.d.ts new file mode 100644 index 0000000000..bc2d8a36f3 --- /dev/null +++ b/examples/06-custom-schema/10-diagram-block/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/10-diagram-block/vite.config.ts b/examples/06-custom-schema/10-diagram-block/vite.config.ts new file mode 100644 index 0000000000..0133a6da9e --- /dev/null +++ b/examples/06-custom-schema/10-diagram-block/vite.config.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/packages/code-block/src/index.test.ts b/packages/code-block/src/index.test.ts index f8a87bbdf4..8a83d858b5 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, syntaxHighlighter } from "./index.js"; describe("codeBlock", () => { it("should exist", () => { @@ -11,7 +11,10 @@ describe("codeBlock", () => { it("should have supportedLanguages", () => { expect(codeBlockOptions.supportedLanguages).toBeDefined(); }); - it("should have createHighlighter", () => { - expect(codeBlockOptions.createHighlighter).toBeDefined(); + it("should not configure a highlighter (that's now the syntaxHighlighter extension)", () => { + expect("createHighlighter" in codeBlockOptions).toBe(false); + }); + it("should export a pre-configured syntaxHighlighter extension", () => { + expect(syntaxHighlighter).toBeDefined(); }); }); diff --git a/packages/code-block/src/index.ts b/packages/code-block/src/index.ts index 2cb588092d..7cc905f662 100644 --- a/packages/code-block/src/index.ts +++ b/packages/code-block/src/index.ts @@ -1,6 +1,27 @@ import type { CodeBlockOptions } from "@blocknote/core"; +import { SyntaxHighlightingExtension } from "@blocknote/core"; import { createHighlighter } from "./shiki.bundle.js"; +/** + * A ready-to-use syntax highlighting extension, pre-configured with this + * package's bundled Shiki highlighter (the languages in `codeBlockOptions` and + * the `github-dark` / `github-light` themes). Add it to the editor's + * `extensions` to enable syntax highlighting for code blocks (and any other + * block that declares a language, such as the math block): + * + * @example + * ```ts + * useCreateBlockNote({ extensions: [syntaxHighlighter] }); + * ``` + */ +export const syntaxHighlighter = SyntaxHighlightingExtension({ + createHighlighter: () => + createHighlighter({ + themes: ["github-dark", "github-light"], + langs: [], + }), +}); + export const codeBlockOptions = { defaultLanguage: "javascript", supportedLanguages: { @@ -197,9 +218,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..c22d8c774b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -105,7 +105,7 @@ "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "lib0": "^0.2.99", - "prosemirror-highlight": "^0.15.1", + "prosemirror-highlight": "^0.15.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.3", diff --git a/packages/core/src/blocks/Code/CodeBlockOptions.ts b/packages/core/src/blocks/Code/CodeBlockOptions.ts new file mode 100644 index 0000000000..5f9435b0c4 --- /dev/null +++ b/packages/core/src/blocks/Code/CodeBlockOptions.ts @@ -0,0 +1,83 @@ +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..4eebf5d6e7 100644 --- a/packages/core/src/blocks/Code/block.ts +++ b/packages/core/src/blocks/Code/block.ts @@ -1,57 +1,14 @@ -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"; +import { + parsePreCode, + parsePreCodeContent, +} from "./helpers/parse/parsePreCode.js"; +import { createPreCode } from "./helpers/toExternalHTML/createPreCode.js"; +import { CodeKeyboardShortcutsExtension } from "./helpers/extensions/CodeKeyboardShortcutsExtension.js"; +import { CodeBlockOptions } from "./CodeBlockOptions.js"; +import { createCodeBlock } from "./helpers/render/createCodeBlock.js"; -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>; -}; - -export type CodeBlockConfig = ReturnType; +const CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY = "code-block-keyboard-shortcuts"; export const createCodeBlockConfig = createBlockConfig( ({ defaultLanguage = "text" }: CodeBlockOptions) => @@ -66,6 +23,8 @@ export const createCodeBlockConfig = createBlockConfig( }) as const, ); +export type CodeBlockConfig = ReturnType; + export const createCodeBlockSpec = createBlockSpec( createCodeBlockConfig, (options) => ({ @@ -73,236 +32,25 @@ export const createCodeBlockSpec = createBlockSpec( code: true, defining: true, isolating: false, + highlight: (block) => block.props.language, }, - 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) => + createCodeBlock( + 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: [], - }; - }, - }, - ], - }), - ]; - }, + (options) => [ + CodeKeyboardShortcutsExtension(options)( + CODE_BLOCK_KEYBOARD_SHORTCUTS_KEY, + "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/CodeKeyboardShortcutsExtension.ts b/packages/core/src/blocks/Code/helpers/extensions/CodeKeyboardShortcutsExtension.ts new file mode 100644 index 0000000000..f6b69f6c69 --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/extensions/CodeKeyboardShortcutsExtension.ts @@ -0,0 +1,120 @@ +import { createExtension } from "../../../../editor/BlockNoteExtension.js"; +import { CodeBlockOptions, getLanguageId } from "../../CodeBlockOptions.js"; + +export const CodeKeyboardShortcutsExtension = + (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/createCodeBlock.ts b/packages/core/src/blocks/Code/helpers/render/createCodeBlock.ts new file mode 100644 index 0000000000..ed1e73edf3 --- /dev/null +++ b/packages/core/src/blocks/Code/helpers/render/createCodeBlock.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 createCodeBlock = ( + 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/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..d40bba055c 100644 --- a/packages/core/src/blocks/index.ts +++ b/packages/core/src/blocks/index.ts @@ -16,6 +16,9 @@ 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/parse/parsePreCode.js"; +export * from "./Code/helpers/render/createCodeBlock.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..0800711617 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -22,9 +22,27 @@ BASIC STYLES .bn-block-content.ProseMirror-selectednode > *, /* Case for node view renderers */ -.ProseMirror-selectednode > .bn-block-content > * { +.ProseMirror-selectednode > .bn-block-content > *, +/* Case for blocks/inline content where class is set manually */ +.bn-block-content .ProseMirror-selectednode, +.bn-inline-content .ProseMirror-selectednode { + /* Anchor for the `::after` highlight overlay below. */ + position: relative; +} + +/* Draws the selection highlight (border + translucent fill) as an overlay on + top of the element's content */ +.bn-block-content.ProseMirror-selectednode > *::after, +.ProseMirror-selectednode > .bn-block-content > *::after, +.bn-block-content .ProseMirror-selectednode::after, +.bn-inline-content .ProseMirror-selectednode::after { + content: ""; + position: absolute; + inset: 0; border-radius: 4px; - outline: 4px solid rgb(100, 160, 255); + background-color: rgba(100, 160, 255, 0.08); + box-shadow: inset 0 0 0 4px rgba(100, 160, 255, 0.3); + pointer-events: none; } .bn-block-content::before { @@ -453,6 +471,227 @@ 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; +} + +/* Default to dark theme as the code block has a dark background regardless of theme. */ +.shiki { + color: var(--shiki-dark); +} +.bn-source-block-popup .shiki { + color: var(--shiki-light); +} +.bn-root[data-color-scheme="dark"] .bn-source-block-popup .shiki { + color: var(--shiki-dark); +} + +.bn-preview-with-source-popup { + position: relative; +} + +.bn-block-content .bn-preview-with-source-popup { + display: flex; + padding: 12px; + width: 100%; +} + +.bn-inline-content-section .bn-preview-with-source-popup { + display: inline-block; + padding: 0; + width: fit-content; +} + +.bn-preview-container { + anchor-name: --bn-source-popup-anchor; + cursor: pointer; +} + +.bn-block-content .bn-preview-container { + overflow: auto; + width: 100%; +} + +.bn-inline-content-section .bn-preview-container { + overflow: visible; + width: fit-content; +} + +.bn-source-block-popup { + background-color: transparent; + border: none; + border-radius: 0; + box-shadow: none; + color: transparent; + height: 0; + left: 0; + margin-top: 4px; + overflow: clip; + position: absolute; + top: 100%; + width: 0; + z-index: 0; +} + +.bn-preview-with-source-popup[data-open="true"] .bn-source-block-popup { + background-color: var(--bn-colors-menu-background); + border: var(--bn-border); + border-radius: var(--bn-border-radius-medium); + box-shadow: var(--bn-shadow-medium); + color: var(--bn-colors-menu-text); + height: unset; + z-index: 1; +} + +.bn-block-content + .bn-preview-with-source-popup[data-open="true"] + .bn-source-block-popup { + width: 100%; +} + +.bn-inline-content-section + .bn-preview-with-source-popup[data-open="true"] + .bn-source-block-popup { + width: 300px; +} + +.bn-code-block-source-popup-body { + align-items: flex-end; + display: flex; +} + +.bn-code-block-source-popup-body > pre { + align-self: center; + flex: 1; + margin: 0; + max-height: 200px; + overflow: auto; + padding: 16px; + tab-size: 2; + white-space: pre; + width: 0; +} + +.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; +} + +.bn-code-block-source-popup-ok-button-wrapper { + align-items: flex-end; + justify-content: flex-end; + padding: 14px 16px; +} + +.bn-code-block-source-popup-ok-button { + appearance: none; + background-color: rgb(37, 99, 235); + border: none; + border-radius: var(--bn-border-radius-small); + color: white; + cursor: pointer; + font-size: 0.8em; + font-weight: 500; + padding: 4px 8px; +} + +.bn-code-block-source-popup-ok-button:hover { + background-color: rgb(29, 78, 216); +} + +.bn-preview-with-source-popup:has(.bn-preview-placeholder) { + padding: 0; +} + +.bn-preview-placeholder { + align-items: center; + background-color: rgb(242, 241, 238); + border-radius: 4px; + color: rgb(125, 121, 122); + display: flex; +} + +.bn-block-content .bn-preview-placeholder { + gap: 10px; + padding: 12px; +} + +.bn-inline-content-section .bn-preview-placeholder { + gap: 4px; + padding: 0 4px; +} + +.bn-preview-placeholder:where(.dark, .dark *) { + background-color: rgb(70, 70, 70); + color: rgb(190, 190, 190); +} + +.bn-editor[contenteditable="true"] + .bn-preview-placeholder:not(.bn-preview-placeholder-error):hover { + background-color: rgb(225, 225, 225); +} + +.bn-editor[contenteditable="true"] + .bn-preview-placeholder:not(.bn-preview-placeholder-error):hover:where( + .dark, + .dark * + ) { + background-color: rgb(90, 90, 90); +} + +.bn-preview-placeholder-error { + background-color: var(--bn-colors-highlights-red-background); + color: var(--bn-colors-highlights-red-text); +} + +.bn-preview-placeholder-icon { + align-items: center; + display: flex; +} + +.bn-block-content .bn-preview-placeholder-icon { + height: 24px; + width: 24px; +} + +.bn-inline-content-section .bn-preview-placeholder-icon { + height: 14px; + width: 14px; +} + +.bn-preview-placeholder-icon > svg { + height: 100%; + width: 100%; +} + +.bn-preview-placeholder-text { + margin: 0; +} + +.bn-block-content .bn-preview-placeholder-text { + font-size: 0.9rem; +} + +.bn-inline-content-section .bn-preview-placeholder-text { + font-size: 0.7rem; +} + +/* Toggled for a single frame while an up/down arrow press is handled, so the + browser's geometry-based vertical caret movement skips the popup. */ +.bn-suppress-source-popup-caret + .bn-inline-content-section + .bn-preview-with-source-popup:not([data-open="true"]) + .bn-source-block-popup { + visibility: hidden; +} + /* PAGE BREAK */ .bn-block-content[data-content-type="pageBreak"] > div { width: 100%; diff --git a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts new file mode 100644 index 0000000000..7301e37520 --- /dev/null +++ b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.test.ts @@ -0,0 +1,194 @@ +/** + * @vitest-environment jsdom + */ +import { Plugin, PluginKey } from "prosemirror-state"; +import { describe, expect, it } from "vite-plus/test"; + +import { createExtension } from "../../BlockNoteExtension.js"; +import { BlockNoteEditor } from "../../BlockNoteEditor.js"; + +function createMountedEditor( + extensions: BlockNoteEditor["options"]["extensions"], +) { + const editor = BlockNoteEditor.create({ extensions }); + editor.mount(document.createElement("div")); + return editor; +} + +/** + * Returns the index of the plugin identified by `key` within the editor's + * ProseMirror plugin list. A lower index means it runs/applies earlier. + */ +function pluginIndex( + editor: BlockNoteEditor, + key: PluginKey, +): number { + return editor.prosemirrorState.plugins.findIndex( + (plugin) => (plugin as any).spec?.key === key, + ); +} + +describe("ExtensionManager unique keys", () => { + it("throws when two extensions are registered directly with the same key", () => { + const first = createExtension(() => ({ + key: "dup", + value: "first", + })); + const second = createExtension(() => ({ + key: "dup", + value: "second", + })); + + // Two registrations of the same key are a configuration error and must fail + // loudly rather than silently dropping the second. + expect(() => createMountedEditor([first(), second()])).toThrow( + /already registered/, + ); + }); + + it("throws when a blockNoteExtensions dependency reuses an already-registered key", () => { + // Two distinct factories sharing the key "dep". + const depDirect = createExtension(() => ({ + key: "dep", + value: "direct", + })); + const depFromParent = createExtension(() => ({ + key: "dep", + value: "from-parent", + })); + const parent = createExtension(() => ({ + key: "parent", + blockNoteExtensions: [depFromParent()], + })); + + // Registering the dependency directly and also declaring a (distinct) + // extension with the same key via blockNoteExtensions is a duplicate - keys + // must be unique regardless of how the extension is registered. + expect(() => createMountedEditor([depDirect(), parent()])).toThrow( + /already registered/, + ); + }); + + it("throws when a shared sub-dependency is declared by two parents", () => { + // A single sub-extension factory declared by two different parents produces + // two distinct instances with the same key - a duplicate. Shared extensions + // must be registered once, not depended upon by multiple parents. + const sharedSub = createExtension(() => ({ key: "shared-sub" })); + const parentA = createExtension(() => ({ + key: "parent-a", + blockNoteExtensions: [sharedSub()], + })); + const parentB = createExtension(() => ({ + key: "parent-b", + blockNoteExtensions: [sharedSub()], + })); + + expect(() => createMountedEditor([parentA(), parentB()])).toThrow( + /already registered/, + ); + }); + + it("registers a dependency declared via blockNoteExtensions when it isn't registered otherwise", () => { + const dep = createExtension(() => ({ + key: "lonely-dep", + value: "dep", + })); + const parent = createExtension(() => ({ + key: "lonely-parent", + blockNoteExtensions: [dep()], + })); + + const editor = createMountedEditor([parent()]); + + expect(editor.getExtension(parent)).toBeDefined(); + expect(editor.getExtension(dep)?.value).toBe("dep"); + }); +}); + +describe("ExtensionManager ordering", () => { + it("orders an extension before another it declares in runsBefore", () => { + const firstKey = new PluginKey("rb-first"); + const secondKey = new PluginKey("rb-second"); + + const first = createExtension(() => ({ + key: "rb-first", + runsBefore: ["rb-second"], + prosemirrorPlugins: [new Plugin({ key: firstKey })], + })); + const second = createExtension(() => ({ + key: "rb-second", + prosemirrorPlugins: [new Plugin({ key: secondKey })], + })); + + // Register in the "wrong" order to prove runsBefore — not array order — + // determines precedence. + const editor = createMountedEditor([second(), first()]); + + expect(pluginIndex(editor, firstKey)).toBeLessThan( + pluginIndex(editor, secondKey), + ); + }); + + it("flattens sub-extensions and runs the parent after its blockNoteExtensions dependency", () => { + const subKey = new PluginKey("sub-order"); + const parentKey = new PluginKey("parent-order"); + + const sub = createExtension(() => ({ + key: "ordered-sub", + prosemirrorPlugins: [new Plugin({ key: subKey })], + })); + const parent = createExtension(() => ({ + key: "ordered-parent", + blockNoteExtensions: [sub()], + prosemirrorPlugins: [new Plugin({ key: parentKey })], + })); + + const editor = createMountedEditor([parent()]); + + // The sub-extension is flattened into the editor's extensions... + expect(editor.getExtension(sub)).toBeDefined(); + expect(editor.getExtension(parent)).toBeDefined(); + + // ...and because the parent declares the sub as a dependency, the sub runs + // before the parent (even though the parent is registered first). + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentKey), + ); + }); + + it("forces a blockNoteExtensions dependency before a parent that has a higher base priority", () => { + // The parent declares `runsBefore` on an unrelated extension, which raises + // its priority above the default. Without an explicit dependency edge, the + // higher-priority parent would run before its sub. The dependency must + // override that so the sub still runs first. + const subKey = new PluginKey("forced-sub"); + const parentKey = new PluginKey("forced-parent"); + const otherKey = new PluginKey("forced-other"); + + const other = createExtension(() => ({ + key: "forced-other", + prosemirrorPlugins: [new Plugin({ key: otherKey })], + })); + const sub = createExtension(() => ({ + key: "forced-sub", + prosemirrorPlugins: [new Plugin({ key: subKey })], + })); + const parent = createExtension(() => ({ + key: "forced-parent", + runsBefore: ["forced-other"], + blockNoteExtensions: [sub()], + prosemirrorPlugins: [new Plugin({ key: parentKey })], + })); + + const editor = createMountedEditor([parent(), other()]); + + // The parent runs before the unrelated extension (its declared runsBefore)... + expect(pluginIndex(editor, parentKey)).toBeLessThan( + pluginIndex(editor, otherKey), + ); + // ...but its dependency still runs before it. + expect(pluginIndex(editor, subKey)).toBeLessThan( + pluginIndex(editor, parentKey), + ); + }); +}); diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 2bd6f0b34b..7f580c8d7b 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -15,6 +15,7 @@ import { FilePanelExtension, FormattingToolbarExtension, HistoryExtension, + InlineContentBoundaryEditExtension, LinkToolbarExtension, NodeSelectionKeyboardExtension, PlaceholderExtension, @@ -22,6 +23,8 @@ import { PreviousBlockTypeExtension, ShowSelectionExtension, SideMenuExtension, + SourceBlockWithPreviewExtension, + SourceInlineContentWithPreviewExtension, SuggestionMenu, TableHandlesExtension, TrailingNodeExtension, @@ -173,8 +176,11 @@ export function getDefaultExtensions( PlaceholderExtension(options), ShowSelectionExtension(options), SideMenuExtension(options), + SourceBlockWithPreviewExtension(), + SourceInlineContentWithPreviewExtension(), SuggestionMenu(options), HistoryExtension(), + InlineContentBoundaryEditExtension(), PositionMappingExtension(), ...(options.trailingBlock !== false ? [TrailingNodeExtension()] : []), ] as ExtensionFactoryInstance[]; diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index c49f787f57..cff40214a0 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -49,6 +49,12 @@ export class ExtensionManager { * We need to keep track of all the plugins for each extension, so that we can remove them when the extension is unregistered */ private extensionPlugins: Map = new Map(); + /** + * Maps an extension key to the set of extension keys that declared it as a + * dependency via `blockNoteExtensions`. A sub-extension is a dependency of + * the extension that declares it, so it must run *before* its parent(s). + */ + private blockNoteExtensionDependents: Map> = new Map(); constructor( private editor: BlockNoteEditor, @@ -111,6 +117,20 @@ export class ExtensionManager { this.addExtension(extension); } } + + // Add the extensions from inline content specs (the built-in text/link + // specs carry none). + for (const inlineContent of Object.values( + this.editor.schema.inlineContentSpecs, + )) { + for (const extension of ( + inlineContent as { + extensions?: (Extension | ExtensionFactoryInstance)[]; + } + ).extensions ?? []) { + this.addExtension(extension); + } + } } /** @@ -179,6 +199,12 @@ export class ExtensionManager { */ private addExtension( extension: Extension | ExtensionFactoryInstance, + /** + * When this extension is being added as a dependency declared in another + * extension's `blockNoteExtensions`, this is the key of that declaring + * (parent) extension. + */ + parentKey?: string, ): Extension | undefined { let instance: Extension; if (typeof extension === "function") { @@ -191,6 +217,32 @@ export class ExtensionManager { return undefined as any; } + // Extension keys must be unique. Registering a second extension with a key + // that's already taken - whether directly or as a dependency declared via + // another extension's `blockNoteExtensions` - is a configuration error: the + // duplicate would otherwise be silently dropped, hiding the bug (and leaving + // it ambiguous which instance's configuration wins). We fail loudly instead. + // A shared extension should be registered exactly once, not registered again + // by each consumer. + if (this.extensions.some((e) => e.key === instance.key)) { + throw new Error( + `An extension with key "${instance.key}" is already registered. ` + + `Extension keys must be unique - register the shared extension once ` + + `rather than registering it again.`, + ); + } + + // A sub-extension declared via `blockNoteExtensions` must run before the + // extension that declares it, so record that ordering edge. + if (parentKey) { + let dependents = this.blockNoteExtensionDependents.get(instance.key); + if (!dependents) { + dependents = new Set(); + this.blockNoteExtensionDependents.set(instance.key, dependents); + } + dependents.add(parentKey); + } + // Now that we know that the extension is not disabled, we can add it to the extension factories if (typeof extension === "function") { const originalFactory = (instance as any)[originalFactorySymbol] as ( @@ -205,8 +257,8 @@ export class ExtensionManager { this.extensions.push(instance); if (instance.blockNoteExtensions) { - for (const extension of instance.blockNoteExtensions) { - this.addExtension(extension); + for (const subExtension of instance.blockNoteExtensions) { + this.addExtension(subExtension, instance.key); } } @@ -326,7 +378,21 @@ export class ExtensionManager { this.options, ).filter((extension) => !this.disabledExtensions.has(extension.name)); - const getPriority = sortByDependencies(this.extensions); + const getPriority = sortByDependencies( + this.extensions.map((extension) => { + // A sub-extension declared via `blockNoteExtensions` must run before the + // extension(s) that declared it, so we merge those parents into its + // `runsBefore`. + const dependents = this.blockNoteExtensionDependents.get(extension.key); + if (!dependents?.size) { + return extension; + } + return { + key: extension.key, + runsBefore: [...(extension.runsBefore ?? []), ...dependents], + }; + }), + ); const inputRulesByPriority = new Map(); for (const extension of this.extensions) { diff --git a/packages/core/src/exporter/mapping.ts b/packages/core/src/exporter/mapping.ts index 0dca63ebc3..fbe88d693e 100644 --- a/packages/core/src/exporter/mapping.ts +++ b/packages/core/src/exporter/mapping.ts @@ -42,7 +42,10 @@ export type InlineContentMapping< > = { [K in keyof I]: ( inlineContent: InlineContentFromConfig, - exporter: Exporter, + // Deliberately loose on the schema generics, like `BlockMapping` above - + // otherwise a mapping declared for one schema can't be reused (e.g. + // spread) in a mapping for a schema with different types. + exporter: Exporter, ) => RI; }; diff --git a/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts b/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts new file mode 100644 index 0000000000..01c0357d03 --- /dev/null +++ b/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts @@ -0,0 +1,109 @@ +import { Plugin, PluginKey, Selection, TextSelection } from "prosemirror-state"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; + +const PLUGIN_KEY = new PluginKey("inline-content-boundary-edit"); + +// Whether a Backspace/Delete at `selection` would remove the entire content +// range `[content.from, content.to)` of an inline content node. +function emptiesInlineContent( + selection: Selection, + key: string, + content: { from: number; to: number }, +) { + if (!selection.empty) { + return selection.from <= content.from && selection.to >= content.to; + } + + const isSingleChar = content.to - content.from === 1; + + return key === "Backspace" + ? isSingleChar && selection.from === content.to + : isSingleChar && selection.from === content.from; +} + +// Fixes editing at the boundary of an empty custom inline content node (i.e. an +// inline node with editable content, like a mention or inline math). +// +// An empty inline node can't hold a text cursor, so ProseMirror can't reconcile +// edits across the empty boundary from the DOM: typing into an empty node +// inserts text next to it rather than inside, and deleting the last character +// leaves an un-reconcilable empty node that corrupts/freezes the editor. Both +// boundary edits are handled here via transactions so the caret stays inside +// the node, which is kept alive and editable in its empty state. +// +// The cursor is inside such a node exactly when its directly-enclosing node is +// inline (`inline: true` in the spec) - regular text blocks aren't inline, and +// atomic inline content can't hold a cursor - so the handling applies to any +// inline content type without needing to know it by name. +export const InlineContentBoundaryEditExtension = createExtension( + () => + ({ + key: "inlineContentBoundaryEdit", + prosemirrorPlugins: [ + new Plugin({ + key: PLUGIN_KEY, + props: { + handleKeyDown: (view, event) => { + if (!view.editable) { + return false; + } + + const isTypedChar = + event.key.length === 1 && !event.ctrlKey && !event.metaKey; + + if ( + !isTypedChar && + event.key !== "Backspace" && + event.key !== "Delete" + ) { + return false; + } + + const { selection } = view.state; + const node = selection.$from.node(); + if (!node.type.spec.inline) { + return false; + } + + const pos = selection.$from.before(); + const contentFrom = pos + 1; + const contentTo = pos + 1 + node.content.size; + + // Empty content: redirect the typed character into the node. + if (isTypedChar && node.content.size === 0) { + const tr = view.state.tr.insert( + contentFrom, + view.state.schema.text(event.key), + ); + tr.setSelection( + TextSelection.create(tr.doc, contentFrom + event.key.length), + ); + view.dispatch(tr); + + return true; + } + + // Backspace/Delete that would empty the content: delete it all in + // one transaction, keeping the now-empty node (and the caret + // inside it) so it stays editable. + if ( + node.content.size > 0 && + emptiesInlineContent(selection, event.key, { + from: contentFrom, + to: contentTo, + }) + ) { + const tr = view.state.tr.delete(contentFrom, contentTo); + tr.setSelection(TextSelection.create(tr.doc, contentFrom)); + view.dispatch(tr); + + return true; + } + + return false; + }, + }, + }), + ], + }) as const, +); diff --git a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts new file mode 100644 index 0000000000..3d6bf4e612 --- /dev/null +++ b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts @@ -0,0 +1,194 @@ +import { TextSelection } from "prosemirror-state"; + +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { + createExtension, + createStore, +} from "../../editor/BlockNoteExtension.js"; +import { Block } from "../../blocks/index.js"; + +/** + * A single editor-wide extension that drives the source popup for blocks that + * render a preview. Which blocks it activates on is decided by each spec's + * `meta.hasPreview` flag, so individual blocks opt in rather than the extension + * being configured with a block type. + * + * The extension is registered once (it's a default extension) and is a no-op + * when no block declares `meta.hasPreview`. + */ +export const SourceBlockWithPreviewExtension = createExtension( + ({ editor }: { editor: BlockNoteEditor }) => { + const store = createStore<{ + popupOpen: string | undefined; + selected: string | undefined; + }>({ + popupOpen: undefined, + selected: undefined, + }); + + // A block has a preview iff its spec's implementation declares + // `meta.hasPreview` (read from the spec, like the syntax-highlighting + // extension reads `meta.highlight`). + const blockHasPreview = (block: Block) => + !!editor.schema.blockSpecs[block.type]?.implementation?.meta?.hasPreview; + + const handleArrow = + (direction: "prev" | "next") => + ({ editor }: { editor: BlockNoteEditor }) => { + const { block, prevBlock, nextBlock } = editor.getTextCursorPosition(); + if (!blockHasPreview(block) || store.state.popupOpen === block.id) { + return false; + } + + const targetBlock = direction === "prev" ? prevBlock : nextBlock; + if (!targetBlock) { + return false; + } + + editor.setTextCursorPosition( + targetBlock.id, + direction === "prev" ? "end" : "start", + ); + + return true; + }; + + return { + key: "sourceBlockWithPreview", + store, + keyboardShortcuts: { + // Toggles the popup. For multi-line sources (e.g. diagrams), Enter + // inserts a line break while the popup is open instead of closing it. + Enter: ({ editor }) => { + const { block } = editor.getTextCursorPosition(); + if (!blockHasPreview(block)) { + return false; + } + + // While the popup is open on a multi-line source, insert a line break + // rather than closing. A preview block's source is multi-line when its + // spec configures Enter to insert a hard break + // (`meta.hardBreakShortcut === "enter"`), e.g. diagrams; single-line + // sources (e.g. math) leave it unset. Handled here (rather than + // falling through to the shared hard-break handler) because that + // handler currently reads `hardBreakShortcut` from the block config, + // which doesn't carry it - see the note in `KeyboardShortcutsExtension`. + if ( + store.state.popupOpen === block.id && + editor.schema.blockSpecs[block.type]?.implementation?.meta + ?.hardBreakShortcut === "enter" + ) { + const view = editor.prosemirrorView!; + view.dispatch(view.state.tr.insertText("\n")); + + return true; + } + + editor.setTextCursorPosition(block.id, "end"); + store.setState((state) => ({ + ...state, + popupOpen: + store.state.popupOpen === block.id ? undefined : block.id, + })); + + return true; + }, + // Closes the popup. + Escape: ({ editor }) => { + const { block } = editor.getTextCursorPosition(); + if (!blockHasPreview(block) || store.state.popupOpen !== block.id) { + return false; + } + + editor.setTextCursorPosition(block.id, "end"); + + store.setState((state) => ({ ...state, popupOpen: undefined })); + + return true; + }, + // While the popup is open, selects the whole source instead of the + // whole document. + "Mod-a": ({ editor }) => { + const { block } = editor.getTextCursorPosition(); + if (!blockHasPreview(block) || store.state.popupOpen !== block.id) { + return false; + } + + const view = editor.prosemirrorView!; + const { $from } = view.state.selection; + if ($from.parent.type.name !== block.type) { + return false; + } + + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.doc, $from.start(), $from.end()), + ), + ); + + return true; + }, + // While the popup is closed, moves the selection straight to the previous/next block + // instead of into the (hidden) source. + ArrowUp: handleArrow("prev"), + ArrowLeft: handleArrow("prev"), + ArrowDown: handleArrow("next"), + ArrowRight: handleArrow("next"), + }, + mount: ({ dom, signal }) => { + // Closes the popup when the selection leaves the block that owns it and tracks which block + // the selection is in. + const unsubscribeSelectionChange = editor.onSelectionChange(() => { + const { block } = editor.getTextCursorPosition(); + + store.setState((state) => ({ + selected: blockHasPreview(block) ? block.id : undefined, + popupOpen: + state.popupOpen && state.popupOpen !== block.id + ? undefined + : state.popupOpen, + })); + }); + signal.addEventListener("abort", unsubscribeSelectionChange); + + // While the popup is closed, prevents editing of the (hidden) source. Handled here rather + // than in `keyboardShortcuts` as it needs to match any text-input key, which a keymap + // can't express. + const handleKeyDown = (event: KeyboardEvent) => { + if (!editor.isEditable) { + return; + } + + const { block } = editor.getTextCursorPosition(); + if (!blockHasPreview(block) || store.state.popupOpen === block.id) { + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + event.stopImmediatePropagation(); + editor.removeBlocks([block.id]); + + return; + } + + if ( + (event.key.length === 1 && !event.ctrlKey && !event.metaKey) || + event.key === "Tab" + ) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + }; + dom.addEventListener("keydown", handleKeyDown, { + capture: true, + signal, + }); + + const handleBlur = () => + store.setState((state) => ({ ...state, popupOpen: undefined })); + dom.addEventListener("blur", handleBlur, { capture: true, signal }); + }, + }; + }, +); diff --git a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts new file mode 100644 index 0000000000..5a86c8ef72 --- /dev/null +++ b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts @@ -0,0 +1,140 @@ +import { Selection, TextSelection } from "prosemirror-state"; + +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { + createExtension, + createStore, +} from "../../editor/BlockNoteExtension.js"; + +/** + * Inline-content counterpart of {@link SourceBlockWithPreviewExtension}. A + * single editor-wide extension that drives the source popup for inline content + * that renders a preview. Which inline content it activates on is decided by + * each spec's `meta.hasPreview` flag, so individual inline content opts in + * rather than the extension being configured with a type. + * + * Unlike the block version, the popup isn't toggled with a separate state flag: + * it's open exactly when the selection is inside the inline content's source. + * The store therefore only tracks which inline content (by its position) holds + * the selection - moving the selection in opens its popup, moving it out closes + * it. Since the source popup is always laid out (just hidden via opacity), the + * cursor can navigate into and out of it with the arrow keys as usual. + * + * The extension is registered once (it's a default extension) and is a no-op + * when no inline content declares `meta.hasPreview`. + */ +export const SourceInlineContentWithPreviewExtension = createExtension( + ({ editor }: { editor: BlockNoteEditor }) => { + const store = createStore<{ + selected: number | undefined; + }>({ + selected: undefined, + }); + + // Inline content has a preview iff its spec's implementation declares + // `meta.hasPreview`. + const nodeHasPreview = (nodeName: string) => + !!editor.schema.inlineContentSpecs[nodeName]?.implementation?.meta + ?.hasPreview; + + // Moves the selection out of the inline content, to just `"before"` or + // `"after"` it, which closes the popup via the selection-change handler + // below. Lets the keyboard commit-and-exit the source the same way arrowing + // past its edge does, keeps Enter from splitting the block while editing the + // source, and lets the up/down arrows step out of the source rather than + // staying trapped inside it. + const moveSelectionOut = + (direction: "before" | "after") => + ({ editor }: { editor: BlockNoteEditor }) => { + const { $from } = editor.prosemirrorState.selection; + const node = $from.node(); + if (!nodeHasPreview(node.type.name)) { + return false; + } + + const view = editor.prosemirrorView!; + const selection = Selection.near( + view.state.doc.resolve( + direction === "before" ? $from.before() : $from.after(), + ), + direction === "before" ? -1 : 1, + ); + view.dispatch(view.state.tr.setSelection(selection)); + + return true; + }; + + return { + key: "sourceInlineContentWithPreview", + store, + keyboardShortcuts: { + Enter: moveSelectionOut("after"), + Escape: moveSelectionOut("after"), + ArrowUp: moveSelectionOut("before"), + ArrowDown: moveSelectionOut("after"), + // While editing the source, selects the whole source instead of the + // whole document. + "Mod-a": ({ editor }) => { + const { $from } = editor.prosemirrorState.selection; + if (!nodeHasPreview($from.node().type.name)) { + return false; + } + + const view = editor.prosemirrorView!; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.doc, $from.start(), $from.end()), + ), + ); + + return true; + }, + }, + mount: ({ dom, signal }) => { + // The popup is open exactly when the selection is inside the inline + // content, so we just track which inline content (if any) holds it. + const unsubscribeSelectionChange = editor.onSelectionChange(() => { + const { $from } = editor.prosemirrorState.selection; + const node = $from.node(); + + store.setState({ + selected: nodeHasPreview(node.type.name) + ? $from.before() + : undefined, + }); + }); + signal.addEventListener("abort", unsubscribeSelectionChange); + + // Sets `visibility: hidden` on the popup for a single frame when pressing up/down arrow + // keys. The popup is normally hidden through `opacity: 0`, which means it's still visible + // to the browser for navigation. Therefore, the up/down arrows can sometimes move the + // selection into the popup from unexpected positions, such as on the same line. Setting + // `visibility: hidden` makes the browser ignore it when determining the new selection. + const handleVerticalArrow = (event: KeyboardEvent) => { + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") { + return; + } + + // When the selection is already inside a source, leave navigation + // (moving within or out of it) to the browser as usual. + const { $from } = editor.prosemirrorState.selection; + if (nodeHasPreview($from.node().type.name)) { + return; + } + + dom.classList.add("bn-suppress-source-popup-caret"); + requestAnimationFrame(() => + dom.classList.remove("bn-suppress-source-popup-caret"), + ); + }; + dom.addEventListener("keydown", handleVerticalArrow, { + capture: true, + signal, + }); + + const handleBlur = () => store.setState({ selected: undefined }); + dom.addEventListener("blur", handleBlur, { capture: true, signal }); + }, + }; + }, +); 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..3ad6cbef0a --- /dev/null +++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + collectHighlightNodeTypes, + SyntaxHighlightingExtension, +} from "./SyntaxHighlighting.js"; + +/** + * @vitest-environment jsdom + */ + +describe("SyntaxHighlightingExtension", () => { + // The extension only reads `editor.schema.blockSpecs` and + // `inlineContentSpecs`, 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" } }, + }, + inlineContentSpecs: {}, + }, + }) as any; + + const pluginsFor = (options: any) => + SyntaxHighlightingExtension(options)({ editor: fakeEditor() }) + .prosemirrorPlugins; + + // Whether highlighting is enabled at all is decided by the user (they choose + // to add this extension to the editor's `extensions`), 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); + }); +}); + +describe("collectHighlightNodeTypes", () => { + const highlight = () => "latex"; + + it("includes blocks with `content: inline` and a `meta.highlight`", () => { + const types = collectHighlightNodeTypes({ + blockSpecs: { + // Highlightable: inline content + a highlight callback. + math: { + config: { type: "math", content: "inline" }, + implementation: { meta: { highlight } }, + }, + // Not highlightable: no highlight callback. + paragraph: { + config: { type: "paragraph", content: "inline" }, + implementation: { meta: {} }, + }, + // Not highlightable: `content: none` holds no editable text. + image: { + config: { type: "image", content: "none" }, + implementation: { meta: { highlight } }, + }, + }, + inlineContentSpecs: {}, + }); + + expect(types).toEqual(["math"]); + }); + + it("includes inline content with `content: styled` and a `meta.highlight`", () => { + const types = collectHighlightNodeTypes({ + blockSpecs: {}, + inlineContentSpecs: { + // Highlightable: styled (editable rich text) + a highlight callback. + inlineMath: { + config: { type: "inlineMath", content: "styled" }, + implementation: { meta: { highlight } }, + }, + // Not highlightable: no highlight callback. + mention: { + config: { type: "mention", content: "styled" }, + implementation: { meta: {} }, + }, + // Not highlightable: `content: none` holds no editable text. + tag: { + config: { type: "tag", content: "none" }, + implementation: { meta: { highlight } }, + }, + // Built-in `text`/`link` specs have string configs, not objects. + text: { config: "text", implementation: undefined }, + link: { config: "link", implementation: undefined }, + }, + }); + + expect(types).toEqual(["inlineMath"]); + }); + + it("collects both block and inline-content highlight types together", () => { + const types = collectHighlightNodeTypes({ + blockSpecs: { + math: { + config: { type: "math", content: "inline" }, + implementation: { meta: { highlight } }, + }, + }, + inlineContentSpecs: { + inlineMath: { + config: { type: "inlineMath", content: "styled" }, + implementation: { meta: { highlight } }, + }, + }, + }); + + expect(types).toContain("math"); + expect(types).toContain("inlineMath"); + expect(types).toHaveLength(2); + }); +}); diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts new file mode 100644 index 0000000000..309deac375 --- /dev/null +++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts @@ -0,0 +1,88 @@ +import type { HighlighterGeneric } from "@shikijs/types"; +import { + createExtension, + ExtensionOptions, +} from "../../editor/BlockNoteExtension.js"; +import { lazyShikiPlugin } from "./shiki.js"; +import { + CustomInlineContentConfig, + InlineContentSpec, + LooseBlockSpec, +} from "../../schema/index.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>; +}; + +/** + * Collects the node type names that should be syntax-highlighted from a schema's + * block and inline-content specs. + * + * A spec is a candidate when it has a `meta.highlight` callback (which decides + * the language) AND the node actually holds editable text. Block and + * inline-content specs use different `content` value spaces, so "editable text" + * means `content === "inline"` for blocks and `content === "styled"` for inline + * content - hence the two are filtered separately. + * + * Inline content (e.g. inline math) is highlighted too: `prosemirror-highlight` + * collects nodes by `node.inlineContent` since v0.15.3 + * (https://github.com/ocavue/prosemirror-highlight/pull/137), so inline nodes + * holding inline content are visited alongside text blocks. + */ +export function collectHighlightNodeTypes(schema: { + blockSpecs: Record; + inlineContentSpecs: Record; +}): string[] { + const blockNodeTypes = Object.values(schema.blockSpecs) + .filter( + (blockSpec): blockSpec is LooseBlockSpec => + typeof (blockSpec as LooseBlockSpec)?.config === "object" && + (blockSpec as LooseBlockSpec).config.content === "inline" && + !!(blockSpec as LooseBlockSpec).implementation?.meta?.highlight, + ) + .map((blockSpec) => blockSpec.config.type); + + const inlineContentNodeTypes = Object.values(schema.inlineContentSpecs) + .filter( + ( + inlineContentSpec, + ): inlineContentSpec is InlineContentSpec => + typeof ( + inlineContentSpec as InlineContentSpec + )?.config === "object" && + (inlineContentSpec as InlineContentSpec) + .config.content === "styled" && + !!(inlineContentSpec as InlineContentSpec) + .implementation?.meta?.highlight, + ) + .map((inlineContentSpec) => inlineContentSpec.config.type); + + return [...blockNodeTypes, ...inlineContentNodeTypes]; +} + +/** + * A single editor-wide extension that syntax-highlights block and inline-content + * content. Which nodes get highlighted (and as which language) is decided by + * each spec's `meta.highlight` callback, so individual specs declare their own + * language rather than the extension configuring them. + * + * Highlighting is opt-in: the user adds this extension to the editor's + * `extensions` (configured with a `createHighlighter`) to enable it. When it's + * absent, content renders as plain text. + */ +export const SyntaxHighlightingExtension = createExtension( + ({ editor, options }: ExtensionOptions) => { + const nodeTypes = collectHighlightNodeTypes(editor.schema); + + return { + key: "syntaxHighlighting", + prosemirrorPlugins: [lazyShikiPlugin(options, nodeTypes, editor.schema)], + }; + }, +); diff --git a/packages/core/src/extensions/SyntaxHighlighting/inlineHighlight.test.ts b/packages/core/src/extensions/SyntaxHighlighting/inlineHighlight.test.ts new file mode 100644 index 0000000000..96e03418e7 --- /dev/null +++ b/packages/core/src/extensions/SyntaxHighlighting/inlineHighlight.test.ts @@ -0,0 +1,135 @@ +import { Schema } from "prosemirror-model"; +import { EditorState, PluginKey } from "prosemirror-state"; +import { Decoration } from "prosemirror-view"; +import { createHighlightPlugin } from "prosemirror-highlight"; +import { describe, expect, it } from "vite-plus/test"; + +/** + * @vitest-environment jsdom + */ + +// Regression coverage for inline syntax highlighting. `prosemirror-highlight` +// used to collect only text-block nodes, so inline nodes (e.g. inline math) +// were never highlighted. Since v0.15.3 it collects nodes by +// `node.inlineContent` (https://github.com/ocavue/prosemirror-highlight/pull/137), +// so inline content with a `meta.highlight` is highlighted too. This suite +// guards that behavior. It drives the plugin directly against a hand-built +// ProseMirror doc, so it needs neither Shiki nor a browser. +describe("inline syntax highlighting", () => { + // A minimal schema with an *inline* node type that holds inline content + // (`content: "text*"`), mirroring how inline content like inline math is + // structured, plus an *atom* inline node that holds no content. + const schema = new Schema({ + nodes: { + text: { group: "inline" }, + inlineCode: { + group: "inline", + inline: true, + content: "text*", + toDOM: () => ["span", 0], + }, + // An atom inline node (no inline content) - like a mention. It should + // never be collected for highlighting even if named in `nodeTypes`, since + // it holds no editable text. This is why the library keys off + // `node.inlineContent` rather than merely `node.isInline`. + mention: { + group: "inline", + inline: true, + atom: true, + toDOM: () => ["span", "@x"], + }, + paragraph: { + group: "block", + content: "inline*", + toDOM: () => ["p", 0], + }, + doc: { content: "block+" }, + }, + }); + + const docWithInline = schema.node("doc", null, [ + schema.node("paragraph", null, [ + schema.text("before "), + schema.node("inlineCode", null, [schema.text("const x = 1")]), + schema.text(" "), + schema.node("mention"), + schema.text(" after"), + ]), + ]); + + it("calls the parser with the inline node's content and decorates it", () => { + const seen: { content: string; language?: string }[] = []; + + const plugin = createHighlightPlugin({ + // Synchronous stub parser mirroring how the real Shiki parser emits token + // decorations: `Decoration.inline` over positions *inside* the node, + // starting at `pos + 1` (the node's content starts after its opening + // boundary). Node-spanning decorations aren't valid over an inline node, + // which is fine - the token decorations are what actually color the text. + parser: ({ content, language, pos }) => { + seen.push({ content, language }); + return [ + Decoration.inline(pos + 1, pos + 1 + content.length, { + class: "hl", + }), + ]; + }, + nodeTypes: ["inlineCode"], + languageExtractor: () => "javascript", + }); + + const state = EditorState.create({ + schema, + doc: docWithInline, + plugins: [plugin], + }); + + const key = (plugin as any).spec.key as PluginKey; + const pluginState = key.getState(state); + + // The inline node's text reached the parser - proving inline nodes are + // collected (with the pre-0.15.3 library, `seen` would be empty). + expect(seen).toHaveLength(1); + expect(seen[0].content).toBe("const x = 1"); + expect(seen[0].language).toBe("javascript"); + + // And a decoration was produced for it. + expect(pluginState.decorations).toBeDefined(); + expect(pluginState.decorations.find().length).toBeGreaterThan(0); + }); + + it("still leaves non-matching inline nodes untouched", () => { + const seen: string[] = []; + const plugin = createHighlightPlugin({ + parser: ({ content }) => { + seen.push(content); + return []; + }, + nodeTypes: ["somethingElse"], + languageExtractor: () => "javascript", + }); + + EditorState.create({ schema, doc: docWithInline, plugins: [plugin] }); + + expect(seen).toHaveLength(0); + }); + + it("excludes atom inline nodes even when named in `nodeTypes`", () => { + const seen: string[] = []; + const plugin = createHighlightPlugin({ + parser: ({ content }) => { + seen.push(content); + return []; + }, + // `mention` is an atom inline node with no inline content, so it holds no + // text to highlight. The library keys off `node.inlineContent`, not + // `node.isInline`, so it's correctly skipped. + nodeTypes: ["mention"], + languageExtractor: () => "javascript", + }); + + EditorState.create({ schema, doc: docWithInline, plugins: [plugin] }); + + expect(seen).toHaveLength(0); + }); +}); diff --git a/packages/core/src/extensions/SyntaxHighlighting/shiki.ts b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts new file mode 100644 index 0000000000..a0e8cec88b --- /dev/null +++ b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts @@ -0,0 +1,116 @@ +import type { HighlighterGeneric } from "@shikijs/types"; +import { Parser, createHighlightPlugin } from "prosemirror-highlight"; +import { createParser } from "prosemirror-highlight/shiki"; +import type { SyntaxHighlightingOptions } from "./SyntaxHighlighting.js"; +import { CustomBlockNoteSchema } from "../../schema/schema.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. + * + * Each spec's `meta.highlight` callback resolves a node 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[], + schema: CustomBlockNoteSchema, +) { + 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, pickThemeOptions(highlighter)); + 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 a spec's + // `meta.highlight` needs to pick a language). + languageExtractor: (node) => { + const nodeShape = { + type: node.type.name, + props: node.attrs, + }; + // search for the node in the blockSpec or inlineContentSpecs + const spec = + schema.blockSpecs[nodeShape.type] || + schema.inlineContentSpecs[nodeShape.type]; + + return spec?.implementation?.meta?.highlight?.(nodeShape) ?? undefined; + }, + nodeTypes, + }); +} + +// If a light and dark theme is added to the highlighter, this function specifies them in +// `createParser`. This lets us use `--shiki-light` and `--shiki-dark` CSS variables for correct +// styling for both light & dark editor themes. +function pickThemeOptions(highlighter: HighlighterGeneric) { + const themes = highlighter.getLoadedThemes(); + const light = themes.find((t) => /light/i.test(t)); + const dark = themes.find((t) => /dark/i.test(t)); + + if (light && dark) { + return { themes: { light, dark }, defaultColor: false as const }; + } + + return undefined; +} diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index e568462a13..ef0faaf206 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -3,6 +3,7 @@ export * from "./DropCursor/DropCursor.js"; export * from "./FilePanel/FilePanel.js"; export * from "./FormattingToolbar/FormattingToolbar.js"; export * from "./History/History.js"; +export * from "./InlineContentBoundaryEdit/InlineContentBoundaryEdit.js"; export * from "./LinkToolbar/LinkToolbar.js"; export * from "./LinkToolbar/protocols.js"; export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js"; @@ -11,7 +12,10 @@ export * from "./PositionMapping/PositionMapping.js"; export * from "./PreviousBlockType/PreviousBlockType.js"; export * from "./ShowSelection/ShowSelection.js"; export * from "./SideMenu/SideMenu.js"; +export * from "./SourceBlockWithPreview/SourceBlockWithPreview.js"; +export * from "./SourceInlineContentWithPreview/SourceInlineContentWithPreview.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/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 487b771d7e..40b9d25e23 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -804,6 +804,14 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); + // NOTE: This likely doesn't work as intended - `blockSchema[type]` + // holds the block *config* (type/propSchema/content), which carries + // no `meta`, so `meta?.hardBreakShortcut` is always `undefined` and + // this falls back to the default. It should read from the block + // spec's implementation instead (i.e. + // `editor.schema.blockSpecs[type].implementation.meta`), the way the + // syntax-highlighting extension reads `meta.highlight`. Left as-is + // for a follow-up pass. const blockHardBreakShortcut = this.options.editor.schema.blockSchema[ blockInfo.blockNoteType as keyof typeof this.options.editor.schema.blockSchema 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 0fbb33e051..dd5a6c90a4 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -224,10 +224,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 c738871c50..4c8fac9cf9 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 { @@ -27,7 +31,10 @@ export type BlockNoteDOMAttributes = Partial<{ [DOMElement in BlockNoteDOMElement]: Record; }>; -export interface BlockConfigMeta { +export interface BlockConfigMeta< + TName extends string = string, + TProps extends PropSchema = PropSchema, +> { /** * Defines which keyboard shortcut should be used to insert a hard break into the block's inline content. * @default "shift+enter" @@ -58,6 +65,18 @@ export interface BlockConfigMeta { * Whether the block is a {@link https://prosemirror.net/docs/ref/#model.NodeSpec.isolating} block */ isolating?: boolean; + + /** + * Enables syntax highlighting of the contents of the block with the result of this callback + */ + highlight?(block: { type: TName; props: Props }): string | undefined; + + /** + * Marks the block as rendering a preview with an editable source popup, driven + * by the editor-wide `SourceBlockWithPreviewExtension`. When `true`, the + * block's source is hidden behind its preview and edited via the popup. + */ + hasPreview?: boolean; } /** @@ -188,6 +207,7 @@ export type LooseBlockSpec< dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; + update?: (node: ProsemirrorNode) => boolean; destroy?: () => void; }; toExternalHTML?: ( @@ -246,6 +266,7 @@ export type BlockSpecs = { dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; + update?: (node: ProsemirrorNode) => boolean; destroy?: () => void; }; toExternalHTML?: ( @@ -477,7 +498,7 @@ export type BlockImplementation< /** * Metadata */ - meta?: BlockConfigMeta; + meta?: BlockConfigMeta; /** * A function that converts the block into a DOM element */ @@ -511,6 +532,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/core/src/schema/inlineContent/createSpec.ts b/packages/core/src/schema/inlineContent/createSpec.ts index f4522936a4..fa560f91d7 100644 --- a/packages/core/src/schema/inlineContent/createSpec.ts +++ b/packages/core/src/schema/inlineContent/createSpec.ts @@ -1,6 +1,12 @@ import { Node } from "@tiptap/core"; -import { TagParseRule } from "@tiptap/pm/model"; +import { + DOMParser, + Fragment, + Node as ProsemirrorNode, + Schema, + TagParseRule, +} from "@tiptap/pm/model"; import { inlineContentToNodes } from "../../api/nodeConversions/blockToNode.js"; import { nodeToCustomInlineContent } from "../../api/nodeConversions/nodeToBlock.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; @@ -25,6 +31,23 @@ export type CustomInlineContentImplementation< > = { meta?: { draggable?: boolean; + code?: boolean; + /** + * When {@link code} is `true`, this can syntax highlight the contents of the + * inline content with the result of this callback. + */ + // Method syntax (rather than an arrow-function property) so its parameter is + // checked bivariantly, keeping a specific implementation assignable to the + // generic spec record type. + highlight?( + inlineContent: Pick, "type" | "props">, + ): string | undefined; + /** + * Marks the inline content as rendering a preview with an editable source + * popup, driven by the editor-wide + * `SourceInlineContentWithPreviewExtension`. + */ + hasPreview?: boolean; }; /** @@ -32,6 +55,17 @@ export type CustomInlineContentImplementation< */ parse?: (el: HTMLElement) => Partial> | undefined; + /** + * Advanced parsing function that controls how the content within the inline + * content is parsed. This is not recommended to use, and is only useful for + * advanced use cases. Only applies to inline content with `content: "styled"`. + * Return `undefined` to fall through to the default inline content parsing. + */ + parseContent?: (options: { + el: HTMLElement; + schema: Schema; + }) => Fragment | undefined; + /** * Renders an inline content to DOM elements */ @@ -54,6 +88,16 @@ export type CustomInlineContentImplementation< editor: BlockNoteEditor, // (note) if we want to fix the manual cast, we need to prevent circular references and separate block definition and render implementations // or allow manually passing , but that's not possible without passing the other generics because Typescript doesn't support partial inferred generics + /** + * The ProseMirror node backing this inline content. + */ + node: ProsemirrorNode, + /** + * Returns this inline content's position in the document. When rendered + * outside the editor (i.e. serialized to HTML), this is a no-op that returns + * `undefined`. + */ + getPos: () => number | undefined, ) => { dom: HTMLElement; contentDOM?: HTMLElement; @@ -85,22 +129,65 @@ export type CustomInlineContentImplementation< runsBefore?: string[]; }; +// Resolves the element whose children hold the inline content's editable +// content, i.e. the `[data-editable]` element (or the element itself if it is / +// contains none). +function getEditableElement(element: HTMLElement) { + if (element.matches("[data-editable]")) { + return element; + } + + return element.querySelector("[data-editable]") || element; +} + +// Parses an element's children as inline content. +function parseInlineContent(el: HTMLElement, schema: Schema) { + return DOMParser.fromSchema(schema).parse(el, { + topNode: schema.nodes.paragraph.create(), + preserveWhitespace: true, + }).content; +} + export function getInlineContentParseRules( config: C, customParseFunction?: CustomInlineContentImplementation["parse"], + customParseContentFunction?: CustomInlineContentImplementation< + C, + any + >["parseContent"], ) { + // When a custom `parseContent` function is provided (and this inline content + // actually holds content), it controls how content within the inline content + // is parsed. This applies to _both_ parse rules below, as content copied from + // within the editor is tagged with `data-inline-content-type` (matched by the + // first rule), while content pasted from outside is matched by the custom + // `parse` function (the second rule). `resolveContentElement` locates the + // element whose children to parse as a fallback when `parseContent` returns + // `undefined`. + const getContent = + customParseContentFunction && config.content === "styled" + ? (resolveContentElement: (el: HTMLElement) => HTMLElement) => + (node: HTMLElement, schema: Schema) => { + const result = customParseContentFunction({ el: node, schema }); + + // `parseContent` may return `undefined` to fall through to the + // default inline content parsing. + if (result !== undefined) { + return result; + } + + return parseInlineContent(resolveContentElement(node), schema); + } + : undefined; + const rules: TagParseRule[] = [ { tag: `[data-inline-content-type="${config.type}"]`, - contentElement: (element) => { - const htmlElement = element as HTMLElement; - - if (htmlElement.matches("[data-editable]")) { - return htmlElement; - } - - return htmlElement.querySelector("[data-editable]") || htmlElement; - }, + contentElement: (element) => getEditableElement(element as HTMLElement), + getContent: getContent + ? (node, schema) => + getContent(getEditableElement)(node as HTMLElement, schema) + : undefined, }, ]; @@ -120,6 +207,12 @@ export function getInlineContentParseRules( return props; }, + // Because we do the parsing ourselves, we want to preserve whitespace for + // content we've parsed. + preserveWhitespace: getContent ? true : undefined, + getContent: getContent + ? (node, schema) => getContent((el) => el)(node as HTMLElement, schema) + : undefined, }); } return rules; @@ -139,6 +232,7 @@ export function createInlineContentSpec< draggable: inlineContentImplementation.meta?.draggable, selectable: inlineContentConfig.content === "styled", atom: inlineContentConfig.content === "none", + code: inlineContentImplementation.meta?.code, content: inlineContentConfig.content === "styled" ? "inline*" : "", addAttributes() { @@ -153,6 +247,7 @@ export function createInlineContentSpec< return getInlineContentParseRules( inlineContentConfig, inlineContentImplementation.parse, + inlineContentImplementation.parseContent, ); }, @@ -170,6 +265,8 @@ export function createInlineContentSpec< // No-op }, editor, + node, + () => undefined, ); return addInlineContentAttributes( @@ -206,6 +303,8 @@ export function createInlineContentSpec< ); }, editor, + node, + getPos, ); return addInlineContentAttributes( @@ -225,10 +324,19 @@ export function createInlineContentSpec< ...inlineContentImplementation, toExternalHTML: inlineContentImplementation.toExternalHTML, render(inlineContent, updateInlineContent, editor) { + // Rendered outside the editor (serialization), so there's no live node + // view - derive the node from the content and stub out `getPos`. + const node = inlineContentToNodes( + [inlineContent] as any, + editor.pmSchema, + )[0]; + const output = inlineContentImplementation.render( inlineContent, updateInlineContent, editor, + node, + () => undefined, ); return addInlineContentAttributes( diff --git a/packages/core/src/schema/inlineContent/internal.ts b/packages/core/src/schema/inlineContent/internal.ts index 799c41e4f1..f292fcb73b 100644 --- a/packages/core/src/schema/inlineContent/internal.ts +++ b/packages/core/src/schema/inlineContent/internal.ts @@ -1,5 +1,9 @@ import { KeyboardShortcutCommand, Node } from "@tiptap/core"; +import { + Extension, + ExtensionFactoryInstance, +} from "../../editor/BlockNoteExtension.js"; import { camelToDataKebab } from "../../util/string.js"; import { PropSchema, Props } from "../propTypes.js"; import { @@ -77,10 +81,12 @@ export function createInternalInlineContentSpec< >( config: T, implementation: InlineContentImplementation>, + extensions?: (Extension | ExtensionFactoryInstance)[], ): InlineContentSpec { return { config, implementation, + extensions, } as const; } @@ -101,10 +107,18 @@ export function createInlineContentSpecFromTipTapNode< propSchema, content: node.config.content === "inline*" ? "styled" : "none", }, + // Cast needed because `implementation` is typed against the generic + // `CustomInlineContentConfig`, while `createInternalInlineContentSpec` + // expects the implementation for the specific (still-generic) config + // inferred from `node`/`propSchema` above. { ...implementation, node, - }, + } as unknown as InlineContentImplementation<{ + type: T["name"]; + propSchema: P; + content: "styled" | "none"; + }>, ); } diff --git a/packages/core/src/schema/inlineContent/types.ts b/packages/core/src/schema/inlineContent/types.ts index b8e922502a..4c27a1666b 100644 --- a/packages/core/src/schema/inlineContent/types.ts +++ b/packages/core/src/schema/inlineContent/types.ts @@ -2,6 +2,10 @@ import { Node } from "@tiptap/core"; import { PropSchema, Props } from "../propTypes.js"; import { StyleSchema, Styles } from "../styles/types.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { + Extension, + ExtensionFactoryInstance, +} from "../../editor/BlockNoteExtension.js"; import { ViewMutationRecord } from "prosemirror-view"; export type CustomInlineContentConfig = { @@ -16,11 +20,35 @@ export type InlineContentConfig = CustomInlineContentConfig | "text" | "link"; // InlineContentImplementation contains the "implementation" info about an InlineContent element // such as the functions / Nodes required to render and / or serialize it export type InlineContentImplementation = - T extends "link" | "text" - ? undefined - : { + T extends CustomInlineContentConfig + ? { meta?: { + /** + * Whether the inline content is a {@link https://prosemirror.net/docs/ref/#model.NodeSpec.code} block + */ + code?: boolean; + /** + * When {@link code} is `true`, this can syntax highlight the contents of the block with the result of this callback + */ + // Method syntax (rather than an arrow-function property) so its + // parameter is checked bivariantly, keeping a specific + // implementation assignable to the generic spec record type. + highlight?( + inlineContent: Pick< + CustomInlineContentFromConfig, + "type" | "props" + >, + ): string | undefined; + /** + * Whether the inline content is draggable + */ draggable?: boolean; + /** + * Marks the inline content as rendering a preview with an editable + * source popup, driven by the editor-wide + * `SourceInlineContentWithPreviewExtension`. + */ + hasPreview?: boolean; }; node: Node; toExternalHTML?: ( @@ -43,7 +71,8 @@ export type InlineContentImplementation = destroy?: () => void; }; runsBefore?: string[]; - }; + } + : undefined; export type InlineContentSchemaWithInlineContent< IType extends string, @@ -57,6 +86,7 @@ export type InlineContentSchemaWithInlineContent< export type InlineContentSpec = { config: T; implementation: InlineContentImplementation; + extensions?: (Extension | ExtensionFactoryInstance)[]; }; // A Schema contains all the types (Configs) supported in an editor diff --git a/packages/diagram-block/.gitignore b/packages/diagram-block/.gitignore new file mode 100644 index 0000000000..58f115c8dc --- /dev/null +++ b/packages/diagram-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/diagram-block/LICENSE b/packages/diagram-block/LICENSE new file mode 100644 index 0000000000..fa0086a952 --- /dev/null +++ b/packages/diagram-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/diagram-block/package.json b/packages/diagram-block/package.json new file mode 100644 index 0000000000..ff777e784c --- /dev/null +++ b/packages/diagram-block/package.json @@ -0,0 +1,77 @@ +{ + "name": "@blocknote/diagram-block", + "homepage": "https://github.com/TypeCellOS/BlockNote", + "private": false, + "sideEffects": [ + "*.css" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/TypeCellOS/BlockNote.git", + "directory": "packages/diagram-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", + "mermaid", + "diagram", + "flowchart" + ], + "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-diagram-block.cjs", + "module": "./dist/blocknote-diagram-block.js", + "exports": { + ".": { + "types": "./types/src/index.d.ts", + "import": "./dist/blocknote-diagram-block.js", + "require": "./dist/blocknote-diagram-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": { + "mermaid": "^11.0.0" + }, + "devDependencies": { + "@blocknote/react": "workspace:^", + "react-icons": "^5.5.0", + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "rimraf": "^5.0.10", + "rollup-plugin-webpack-stats": "^0.2.6", + "typescript": "^5.9.3", + "vite-plus": "catalog:" + }, + "peerDependencies": { + "@blocknote/core": "workspace:^", + "@blocknote/react": "workspace:^", + "react": "^18.0 || ^19.0 || >= 19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" + } +} diff --git a/packages/diagram-block/src/block/createReactDiagramBlockSpec.test.tsx b/packages/diagram-block/src/block/createReactDiagramBlockSpec.test.tsx new file mode 100644 index 0000000000..3588ca7cde --- /dev/null +++ b/packages/diagram-block/src/block/createReactDiagramBlockSpec.test.tsx @@ -0,0 +1,274 @@ +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { BlockNoteViewRaw } from "@blocknote/react"; +import { flushSync } from "react-dom"; +import { createRoot, Root } from "react-dom/client"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; +import { createReactDiagramBlockSpec } from "./createReactDiagramBlockSpec.js"; + +// TODO: deprecate jsdom, use vitest browser test? +/** + * @vitest-environment jsdom + */ + +// Mermaid needs a real browser to render, so the tests mock it - the specs +// under test cover the block's editing behavior, not Mermaid itself. Sources +// containing "INVALID" fail to parse, for testing the error states. +vi.mock("mermaid", () => ({ + default: { + initialize: vi.fn(), + parse: vi.fn(async (source: string) => { + if (source.includes("INVALID")) { + throw new Error("mock parse error"); + } + return true; + }), + render: vi.fn(async () => ({ + svg: '', + })), + }, +})); + +// The diagram block isn't a default block, so register it in a custom schema. +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { diagram: createReactDiagramBlockSpec() }, +}); + +describe("Diagram block source popup", () => { + let editor: BlockNoteEditor; + let div: HTMLDivElement; + let root: Root; + + beforeEach(async () => { + // Mounted in the document tree so capture-phase handlers on ancestors see + // dispatched events. + div = document.createElement("div"); + document.body.appendChild(div); + + editor = BlockNoteEditor.create({ + schema, + trailingBlock: false, + initialContent: [ + { id: "para", type: "paragraph", content: "before" }, + { id: "diagram", type: "diagram", content: "graph TD\n A --> B" }, + ], + }); + + root = createRoot(div); + flushSync(() => { + root.render(); + }); + // Let the React node view render (and the mocked Mermaid "render") before + // assertions read its DOM. + await flush(); + + editor.setTextCursorPosition("diagram", "start"); + }); + + afterEach(() => { + root.unmount(); + editor._tiptapEditor.destroy(); + editor = undefined as any; + div.remove(); + }); + + /** Yields to the event loop so async renders & store updates can flush. */ + function flush() { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + + /** The preview-with-source-popup root, which holds `data-open`. */ + function previewRoot(): HTMLElement { + return div.querySelector(".bn-preview-with-source-popup") as HTMLElement; + } + + /** Whether the source popup is open (the source is being edited). */ + function isPopupOpen(): boolean { + return previewRoot()?.getAttribute("data-open") === "true"; + } + + /** The diagram block's source as plain text. */ + function source(): string { + const block = editor.getBlock("diagram")!; + return (block.content as { text: string }[]) + .map((node) => node.text ?? "") + .join(""); + } + + /** Dispatches a keydown on the editor DOM, running the ProseMirror keymap. + * Returns whether the default was prevented. */ + function pressKey(key: string, init: KeyboardEventInit = {}): boolean { + const event = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...init, + }); + editor.prosemirrorView!.dom.dispatchEvent(event); + return event.defaultPrevented; + } + + it("renders the (mocked) diagram preview", () => { + expect( + previewRoot().querySelector( + '.bn-preview-container [data-mermaid-mock="true"]', + ), + ).not.toBeNull(); + }); + + it("Enter opens the source popup", async () => { + expect(isPopupOpen()).toBe(false); + + expect(pressKey("Enter")).toBe(true); + await flush(); + + expect(isPopupOpen()).toBe(true); + }); + + it("Enter inserts a line break while the popup is open (newline enter behaviour)", async () => { + pressKey("Enter"); + await flush(); + expect(isPopupOpen()).toBe(true); + const sourceBefore = source(); + + // Unlike the (single-line) math block, Enter extends the source instead + // of closing the popup. + expect(pressKey("Enter")).toBe(true); + await flush(); + + expect(isPopupOpen()).toBe(true); + expect(source()).toBe(sourceBefore + "\n"); + }); + + it("Mod+A selects only the source while the popup is open", async () => { + pressKey("Enter"); + await flush(); + expect(isPopupOpen()).toBe(true); + + // jsdom isn't detected as macOS, so "Mod" resolves to Ctrl. + expect(pressKey("a", { ctrlKey: true })).toBe(true); + await flush(); + + // The selection spans exactly the block's source, not the document. + const selection = editor.prosemirrorState.selection; + expect( + editor.prosemirrorState.doc.textBetween(selection.from, selection.to), + ).toBe(source()); + expect(isPopupOpen()).toBe(true); + }); + + it("Escape closes the source popup", async () => { + pressKey("Enter"); + await flush(); + expect(isPopupOpen()).toBe(true); + + expect(pressKey("Escape")).toBe(true); + await flush(); + + expect(isPopupOpen()).toBe(false); + }); + + it("blocks character input while the popup is closed", () => { + expect(isPopupOpen()).toBe(false); + + // The source is hidden, so the keystroke is swallowed rather than + // silently editing the source the user can't see. + expect(pressKey("a")).toBe(true); + }); + + /** The mocked rendered diagram, if currently shown as the preview. */ + function renderedDiagram(): Element | null { + return previewRoot().querySelector( + '.bn-preview-container [data-mermaid-mock="true"]', + ); + } + + /** The compact error state, if currently shown as the preview. */ + function errorState(): Element | null { + return previewRoot().querySelector( + ".bn-preview-container .bn-preview-placeholder-error", + ); + } + + it("keeps the last preview while editing an erroneous source", async () => { + pressKey("Enter"); + await flush(); + expect(isPopupOpen()).toBe(true); + + editor.updateBlock("diagram", { content: "graph INVALID" }); + await flush(); + + // The last valid diagram stays up instead of the error state. + expect(renderedDiagram()).not.toBeNull(); + expect(errorState()).toBeNull(); + }); + + it("shows the error state once an erroneous source is committed", async () => { + pressKey("Enter"); + await flush(); + editor.updateBlock("diagram", { content: "graph INVALID" }); + await flush(); + + pressKey("Escape"); + await flush(); + expect(isPopupOpen()).toBe(false); + + // Committed with an error, so the (stale) preview is replaced by the + // error state. + expect(renderedDiagram()).toBeNull(); + expect(errorState()).not.toBeNull(); + }); + + it("keeps showing the error state when reopening a committed error", async () => { + pressKey("Enter"); + await flush(); + editor.updateBlock("diagram", { content: "graph INVALID" }); + await flush(); + pressKey("Escape"); + await flush(); + expect(errorState()).not.toBeNull(); + + pressKey("Enter"); + await flush(); + expect(isPopupOpen()).toBe(true); + + // The error was committed, so reopening doesn't bring the stale preview + // back - the error state stays until the source renders successfully. + expect(renderedDiagram()).toBeNull(); + expect(errorState()).not.toBeNull(); + }); + + it("shows the error state while editing when there's no last preview", async () => { + // A fresh block whose source never rendered successfully. + editor.removeBlocks(["diagram"]); + editor.insertBlocks( + [{ id: "broken", type: "diagram", content: "graph INVALID" }], + "para", + "after", + ); + await flush(); + + expect(renderedDiagram()).toBeNull(); + expect(errorState()).not.toBeNull(); + }); + + it("shows the placeholder for an empty source", async () => { + editor.removeBlocks(["diagram"]); + editor.insertBlocks( + [{ id: "empty", type: "diagram", content: "" }], + "para", + "after", + ); + await flush(); + + expect( + previewRoot().querySelector(".bn-preview-placeholder-text")?.textContent, + ).toBe("Add a Mermaid diagram"); + }); +}); diff --git a/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx b/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx new file mode 100644 index 0000000000..b49143daac --- /dev/null +++ b/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx @@ -0,0 +1,63 @@ +import { createBlockConfig } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; + +import { + parseDiagramCodeContent, + parseDiagramCodeElement, +} from "./helpers/parse/parseDiagramCodeElement.js"; +import { DiagramBlockPreviewWithPopup } from "./helpers/render/DiagramBlockPreviewWithPopup.js"; + +export const createDiagramBlockConfig = createBlockConfig( + () => + ({ + type: "diagram" as const, + // The block is semantically a diagram; Mermaid is its (only, for now) + // rendering engine. An `engine` prop with a "mermaid" default can be + // added later without breaking stored documents. + propSchema: {}, + content: "inline" as const, + }) as const, +); + +export type DiagramBlockConfig = ReturnType; + +export const createReactDiagramBlockSpec = createReactBlockSpec( + createDiagramBlockConfig, + { + meta: { + code: true, + defining: true, + isolating: false, + // Diagram source is Mermaid, so highlight it as such when the syntax + // highlighting extension is present. + // + // NOTE: this currently has no visible effect. Shiki's Mermaid grammar is + // a Markdown injection (`injectionSelector: "L:text.html.markdown"`) that + // only tokenizes inside a ```` ```mermaid ```` fence, so it produces no + // tokens for bare Mermaid source. See + // https://github.com/shikijs/shiki/issues/973 - once that's resolved + // upstream, highlighting should start working with no change here. + highlight: () => "mermaid", + // Diagram blocks always render a preview. Diagram sources span multiple + // lines, so Enter inserts a line break instead of closing the popup + // (`hardBreakShortcut: "enter"`). + hasPreview: true, + hardBreakShortcut: "enter", + }, + parse: parseDiagramCodeElement, + parseContent: parseDiagramCodeContent, + // The code block also parses `
` elements, so the diagram's
+    // rule must be tried first to claim the `language-mermaid` ones.
+    runsBefore: ["codeBlock"],
+    render: DiagramBlockPreviewWithPopup,
+    toExternalHTML: (props) => (
+      
+        
+      
+ ), + }, +); diff --git a/packages/diagram-block/src/block/helpers/index.ts b/packages/diagram-block/src/block/helpers/index.ts new file mode 100644 index 0000000000..b7d92c8b36 --- /dev/null +++ b/packages/diagram-block/src/block/helpers/index.ts @@ -0,0 +1,2 @@ +export * from "./parse/index.js"; +export * from "./render/index.js"; diff --git a/packages/diagram-block/src/block/helpers/parse/index.ts b/packages/diagram-block/src/block/helpers/parse/index.ts new file mode 100644 index 0000000000..1acfba2d07 --- /dev/null +++ b/packages/diagram-block/src/block/helpers/parse/index.ts @@ -0,0 +1 @@ +export * from "./parseDiagramCodeElement.js"; diff --git a/packages/diagram-block/src/block/helpers/parse/parseDiagramCodeElement.ts b/packages/diagram-block/src/block/helpers/parse/parseDiagramCodeElement.ts new file mode 100644 index 0000000000..ae30b454bd --- /dev/null +++ b/packages/diagram-block/src/block/helpers/parse/parseDiagramCodeElement.ts @@ -0,0 +1,30 @@ +import { parsePreCodeContent } from "@blocknote/core"; + +// Parses `
` elements - the fenced code
+// block representation used by Markdown & other editors - into diagram
+// blocks.
+export const parseDiagramCodeElement = (el: HTMLElement) => {
+  if (el.tagName !== "PRE") {
+    return undefined;
+  }
+
+  const code = el.firstElementChild;
+  if (el.childElementCount !== 1 || code?.tagName !== "CODE") {
+    return undefined;
+  }
+
+  const language =
+    code.getAttribute("data-language") ||
+    code.className
+      .split(" ")
+      .find((name) => name.startsWith("language-"))
+      ?.replace("language-", "");
+
+  return language === "mermaid" ? {} : undefined;
+};
+
+// Parses the code element's text as the diagram block's source, preserving
+// line breaks.
+export const parseDiagramCodeContent = (
+  options: Parameters[0],
+) => parsePreCodeContent(options, "diagram");
diff --git a/packages/diagram-block/src/block/helpers/render/DiagramBlockPreviewWithPopup.tsx b/packages/diagram-block/src/block/helpers/render/DiagramBlockPreviewWithPopup.tsx
new file mode 100644
index 0000000000..7532e76462
--- /dev/null
+++ b/packages/diagram-block/src/block/helpers/render/DiagramBlockPreviewWithPopup.tsx
@@ -0,0 +1,96 @@
+import {
+  PreviewPlaceholder,
+  ReactCustomBlockRenderProps,
+  SourceBlockWithPreview,
+} from "@blocknote/react";
+import mermaid from "mermaid";
+import { useEffect, useState } from "react";
+import { SiMermaid } from "react-icons/si";
+
+import { getDiagramPlainTextContent } from "../../../helpers/getDiagramPlainTextContent.js";
+import { initializeMermaid } from "../../../helpers/initializeMermaid.js";
+import { DiagramBlockConfig } from "../../createReactDiagramBlockSpec.js";
+
+// Each render call needs its own element ID.
+let mermaidElementId = 0;
+
+/**
+ * Renders the Mermaid source to an SVG string. The current diagram (or the
+ * last valid one, when the source has an error) stays up until the new one
+ * has fully rendered, and swapping inline SVG commits in a single frame - so
+ * the preview never flashes.
+ */
+export const useMermaidSVG = (source: string) => {
+  const [svg, setSVG] = useState("");
+  const [error, setError] = useState(undefined);
+
+  useEffect(() => {
+    if (!source.trim()) {
+      setSVG("");
+      setError(undefined);
+
+      return;
+    }
+
+    initializeMermaid();
+
+    // Rendering is asynchronous, so bail out if the source changed (or the
+    // block was removed) before it finished.
+    let stale = false;
+    void (async () => {
+      // The rendered SVG carries the given ID into the document, and Mermaid
+      // removes any existing element with that ID when rendering. So each
+      // render gets a fresh ID - reusing one makes Mermaid yank the displayed
+      // diagram out of the page mid-render.
+      const id = `mermaid-preview-${mermaidElementId++}`;
+      try {
+        await mermaid.parse(source);
+        const { svg } = await mermaid.render(id, source);
+        if (!stale) {
+          setSVG(svg);
+          setError(undefined);
+        }
+      } catch (err) {
+        if (!stale) {
+          setError(err instanceof Error ? err.message : String(err));
+        }
+      }
+    })();
+
+    return () => {
+      stale = true;
+    };
+  }, [source]);
+
+  return { svg, error };
+};
+export const DiagramBlockPreviewWithPopup = (
+  props: ReactCustomBlockRenderProps,
+) => {
+  const source = getDiagramPlainTextContent(props.block.content).trim();
+  const { svg, error } = useMermaidSVG(source);
+
+  return (
+    
+        ) : undefined
+      }
+      error={error}
+      emptySourcePlaceholder={
+        } text="Add a Mermaid diagram" />
+      }
+    />
+  );
+};
diff --git a/packages/diagram-block/src/block/helpers/render/index.ts b/packages/diagram-block/src/block/helpers/render/index.ts
new file mode 100644
index 0000000000..5ac7393214
--- /dev/null
+++ b/packages/diagram-block/src/block/helpers/render/index.ts
@@ -0,0 +1 @@
+export * from "./DiagramBlockPreviewWithPopup.js";
diff --git a/packages/diagram-block/src/block/index.ts b/packages/diagram-block/src/block/index.ts
new file mode 100644
index 0000000000..8d74f580a9
--- /dev/null
+++ b/packages/diagram-block/src/block/index.ts
@@ -0,0 +1,2 @@
+export * from "./createReactDiagramBlockSpec.js";
+export * from "./helpers/index.js";
diff --git a/packages/diagram-block/src/helpers/getDiagramPlainTextContent.ts b/packages/diagram-block/src/helpers/getDiagramPlainTextContent.ts
new file mode 100644
index 0000000000..d39f42cf30
--- /dev/null
+++ b/packages/diagram-block/src/helpers/getDiagramPlainTextContent.ts
@@ -0,0 +1,14 @@
+// Converts rich text content in diagram blocks to the plain text Mermaid
+// source. Should be removed once we add plain text support for blocks.
+export const getDiagramPlainTextContent = (content: unknown): string => {
+  if (!Array.isArray(content)) {
+    return "";
+  }
+
+  return content
+    .map((node) =>
+      node && typeof node === "object" && "text" in node ? node.text : "",
+    )
+    .join("");
+};
+// TODO: remove
diff --git a/packages/diagram-block/src/helpers/index.ts b/packages/diagram-block/src/helpers/index.ts
new file mode 100644
index 0000000000..1142d0c349
--- /dev/null
+++ b/packages/diagram-block/src/helpers/index.ts
@@ -0,0 +1,3 @@
+export * from "./getDiagramPlainTextContent.js";
+export * from "./initializeMermaid.js";
+export * from "./renderDiagramToImage.js";
diff --git a/packages/diagram-block/src/helpers/initializeMermaid.ts b/packages/diagram-block/src/helpers/initializeMermaid.ts
new file mode 100644
index 0000000000..11537104f7
--- /dev/null
+++ b/packages/diagram-block/src/helpers/initializeMermaid.ts
@@ -0,0 +1,18 @@
+import mermaid from "mermaid";
+
+// The diagrams are rendered manually whenever a block's source changes.
+let initialized = false;
+
+export const initializeMermaid = () => {
+  if (!initialized) {
+    initialized = true;
+    mermaid.initialize({
+      startOnLoad: false,
+      // On render errors, makes Mermaid throw right away - instead of
+      // rendering its own error graphic AND leaving its temporary render
+      // element behind in the document (it only cleans the element up with
+      // this option set). The block renders its own error UI.
+      suppressErrorRendering: true,
+    });
+  }
+};
diff --git a/packages/diagram-block/src/helpers/renderDiagramToImage.ts b/packages/diagram-block/src/helpers/renderDiagramToImage.ts
new file mode 100644
index 0000000000..b75aced1ef
--- /dev/null
+++ b/packages/diagram-block/src/helpers/renderDiagramToImage.ts
@@ -0,0 +1,59 @@
+import mermaid from "mermaid";
+
+import { initializeMermaid } from "./initializeMermaid.js";
+
+// Each render call needs its own element ID (Mermaid removes any existing
+// document element with the given ID when rendering).
+let exportElementId = 0;
+
+/**
+ * Renders the Mermaid source to a PNG image (as a data URL, with the
+ * diagram's natural dimensions in pixels), e.g. to embed diagrams as images
+ * when exporting documents to PDF/DOCX/ODT. Throws when the source is
+ * invalid. Browser-only - Mermaid can't render outside of it.
+ */
+export const renderDiagramToImage = async (
+  source: string,
+): Promise<{ dataURL: string; width: number; height: number }> => {
+  initializeMermaid();
+
+  await mermaid.parse(source);
+  const { svg } = await mermaid.render(
+    `diagram-export-${exportElementId++}`,
+    source,
+  );
+
+  // Mermaid sizes the SVG relatively (`width: 100%`), so it needs explicit
+  // pixel dimensions before rasterizing. These must be set on the SVG itself,
+  // not the `Image` element: the element's width/height only affect layout,
+  // while canvas rasterization uses the SVG's intrinsic size - without one,
+  // Safari falls back to a default size (and older Firefox refuses to draw
+  // the image to a canvas at all). The view box is also the only reliable
+  // source for the diagram's pixel dimensions, which the exporters need for
+  // layout.
+  const svgElement = new DOMParser().parseFromString(
+    svg,
+    "image/svg+xml",
+  ).documentElement;
+  const viewBox = svgElement.getAttribute("viewBox")?.split(/\s+/).map(Number);
+  const width = Math.ceil(viewBox?.[2] || 800);
+  const height = Math.ceil(viewBox?.[3] || 600);
+  // Sized at 2x so the diagram stays sharp in the exported document. The 2x
+  // goes into the SVG's dimensions (rather than only the canvas), so browsers
+  // rasterize the vector at the full canvas resolution instead of upscaling a
+  // 1x raster.
+  svgElement.setAttribute("width", String(width * 2));
+  svgElement.setAttribute("height", String(height * 2));
+  const sizedSVG = new XMLSerializer().serializeToString(svgElement);
+
+  const image = new Image();
+  image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(sizedSVG)}`;
+  await image.decode();
+
+  const canvas = document.createElement("canvas");
+  canvas.width = width * 2;
+  canvas.height = height * 2;
+  canvas.getContext("2d")!.drawImage(image, 0, 0, canvas.width, canvas.height);
+
+  return { dataURL: canvas.toDataURL("image/png"), width, height };
+};
diff --git a/packages/diagram-block/src/index.ts b/packages/diagram-block/src/index.ts
new file mode 100644
index 0000000000..dc4b7a9fa2
--- /dev/null
+++ b/packages/diagram-block/src/index.ts
@@ -0,0 +1,2 @@
+export * from "./block/index.js";
+export * from "./helpers/index.js";
diff --git a/packages/diagram-block/src/vite-env.d.ts b/packages/diagram-block/src/vite-env.d.ts
new file mode 100644
index 0000000000..bc2d8a36f3
--- /dev/null
+++ b/packages/diagram-block/src/vite-env.d.ts
@@ -0,0 +1 @@
+/// 
diff --git a/packages/diagram-block/tsconfig.json b/packages/diagram-block/tsconfig.json
new file mode 100644
index 0000000000..c74ac34642
--- /dev/null
+++ b/packages/diagram-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/diagram-block/vite.config.ts b/packages/diagram-block/vite.config.ts
new file mode 100644
index 0000000000..55e07044a5
--- /dev/null
+++ b/packages/diagram-block/vite.config.ts
@@ -0,0 +1,88 @@
+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" },
+            ],
+            output: ["dist/**", "!dist/*.tsbuildinfo"],
+          },
+        },
+      },
+      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-diagram-block": path.resolve(__dirname, "src/index.ts"),
+          },
+          name: "blocknote-diagram-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) => {
+            // Bundle react-icons into the output (tree-shaken) so consumers
+            // don't need to install it as a peer/runtime dependency.
+            const bundledDeps = ["react-icons"];
+            if (
+              bundledDeps.some(
+                (dep) => source === dep || source.startsWith(dep + "/"),
+              )
+            ) {
+              return false;
+            }
+            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/diagram-block/vitestSetup.ts b/packages/diagram-block/vitestSetup.ts
new file mode 100644
index 0000000000..dbcf3eb39c
--- /dev/null
+++ b/packages/diagram-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/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..c1802fcc1d
--- /dev/null
+++ b/packages/math-block/package.json
@@ -0,0 +1,84 @@
+{
+  "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": {
+    "@handlewithcare/prosemirror-inputrules": "^0.1.4",
+    "katex": "^0.16.11",
+    "mathml-to-latex": "^1.8.0",
+    "prosemirror-model": "^1.25.4",
+    "prosemirror-state": "^1.4.4",
+    "prosemirror-view": "^1.41.4"
+  },
+  "devDependencies": {
+    "@blocknote/react": "workspace:^",
+    "react-icons": "^5.5.0",
+    "@blocknote/xl-multi-column": "workspace:^",
+    "@types/katex": "^0.16.7",
+    "@types/react": "^19.2.3",
+    "@types/react-dom": "^19.2.3",
+    "react": "^19.2.5",
+    "react-dom": "^19.2.5",
+    "rimraf": "^5.0.10",
+    "rollup-plugin-webpack-stats": "^0.2.6",
+    "typescript": "^5.9.3",
+    "vite-plus": "catalog:"
+  },
+  "peerDependencies": {
+    "@blocknote/core": "workspace:^",
+    "@blocknote/react": "workspace:^",
+    "react": "^18.0 || ^19.0 || >= 19.0.0-rc",
+    "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc"
+  }
+}
diff --git a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx
new file mode 100644
index 0000000000..c694e5b7c6
--- /dev/null
+++ b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx
@@ -0,0 +1,356 @@
+import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core";
+import { BlockNoteViewRaw } from "@blocknote/react";
+import { flushSync } from "react-dom";
+import { createRoot, Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
+import { createReactMathBlockSpec } from "./createReactMathBlockSpec.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: createReactMathBlockSpec() },
+});
+
+describe("Math block source popup keyboard handling", () => {
+  let editor: BlockNoteEditor;
+  let div: HTMLDivElement;
+  let root: Root;
+
+  beforeEach(() => {
+    // jsdom doesn't implement `elementFromPoint`, which ProseMirror's mousedown
+    // handler calls to map coordinates to a document position. The click tests
+    // dispatch mouse events, so stub it out (returning `null` makes ProseMirror
+    // bail gracefully) to avoid an uncaught error.
+    if (!document.elementFromPoint) {
+      document.elementFromPoint = () => null;
+    }
+
+    // The keyboard handler listens on the editor DOM (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`.
+    div = document.createElement("div");
+    document.body.appendChild(div);
+
+    editor = BlockNoteEditor.create({ schema });
+
+    // Rendered the same way as the inline math test: a `BlockNoteViewRaw` into a
+    // div, so the React node view mounts as it does in production. (The React
+    // block renders via a `ReactNodeViewRenderer` portal, so `editor.mount`
+    // isn't enough to get the preview into the DOM.)
+    root = createRoot(div);
+    flushSync(() => {
+      root.render();
+    });
+  });
+
+  afterEach(() => {
+    root.unmount();
+    editor._tiptapEditor.destroy();
+    editor = undefined as any;
+    div.remove();
+  });
+
+  /** Yields to the event loop so store-driven React re-renders can flush. */
+  function flush() {
+    return new Promise((resolve) => setTimeout(resolve, 0));
+  }
+
+  /** Replaces the document and waits for the React node views to render. */
+  async function setup(blocks: any[]) {
+    editor.replaceBlocks(editor.document, blocks);
+    // The preview is rendered asynchronously by React, so wait for it before
+    // reading its DOM.
+    await flush();
+  }
+
+  /** 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 as if the caret were in the block's (possibly
+   * hidden) source. Returns whether the default was prevented.
+   *
+   * Dispatched on the ProseMirror DOM rather than the preview element:
+   * ProseMirror ignores keydowns originating from the `contentEditable=false`
+   * preview region, so its keymap (Enter/Escape/arrows) wouldn't fire there.
+   * The handlers key off the selection (set via `setTextCursorPosition`), not
+   * the event target, so this matches a real caret in the source. */
+  function pressKey(key: string, init: KeyboardEventInit = {}): boolean {
+    const event = new KeyboardEvent("keydown", {
+      key,
+      bubbles: true,
+      cancelable: true,
+      ...init,
+    });
+    editor.prosemirrorView!.dom.dispatchEvent(event);
+    return event.defaultPrevented;
+  }
+
+  describe("with adjacent paragraphs", () => {
+    beforeEach(async () => {
+      await 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", async () => {
+      expect(isPopupOpen("math")).toBe(false);
+
+      expect(pressKey("Enter")).toBe(true);
+      await flush();
+
+      expect(isPopupOpen("math")).toBe(true);
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
+    });
+
+    it("Enter again closes the source popup", async () => {
+      pressKey("Enter");
+      await flush();
+      expect(isPopupOpen("math")).toBe(true);
+
+      expect(pressKey("Enter")).toBe(true);
+      await flush();
+
+      expect(isPopupOpen("math")).toBe(false);
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
+    });
+
+    it("Enter commits without inserting a line break (single-line source)", async () => {
+      pressKey("Enter");
+      await flush();
+      expect(isPopupOpen("math")).toBe(true);
+
+      // The math source is single-line (no `hardBreakShortcut: "enter"`), so
+      // unlike the diagram block, Enter closes the popup rather than extending
+      // the source with a newline.
+      pressKey("Enter");
+      await flush();
+
+      expect(isPopupOpen("math")).toBe(false);
+      expect(editor.getBlock("math")!.content).toEqual([
+        { type: "text", text: "a^2", styles: {} },
+      ]);
+    });
+
+    it("Escape closes the source popup while editing", async () => {
+      pressKey("Enter");
+      await flush();
+      expect(isPopupOpen("math")).toBe(true);
+
+      expect(pressKey("Escape")).toBe(true);
+      await flush();
+
+      expect(isPopupOpen("math")).toBe(false);
+    });
+
+    it("Escape leaves an already-closed popup closed", async () => {
+      expect(isPopupOpen("math")).toBe(false);
+
+      // Defers to the default; our handler doesn't touch the popup state.
+      pressKey("Escape");
+      await flush();
+
+      expect(isPopupOpen("math")).toBe(false);
+    });
+
+    it("ArrowRight while the popup is hidden moves to the next block", () => {
+      expect(pressKey("ArrowRight")).toBe(true);
+
+      expect(editor.getTextCursorPosition().block.id).toBe("after");
+    });
+
+    it("ArrowLeft while the popup is hidden moves to the previous block", () => {
+      expect(pressKey("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("ArrowRight", { ctrlKey: true })).toBe(false);
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
+
+      expect(pressKey("ArrowRight", { metaKey: true })).toBe(false);
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
+    });
+
+    it("ArrowRight while editing defers to the default (navigates the source)", async () => {
+      pressKey("Enter");
+      await flush();
+      expect(isPopupOpen("math")).toBe(true);
+
+      // The arrow isn't hijacked: we stay in the math block with the popup open.
+      pressKey("ArrowRight");
+      await flush();
+
+      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("a")).toBe(true);
+    });
+
+    it("defers character input to the default while the popup is open", async () => {
+      pressKey("Enter");
+      await flush();
+      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("a")).toBe(false);
+    });
+
+    it("Backspace deletes the whole block while the popup is closed", () => {
+      expect(isPopupOpen("math")).toBe(false);
+
+      // The source is hidden, so Backspace removes the whole block rather than
+      // editing the source the user can't see.
+      expect(pressKey("Backspace")).toBe(true);
+      expect(editor.document.some((block) => block.id === "math")).toBe(false);
+    });
+
+    it("Delete deletes the whole block while the popup is closed", () => {
+      expect(isPopupOpen("math")).toBe(false);
+
+      expect(pressKey("Delete")).toBe(true);
+      expect(editor.document.some((block) => block.id === "math")).toBe(false);
+    });
+
+    it("blocks indent keys while the popup is closed", () => {
+      expect(isPopupOpen("math")).toBe(false);
+
+      // Tab edits the hidden source, so it's swallowed.
+      expect(pressKey("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("c", { ctrlKey: true })).toBe(false);
+      expect(pressKey("a", { ctrlKey: true })).toBe(false);
+      expect(pressKey("f", { ctrlKey: true })).toBe(false);
+      expect(pressKey("v", { metaKey: true })).toBe(false);
+    });
+
+    it("defers deletion keys to the default while the popup is open", async () => {
+      pressKey("Enter");
+      await flush();
+      expect(isPopupOpen("math")).toBe(true);
+
+      // The source is visible, so deletion is allowed through to ProseMirror.
+      expect(pressKey("Backspace")).toBe(false);
+    });
+  });
+
+  describe("at the document edges", () => {
+    it("ArrowLeft with no previous block defers to the default", async () => {
+      await 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("ArrowLeft");
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
+    });
+
+    it("ArrowRight with no next block defers to the default", async () => {
+      await 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("ArrowRight");
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
+    });
+  });
+
+  describe("clicking the preview", () => {
+    beforeEach(async () => {
+      await 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", async () => {
+      const preview = div.querySelector(
+        `.bn-block[data-id="math"] .bn-preview-container`,
+      ) as HTMLElement;
+
+      // The popup opens on the click; the mousedown is dispatched too for a
+      // realistic event sequence.
+      preview.dispatchEvent(
+        new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
+      );
+      preview.dispatchEvent(
+        new MouseEvent("click", { bubbles: true, cancelable: true }),
+      );
+      await flush();
+
+      expect(isPopupOpen("math")).toBe(true);
+      expect(editor.getTextCursorPosition().block.id).toBe("math");
+      // The cursor lands at the end of the source (after "a^2").
+      expect(editor.prosemirrorView!.state.selection.$from.parentOffset).toBe(
+        3,
+      );
+    });
+  });
+
+  describe("clicking the OK button", () => {
+    beforeEach(async () => {
+      await setup([
+        { id: "before", type: "paragraph", content: "before" },
+        { id: "math", type: "math", content: "a^2" },
+      ]);
+      editor.setTextCursorPosition("math", "start");
+      // Open the popup so the OK button has something to close.
+      pressKey("Enter");
+      await flush();
+      expect(isPopupOpen("math")).toBe(true);
+    });
+
+    it("closes the popup", async () => {
+      const okButton = div.querySelector(
+        `.bn-block[data-id="math"] .bn-code-block-source-popup-ok-button`,
+      ) as HTMLElement;
+
+      okButton.dispatchEvent(
+        new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
+      );
+      okButton.dispatchEvent(
+        new MouseEvent("click", { bubbles: true, cancelable: true }),
+      );
+      await flush();
+
+      expect(isPopupOpen("math")).toBe(false);
+    });
+  });
+});
diff --git a/packages/math-block/src/block/createReactMathBlockSpec.tsx b/packages/math-block/src/block/createReactMathBlockSpec.tsx
new file mode 100644
index 0000000000..a98b5629cf
--- /dev/null
+++ b/packages/math-block/src/block/createReactMathBlockSpec.tsx
@@ -0,0 +1,41 @@
+import { createBlockConfig } from "@blocknote/core";
+import { createReactBlockSpec } from "@blocknote/react";
+
+import { MathBlockInputRulesExtension } from "./helpers/extensions/MathBlockInputRulesExtension.js";
+import {
+  parseBlockMathMLElement,
+  parseBlockMathMLContent,
+} from "./helpers/parse/parseBlockMathMLElement.js";
+import { MathBlockPreviewWithPopup } from "./helpers/render/MathBlockPreviewWithPopup.js";
+import { BlockMathMLElement } from "./helpers/toExternalHTML/BlockMathMLElement.js";
+
+export const createMathBlockConfig = createBlockConfig(
+  () =>
+    ({
+      type: "math" as const,
+      propSchema: {},
+      content: "inline" as const,
+    }) as const,
+);
+
+export type MathBlockConfig = ReturnType;
+
+export const createReactMathBlockSpec = createReactBlockSpec(
+  createMathBlockConfig,
+  {
+    meta: {
+      code: true,
+      defining: true,
+      isolating: false,
+      highlight: () => "latex",
+      // Math blocks always render a preview (single-line source, so Enter
+      // commits/closes the popup - no `hardBreakShortcut` needed).
+      hasPreview: true,
+    },
+    parse: parseBlockMathMLElement,
+    parseContent: parseBlockMathMLContent,
+    render: MathBlockPreviewWithPopup,
+    toExternalHTML: BlockMathMLElement,
+  },
+  [MathBlockInputRulesExtension],
+);
diff --git a/packages/math-block/src/block/helpers/extensions/MathBlockInputRulesExtension.ts b/packages/math-block/src/block/helpers/extensions/MathBlockInputRulesExtension.ts
new file mode 100644
index 0000000000..1c80b4b09c
--- /dev/null
+++ b/packages/math-block/src/block/helpers/extensions/MathBlockInputRulesExtension.ts
@@ -0,0 +1,24 @@
+import { createExtension } from "@blocknote/core";
+
+/**
+ * Converts the current block into a math block when a LaTeX display-math
+ * delimiter is typed at its start:
+ * - `$$ ` (TeX display math)
+ * - `\[ ` (LaTeX display math)
+ *
+ * The matched delimiter is removed and the block is replaced with an empty
+ * math block, ready for the LaTeX source to be typed in.
+ */
+export const MathBlockInputRulesExtension = createExtension({
+  key: "math-block-input-rules",
+  inputRules: [
+    {
+      find: /^\$\$\s$/,
+      replace: () => ({ type: "math", props: {}, content: [] }),
+    },
+    {
+      find: /^\\\[\s$/,
+      replace: () => ({ type: "math", props: {}, content: [] }),
+    },
+  ],
+});
diff --git a/packages/math-block/src/block/helpers/extensions/index.ts b/packages/math-block/src/block/helpers/extensions/index.ts
new file mode 100644
index 0000000000..84c9f71ba3
--- /dev/null
+++ b/packages/math-block/src/block/helpers/extensions/index.ts
@@ -0,0 +1 @@
+export * from "./MathBlockInputRulesExtension.js";
diff --git a/packages/math-block/src/block/helpers/index.ts b/packages/math-block/src/block/helpers/index.ts
new file mode 100644
index 0000000000..03c47b6db5
--- /dev/null
+++ b/packages/math-block/src/block/helpers/index.ts
@@ -0,0 +1,4 @@
+export * from "./extensions/index.js";
+export * from "./parse/index.js";
+export * from "./render/index.js";
+export * from "./toExternalHTML/index.js";
diff --git a/packages/math-block/src/block/helpers/parse/index.ts b/packages/math-block/src/block/helpers/parse/index.ts
new file mode 100644
index 0000000000..257168fb3e
--- /dev/null
+++ b/packages/math-block/src/block/helpers/parse/index.ts
@@ -0,0 +1 @@
+export * from "./parseBlockMathMLElement.js";
diff --git a/packages/math-block/src/block/helpers/parse/parseBlockMathMLElement.ts b/packages/math-block/src/block/helpers/parse/parseBlockMathMLElement.ts
new file mode 100644
index 0000000000..26cc58780e
--- /dev/null
+++ b/packages/math-block/src/block/helpers/parse/parseBlockMathMLElement.ts
@@ -0,0 +1,38 @@
+import { MathMLToLaTeX } from "mathml-to-latex";
+import { Fragment, type Schema } from "prosemirror-model";
+
+export const parseBlockMathMLElement = (el: HTMLElement) =>
+  el.nodeName.toLowerCase() === "math" && el.getAttribute("display") === "block"
+    ? {}
+    : undefined;
+
+export const parseBlockMathMLContent = ({
+  el,
+  schema,
+}: {
+  el: HTMLElement;
+  schema: Schema;
+}) => {
+  const annotations = Array.from(el.getElementsByTagName("annotation"));
+  const texAnnotation = annotations.find(
+    (annotation) => annotation.getAttribute("encoding") === "application/x-tex",
+  );
+
+  // Prioritize getting source from annotation (guaranteed lossless), else parse
+  // MathML elements to LaTeX.
+  let latex: string | undefined;
+  if (texAnnotation?.textContent) {
+    latex = texAnnotation.textContent.trim();
+  } else {
+    try {
+      latex = MathMLToLaTeX.convert(el.outerHTML).trim();
+    } catch {}
+  }
+
+  // Fall through to default parsing if we couldn't derive the source.
+  if (!latex) {
+    return undefined;
+  }
+
+  return Fragment.from(schema.text(latex));
+};
diff --git a/packages/math-block/src/block/helpers/render/MathBlockPreviewWithPopup.tsx b/packages/math-block/src/block/helpers/render/MathBlockPreviewWithPopup.tsx
new file mode 100644
index 0000000000..0019a859d5
--- /dev/null
+++ b/packages/math-block/src/block/helpers/render/MathBlockPreviewWithPopup.tsx
@@ -0,0 +1,40 @@
+import {
+  PreviewPlaceholder,
+  ReactCustomBlockRenderProps,
+  SourceBlockWithPreview,
+} from "@blocknote/react";
+import { TbMathFunction } from "react-icons/tb";
+
+import { MathBlockConfig } from "../../createReactMathBlockSpec.js";
+import { getMathPlainTextContent } from "../../../helpers/getMathPlainTextContent.js";
+import { useLatexToMathMLString } from "../../../helpers/render/useLatexToMathML.js";
+
+export const MathBlockPreviewWithPopup = (
+  props: ReactCustomBlockRenderProps,
+) => {
+  const source = getMathPlainTextContent(props.block.content).trim();
+  const { mathMLString, error } = useLatexToMathMLString(source);
+
+  return (
+    
+        ) : undefined
+      }
+      error={error}
+      emptySourcePlaceholder={
+        }
+          text={props.editor.dictionary.code_block.add_source_button_text}
+        />
+      }
+    />
+  );
+};
diff --git a/packages/math-block/src/block/helpers/render/index.ts b/packages/math-block/src/block/helpers/render/index.ts
new file mode 100644
index 0000000000..9ace70ae91
--- /dev/null
+++ b/packages/math-block/src/block/helpers/render/index.ts
@@ -0,0 +1 @@
+export * from "./MathBlockPreviewWithPopup.js";
diff --git a/packages/math-block/src/block/helpers/toExternalHTML/BlockMathMLElement.tsx b/packages/math-block/src/block/helpers/toExternalHTML/BlockMathMLElement.tsx
new file mode 100644
index 0000000000..636a9084ea
--- /dev/null
+++ b/packages/math-block/src/block/helpers/toExternalHTML/BlockMathMLElement.tsx
@@ -0,0 +1,33 @@
+import { ReactCustomBlockRenderProps } from "@blocknote/react";
+import type { ComponentType } from "react";
+
+import { MathBlockConfig } from "../../createReactMathBlockSpec.js";
+import { getMathPlainTextContent } from "../../../helpers/getMathPlainTextContent.js";
+import { latexToMathMLElement } from "../../../helpers/toExternalHTML/latexToMathMLElement.js";
+
+export const BlockMathMLElement = ({
+  block,
+}: ReactCustomBlockRenderProps) => {
+  const { mathMLElement } = latexToMathMLElement(
+    getMathPlainTextContent(block.content),
+  );
+  if (!mathMLElement) {
+    return null;
+  }
+
+  // `math` isn't part of React's built-in JSX types, so we alias it to a
+  // component type to render it as a JSX element.
+  const Math = "math" as unknown as ComponentType<{
+    xmlns: string;
+    display: string;
+    dangerouslySetInnerHTML: { __html: string };
+  }>;
+
+  return (
+    
+  );
+};
diff --git a/packages/math-block/src/block/helpers/toExternalHTML/index.ts b/packages/math-block/src/block/helpers/toExternalHTML/index.ts
new file mode 100644
index 0000000000..c7f50ed3fd
--- /dev/null
+++ b/packages/math-block/src/block/helpers/toExternalHTML/index.ts
@@ -0,0 +1 @@
+export * from "./BlockMathMLElement.js";
diff --git a/packages/math-block/src/block/index.ts b/packages/math-block/src/block/index.ts
new file mode 100644
index 0000000000..58d9578d73
--- /dev/null
+++ b/packages/math-block/src/block/index.ts
@@ -0,0 +1,2 @@
+export * from "./createReactMathBlockSpec.js";
+export * from "./helpers/index.js";
diff --git a/packages/math-block/src/helpers/getMathPlainTextContent.ts b/packages/math-block/src/helpers/getMathPlainTextContent.ts
new file mode 100644
index 0000000000..fe1f558fce
--- /dev/null
+++ b/packages/math-block/src/helpers/getMathPlainTextContent.ts
@@ -0,0 +1,16 @@
+// Converts rich text content in math blocks/inline content to plain text.
+// Should be removed once we add plain text support for blocks/inline content
+export const getMathPlainTextContent = (content: unknown): string => {
+  if (typeof content === "string") {
+    return content;
+  }
+
+  if (Array.isArray(content)) {
+    return content
+      .map((node) =>
+        node && typeof node === "object" && "text" in node ? node.text : "",
+      )
+      .join("");
+  }
+  return "";
+};
diff --git a/packages/math-block/src/helpers/index.ts b/packages/math-block/src/helpers/index.ts
new file mode 100644
index 0000000000..c39f400196
--- /dev/null
+++ b/packages/math-block/src/helpers/index.ts
@@ -0,0 +1,4 @@
+export * from "./getMathPlainTextContent.js";
+export * from "./latexToHTMLString.js";
+export * from "./render/index.js";
+export * from "./toExternalHTML/index.js";
diff --git a/packages/math-block/src/helpers/latexToHTMLString.ts b/packages/math-block/src/helpers/latexToHTMLString.ts
new file mode 100644
index 0000000000..c87575d51d
--- /dev/null
+++ b/packages/math-block/src/helpers/latexToHTMLString.ts
@@ -0,0 +1,28 @@
+import katex from "katex";
+import "katex/dist/katex.min.css";
+
+export const latexToHTMLString = (
+  latex: string,
+  inline = false,
+  external = false,
+) => {
+  try {
+    return {
+      htmlString: katex.renderToString(latex, {
+        throwOnError: true,
+        displayMode: !inline,
+        output: external ? "mathml" : "htmlAndMathml",
+      }),
+      error: undefined,
+    };
+  } catch (error) {
+    return {
+      htmlString: katex.renderToString(latex, {
+        throwOnError: false,
+        displayMode: !inline,
+        output: external ? "mathml" : "htmlAndMathml",
+      }),
+      error: error instanceof Error ? error.message : String(error),
+    };
+  }
+};
diff --git a/packages/math-block/src/helpers/render/index.ts b/packages/math-block/src/helpers/render/index.ts
new file mode 100644
index 0000000000..ff76ef360b
--- /dev/null
+++ b/packages/math-block/src/helpers/render/index.ts
@@ -0,0 +1 @@
+export * from "./useLatexToMathML.js";
diff --git a/packages/math-block/src/helpers/render/useLatexToMathML.ts b/packages/math-block/src/helpers/render/useLatexToMathML.ts
new file mode 100644
index 0000000000..3fd97a7496
--- /dev/null
+++ b/packages/math-block/src/helpers/render/useLatexToMathML.ts
@@ -0,0 +1,14 @@
+import { useRef } from "react";
+
+import { latexToHTMLString } from "../latexToHTMLString.js";
+
+export const useLatexToMathMLString = (latex: string, inline = false) => {
+  const lastValidMathMLStringRef = useRef("");
+
+  const { htmlString: mathMLString, error } = latexToHTMLString(latex, inline);
+  if (!error || lastValidMathMLStringRef.current === "") {
+    lastValidMathMLStringRef.current = mathMLString;
+  }
+
+  return { mathMLString: lastValidMathMLStringRef.current, error };
+};
diff --git a/packages/math-block/src/helpers/toExternalHTML/index.ts b/packages/math-block/src/helpers/toExternalHTML/index.ts
new file mode 100644
index 0000000000..da198d5f83
--- /dev/null
+++ b/packages/math-block/src/helpers/toExternalHTML/index.ts
@@ -0,0 +1 @@
+export * from "./latexToMathMLElement.js";
diff --git a/packages/math-block/src/helpers/toExternalHTML/latexToMathMLElement.ts b/packages/math-block/src/helpers/toExternalHTML/latexToMathMLElement.ts
new file mode 100644
index 0000000000..0b72fe2159
--- /dev/null
+++ b/packages/math-block/src/helpers/toExternalHTML/latexToMathMLElement.ts
@@ -0,0 +1,16 @@
+import { latexToHTMLString } from "../latexToHTMLString.js";
+
+export const latexToMathMLElement = (latex: string, inline = false) => {
+  const { htmlString: mathMLString, error } = latexToHTMLString(
+    latex,
+    inline,
+    true,
+  );
+
+  // Katex wraps the `math` element in a `span`, which we don't need.
+  const wrapper = document.createElement("div");
+  wrapper.innerHTML = mathMLString;
+  const mathMLElement = wrapper.querySelector("math") as MathMLElement;
+
+  return { mathMLElement, error };
+};
diff --git a/packages/math-block/src/index.ts b/packages/math-block/src/index.ts
new file mode 100644
index 0000000000..eac554f7a7
--- /dev/null
+++ b/packages/math-block/src/index.ts
@@ -0,0 +1,3 @@
+export * from "./block/index.js";
+export * from "./inlineContent/index.js";
+export * from "./helpers/index.js";
diff --git a/packages/math-block/src/inlineContent/createReactMathInlineContentSpec.test.tsx b/packages/math-block/src/inlineContent/createReactMathInlineContentSpec.test.tsx
new file mode 100644
index 0000000000..e840d238e5
--- /dev/null
+++ b/packages/math-block/src/inlineContent/createReactMathInlineContentSpec.test.tsx
@@ -0,0 +1,265 @@
+import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core";
+import { BlockNoteViewRaw } from "@blocknote/react";
+import { Node } from "prosemirror-model";
+import { TextSelection } from "prosemirror-state";
+import { flushSync } from "react-dom";
+import { createRoot, Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
+import { createReactInlineMathSpec } from "./createReactMathInlineContentSpec.js";
+
+// TODO: migrate to react, and depreacte jsdom (use vitest browser test?)
+/**
+ * @vitest-environment jsdom
+ */
+
+// Inline math isn't default inline content, so register it in a custom schema.
+const schema = BlockNoteSchema.create().extend({
+  inlineContentSpecs: { inlineMath: createReactInlineMathSpec() },
+});
+
+describe.skip("Inline math source popup", () => {
+  let editor: BlockNoteEditor;
+  let div: HTMLDivElement;
+  let root: Root;
+
+  beforeEach(async () => {
+    // Rendered the same way as `setupTestEditor`: a `BlockNoteViewRaw` into a
+    // detached div, so the React node view mounts as it does in production.
+    div = document.createElement("div");
+
+    editor = BlockNoteEditor.create({
+      schema,
+      trailingBlock: false,
+      initialContent: [
+        {
+          id: "para",
+          type: "paragraph",
+          content: [
+            "before ",
+            { type: "inlineMath", content: "a^2" },
+            " after",
+          ],
+        },
+      ],
+    });
+
+    root = createRoot(div);
+    flushSync(() => {
+      root.render();
+    });
+    // Let the React node view render before assertions read its DOM.
+    await flush();
+  });
+
+  afterEach(() => {
+    root.unmount();
+    editor._tiptapEditor.destroy();
+    editor = undefined as any;
+  });
+
+  /** Yields to the event loop so store-driven React re-renders can flush. */
+  function flush() {
+    return new Promise((resolve) => setTimeout(resolve, 0));
+  }
+
+  /** The inline math node and its position in the document. */
+  function inlineMath(): { node: Node; pos: number } {
+    let result: { node: Node; pos: number } | undefined;
+    editor.prosemirrorState.doc.descendants((node, pos) => {
+      if (node.type.name === "inlineMath") {
+        result = { node, pos };
+        return false;
+      }
+      return true;
+    });
+    return result!;
+  }
+
+  /** The preview-with-source-popup root, which holds `data-open`. */
+  function previewRoot(): HTMLElement {
+    return div.querySelector(".bn-preview-with-source-popup") as HTMLElement;
+  }
+
+  /**
+   * Whether the source popup is open. Unlike the block, there's no toggle flag:
+   * the popup is open exactly when the selection sits inside the inline math.
+   */
+  function isPopupOpen(): boolean {
+    return previewRoot()?.getAttribute("data-open") === "true";
+  }
+
+  /** Places the selection at the given document position. */
+  function setSelection(pos: number) {
+    const view = editor.prosemirrorView!;
+    view.dispatch(
+      view.state.tr.setSelection(TextSelection.create(view.state.doc, pos)),
+    );
+  }
+
+  /** Moves the caret to the end of the inline math source (opening the popup). */
+  function selectSource() {
+    const { node, pos } = inlineMath();
+    setSelection(pos + node.nodeSize - 1);
+  }
+
+  /** Dispatches a keydown on the editor DOM, running the ProseMirror keymap. */
+  function pressKey(key: string, init: KeyboardEventInit = {}): boolean {
+    const event = new KeyboardEvent("keydown", {
+      key,
+      bubbles: true,
+      cancelable: true,
+      ...init,
+    });
+    editor.prosemirrorView!.dom.dispatchEvent(event);
+    return event.defaultPrevented;
+  }
+
+  /** The name of the node the selection currently sits in. */
+  function selectedNodeType(): string {
+    return editor.prosemirrorState.selection.$from.node().type.name;
+  }
+
+  it("renders a MathML preview for the inline math source", () => {
+    // The source is non-empty, so the preview shows the rendered formula rather
+    // than the "add source" placeholder button.
+    expect(previewRoot()).not.toBeNull();
+    expect(previewRoot().querySelector("math")).not.toBeNull();
+  });
+
+  it("keeps the popup closed while the cursor is outside the inline math", () => {
+    editor.setTextCursorPosition("para", "start");
+
+    expect(isPopupOpen()).toBe(false);
+  });
+
+  it("opens the popup when the cursor moves into the source", async () => {
+    expect(isPopupOpen()).toBe(false);
+
+    selectSource();
+    await flush();
+
+    expect(isPopupOpen()).toBe(true);
+    // The whole inline content is highlighted while its source is being edited.
+    expect(previewRoot().classList.contains("ProseMirror-selectednode")).toBe(
+      true,
+    );
+  });
+
+  it("closes the popup when the cursor moves back out", async () => {
+    selectSource();
+    await flush();
+    expect(isPopupOpen()).toBe(true);
+
+    // Move the caret into the trailing paragraph text, outside the inline math.
+    editor.setTextCursorPosition("para", "end");
+    await flush();
+
+    expect(isPopupOpen()).toBe(false);
+  });
+
+  describe("committing the source", () => {
+    beforeEach(async () => {
+      selectSource();
+      await flush();
+      expect(isPopupOpen()).toBe(true);
+    });
+
+    it("Enter moves the selection out of the source and closes the popup", async () => {
+      pressKey("Enter");
+      await flush();
+
+      expect(isPopupOpen()).toBe(false);
+      // The caret lands just after the inline math, back in the paragraph.
+      expect(selectedNodeType()).not.toBe("inlineMath");
+    });
+
+    it("Escape moves the selection out of the source and closes the popup", async () => {
+      pressKey("Escape");
+      await flush();
+
+      expect(isPopupOpen()).toBe(false);
+      expect(selectedNodeType()).not.toBe("inlineMath");
+    });
+
+    it("ArrowUp moves the selection just before the inline content", async () => {
+      const { pos } = inlineMath();
+
+      pressKey("ArrowUp");
+      await flush();
+
+      expect(isPopupOpen()).toBe(false);
+      expect(selectedNodeType()).not.toBe("inlineMath");
+      // The caret lands just before the inline math (at its start position).
+      expect(editor.prosemirrorState.selection.from).toBe(pos);
+    });
+
+    it("ArrowDown moves the selection just after the inline content", async () => {
+      const { node, pos } = inlineMath();
+
+      pressKey("ArrowDown");
+      await flush();
+
+      expect(isPopupOpen()).toBe(false);
+      expect(selectedNodeType()).not.toBe("inlineMath");
+      // The caret lands just after the inline math.
+      expect(editor.prosemirrorState.selection.from).toBe(pos + node.nodeSize);
+    });
+  });
+
+  describe("clicking the preview", () => {
+    beforeEach(() => {
+      editor.setTextCursorPosition("para", "start");
+    });
+
+    it("opens the popup and places the cursor at the source end", async () => {
+      const container = div.querySelector(
+        ".bn-preview-container",
+      ) as HTMLElement;
+
+      // The popup opens on the click; the mousedown is dispatched too for a
+      // realistic event sequence.
+      container.dispatchEvent(
+        new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
+      );
+      container.dispatchEvent(
+        new MouseEvent("click", { bubbles: true, cancelable: true }),
+      );
+      await flush();
+
+      expect(isPopupOpen()).toBe(true);
+      expect(selectedNodeType()).toBe("inlineMath");
+      // The cursor lands at the end of the source (after "a^2").
+      expect(editor.prosemirrorState.selection.$from.parentOffset).toBe(3);
+    });
+  });
+
+  describe("clicking the OK button", () => {
+    beforeEach(async () => {
+      // Open the popup so the OK button has something to close.
+      selectSource();
+      await flush();
+      expect(isPopupOpen()).toBe(true);
+    });
+
+    it("closes the popup and moves the selection just after the inline content", async () => {
+      const { node, pos } = inlineMath();
+
+      const okButton = div.querySelector(
+        ".bn-code-block-source-popup-ok-button",
+      ) as HTMLElement;
+
+      okButton.dispatchEvent(
+        new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
+      );
+      okButton.dispatchEvent(
+        new MouseEvent("click", { bubbles: true, cancelable: true }),
+      );
+      await flush();
+
+      expect(isPopupOpen()).toBe(false);
+      expect(selectedNodeType()).not.toBe("inlineMath");
+      // The caret lands just after the inline math.
+      expect(editor.prosemirrorState.selection.from).toBe(pos + node.nodeSize);
+    });
+  });
+});
diff --git a/packages/math-block/src/inlineContent/createReactMathInlineContentSpec.tsx b/packages/math-block/src/inlineContent/createReactMathInlineContentSpec.tsx
new file mode 100644
index 0000000000..c81a8afcbe
--- /dev/null
+++ b/packages/math-block/src/inlineContent/createReactMathInlineContentSpec.tsx
@@ -0,0 +1,36 @@
+import { CustomInlineContentConfig } from "@blocknote/core";
+import { createReactInlineContentSpec } from "@blocknote/react";
+
+import { MathInlineInputRulesExtension } from "./helpers/extensions/MathInlineInputRulesExtension.js";
+import {
+  parseInlineMathMLContent,
+  parseInlineMathMLElement,
+} from "./helpers/parse/parseInlineMathMLElement.js";
+import { MathInlinePreviewWithPopup } from "./helpers/render/MathInlinePreviewWithPopup.js";
+import { InlineMathMLElement } from "./helpers/toExternalHTML/InlineMathMLElement.js";
+
+export const mathInlineContentConfig = {
+  type: "inlineMath" as const,
+  propSchema: {},
+  content: "styled" as const,
+} satisfies CustomInlineContentConfig;
+
+export type MathInlineContentConfig = typeof mathInlineContentConfig;
+
+export const createReactInlineMathSpec = () =>
+  createReactInlineContentSpec(
+    mathInlineContentConfig,
+    {
+      meta: {
+        code: true,
+        highlight: () => "latex",
+        // Inline math always renders a preview with an editable source popup.
+        hasPreview: true,
+      },
+      parse: parseInlineMathMLElement,
+      parseContent: parseInlineMathMLContent,
+      render: MathInlinePreviewWithPopup,
+      toExternalHTML: InlineMathMLElement,
+    },
+    [MathInlineInputRulesExtension],
+  );
diff --git a/packages/math-block/src/inlineContent/helpers/extensions/MathInlineInputRulesExtension.ts b/packages/math-block/src/inlineContent/helpers/extensions/MathInlineInputRulesExtension.ts
new file mode 100644
index 0000000000..c93f200549
--- /dev/null
+++ b/packages/math-block/src/inlineContent/helpers/extensions/MathInlineInputRulesExtension.ts
@@ -0,0 +1,42 @@
+import { createExtension } from "@blocknote/core";
+import {
+  InputRule,
+  inputRules as inputRulesPlugin,
+} from "@handlewithcare/prosemirror-inputrules";
+
+import { mathInlineContentConfig } from "../../createReactMathInlineContentSpec.js";
+
+/**
+ * Converts text wrapped in LaTeX inline-math delimiters into inline math
+ * content as it's typed:
+ * - `$...$` (TeX inline math)
+ * - `\(...\)` (LaTeX inline math)
+ *
+ * The delimiters are removed and the enclosed source becomes the inline math's
+ * content.
+ */
+export const MathInlineInputRulesExtension = createExtension({
+  key: "math-inline-input-rules",
+  // Cannot use the `inputRules` field as it only allows for converting matched
+  // content to blocks.
+  prosemirrorPlugins: [
+    inputRulesPlugin({
+      rules: [/\$([^$]+)\$$/, /\\\((.+?)\\\)$/].map(
+        (find) =>
+          new InputRule(find, (state, match, start, end) => {
+            const source = match[1]?.trim();
+            const nodeType = state.schema.nodes[mathInlineContentConfig.type];
+            if (!source || !nodeType) {
+              return null;
+            }
+
+            return state.tr.replaceRangeWith(
+              start,
+              end,
+              nodeType.create(null, state.schema.text(source)),
+            );
+          }),
+      ),
+    }),
+  ],
+});
diff --git a/packages/math-block/src/inlineContent/helpers/extensions/index.ts b/packages/math-block/src/inlineContent/helpers/extensions/index.ts
new file mode 100644
index 0000000000..cc91f3d80f
--- /dev/null
+++ b/packages/math-block/src/inlineContent/helpers/extensions/index.ts
@@ -0,0 +1 @@
+export * from "./MathInlineInputRulesExtension.js";
diff --git a/packages/math-block/src/inlineContent/helpers/index.ts b/packages/math-block/src/inlineContent/helpers/index.ts
new file mode 100644
index 0000000000..03c47b6db5
--- /dev/null
+++ b/packages/math-block/src/inlineContent/helpers/index.ts
@@ -0,0 +1,4 @@
+export * from "./extensions/index.js";
+export * from "./parse/index.js";
+export * from "./render/index.js";
+export * from "./toExternalHTML/index.js";
diff --git a/packages/math-block/src/inlineContent/helpers/parse/index.ts b/packages/math-block/src/inlineContent/helpers/parse/index.ts
new file mode 100644
index 0000000000..0dc4eea7c7
--- /dev/null
+++ b/packages/math-block/src/inlineContent/helpers/parse/index.ts
@@ -0,0 +1 @@
+export * from "./parseInlineMathMLElement.js";
diff --git a/packages/math-block/src/inlineContent/helpers/parse/parseInlineMathMLElement.ts b/packages/math-block/src/inlineContent/helpers/parse/parseInlineMathMLElement.ts
new file mode 100644
index 0000000000..716d92adba
--- /dev/null
+++ b/packages/math-block/src/inlineContent/helpers/parse/parseInlineMathMLElement.ts
@@ -0,0 +1,39 @@
+import { MathMLToLaTeX } from "mathml-to-latex";
+import { Fragment, type Schema } from "prosemirror-model";
+
+export const parseInlineMathMLElement = (el: HTMLElement) =>
+  el.nodeName.toLowerCase() === "math" &&
+  el.getAttribute("display") === "inline"
+    ? {}
+    : undefined;
+
+export const parseInlineMathMLContent = ({
+  el,
+  schema,
+}: {
+  el: HTMLElement;
+  schema: Schema;
+}) => {
+  const annotations = Array.from(el.getElementsByTagName("annotation"));
+  const texAnnotation = annotations.find(
+    (annotation) => annotation.getAttribute("encoding") === "application/x-tex",
+  );
+
+  // Prioritize getting source from annotation (guaranteed lossless), else parse
+  // MathML elements to LaTeX.
+  let latex: string | undefined;
+  if (texAnnotation?.textContent) {
+    latex = texAnnotation.textContent.trim();
+  } else {
+    try {
+      latex = MathMLToLaTeX.convert(el.outerHTML).trim();
+    } catch {}
+  }
+
+  // Fall through to default parsing if we couldn't derive the source.
+  if (!latex) {
+    return undefined;
+  }
+
+  return Fragment.from(schema.text(latex));
+};
diff --git a/packages/math-block/src/inlineContent/helpers/render/MathInlinePreviewWithPopup.tsx b/packages/math-block/src/inlineContent/helpers/render/MathInlinePreviewWithPopup.tsx
new file mode 100644
index 0000000000..a90c77d3c0
--- /dev/null
+++ b/packages/math-block/src/inlineContent/helpers/render/MathInlinePreviewWithPopup.tsx
@@ -0,0 +1,45 @@
+import { StyleSchema } from "@blocknote/core";
+import {
+  PreviewPlaceholder,
+  ReactCustomInlineContentRenderProps,
+  SourceInlineContentWithPreview,
+} from "@blocknote/react";
+import { TbMathFunction } from "react-icons/tb";
+
+import { getMathPlainTextContent } from "../../../helpers/getMathPlainTextContent.js";
+import { useLatexToMathMLString } from "../../../helpers/render/useLatexToMathML.js";
+import { MathInlineContentConfig } from "../../createReactMathInlineContentSpec.js";
+
+export const MathInlinePreviewWithPopup = (
+  props: ReactCustomInlineContentRenderProps<
+    MathInlineContentConfig,
+    StyleSchema
+  >,
+) => {
+  const source = getMathPlainTextContent(props.inlineContent.content).trim();
+  const { mathMLString, error } = useLatexToMathMLString(source, true);
+
+  return (
+    
+        ) : undefined
+      }
+      error={error}
+      emptySourcePlaceholder={
+        }
+          text={props.editor.dictionary.code_block.add_source_button_text}
+        />
+      }
+    />
+  );
+};
diff --git a/packages/math-block/src/inlineContent/helpers/render/index.ts b/packages/math-block/src/inlineContent/helpers/render/index.ts
new file mode 100644
index 0000000000..36d99003c7
--- /dev/null
+++ b/packages/math-block/src/inlineContent/helpers/render/index.ts
@@ -0,0 +1 @@
+export * from "./MathInlinePreviewWithPopup.js";
diff --git a/packages/math-block/src/inlineContent/helpers/toExternalHTML/InlineMathMLElement.tsx b/packages/math-block/src/inlineContent/helpers/toExternalHTML/InlineMathMLElement.tsx
new file mode 100644
index 0000000000..c1289d6826
--- /dev/null
+++ b/packages/math-block/src/inlineContent/helpers/toExternalHTML/InlineMathMLElement.tsx
@@ -0,0 +1,38 @@
+import { StyleSchema } from "@blocknote/core";
+import { ReactCustomInlineContentRenderProps } from "@blocknote/react";
+import type { ComponentType } from "react";
+
+import { MathInlineContentConfig } from "../../createReactMathInlineContentSpec.js";
+import { getMathPlainTextContent } from "../../../helpers/getMathPlainTextContent.js";
+import { latexToMathMLElement } from "../../../helpers/toExternalHTML/latexToMathMLElement.js";
+
+export const InlineMathMLElement = ({
+  inlineContent,
+}: ReactCustomInlineContentRenderProps<
+  MathInlineContentConfig,
+  StyleSchema
+>) => {
+  const { mathMLElement } = latexToMathMLElement(
+    getMathPlainTextContent(inlineContent.content),
+    true,
+  );
+  if (!mathMLElement) {
+    return null;
+  }
+
+  // `math` isn't part of React's built-in JSX types, so we alias it to a
+  // component type to render it as a JSX element.
+  const Math = "math" as unknown as ComponentType<{
+    xmlns: string;
+    display: string;
+    dangerouslySetInnerHTML: { __html: string };
+  }>;
+
+  return (
+    
+  );
+};
diff --git a/packages/math-block/src/inlineContent/helpers/toExternalHTML/index.ts b/packages/math-block/src/inlineContent/helpers/toExternalHTML/index.ts
new file mode 100644
index 0000000000..91edfdaa0d
--- /dev/null
+++ b/packages/math-block/src/inlineContent/helpers/toExternalHTML/index.ts
@@ -0,0 +1 @@
+export * from "./InlineMathMLElement.js";
diff --git a/packages/math-block/src/inlineContent/index.ts b/packages/math-block/src/inlineContent/index.ts
new file mode 100644
index 0000000000..a3c09ffd17
--- /dev/null
+++ b/packages/math-block/src/inlineContent/index.ts
@@ -0,0 +1,2 @@
+export * from "./createReactMathInlineContentSpec.js";
+export * from "./helpers/index.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..3d0202618d
--- /dev/null
+++ b/packages/math-block/vite.config.ts
@@ -0,0 +1,88 @@
+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" },
+            ],
+            output: ["dist/**", "!dist/*.tsbuildinfo"],
+          },
+        },
+      },
+      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) => {
+            // Bundle react-icons into the output (tree-shaken) so consumers
+            // don't need to install it as a peer/runtime dependency.
+            const bundledDeps = ["react-icons"];
+            if (
+              bundledDeps.some(
+                (dep) => source === dep || source.startsWith(dep + "/"),
+              )
+            ) {
+              return false;
+            }
+            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/packages/react/src/blocks/SourceWithPreview/PreviewPlaceholder.tsx b/packages/react/src/blocks/SourceWithPreview/PreviewPlaceholder.tsx
new file mode 100644
index 0000000000..7a2c8b1625
--- /dev/null
+++ b/packages/react/src/blocks/SourceWithPreview/PreviewPlaceholder.tsx
@@ -0,0 +1,31 @@
+import { ReactNode } from "react";
+import { FaCode } from "react-icons/fa";
+import { MdErrorOutline } from "react-icons/md";
+
+/**
+ * Shown in place of the preview when there's nothing to render: the "add
+ * source" state when the source is empty, or - with `error` - the compact
+ * error state when the source failed to render (the full error message is
+ * shown in the source popup while editing).
+ */
+export const PreviewPlaceholder = (props: {
+  text: string;
+  icon?: ReactNode;
+  error?: boolean;
+}) => (
+  
+ {/* The icon is decorative next to the text, so hide it from screen + readers - react-icons don't set this themselves (some even carry + `role="img"`, announcing a nameless image). */} + +

{props.text}

+
+); diff --git a/packages/react/src/blocks/SourceWithPreview/SourcePreviewPopup.ts b/packages/react/src/blocks/SourceWithPreview/SourcePreviewPopup.ts new file mode 100644 index 0000000000..7f78539376 --- /dev/null +++ b/packages/react/src/blocks/SourceWithPreview/SourcePreviewPopup.ts @@ -0,0 +1,25 @@ +/** + * State of & actions on the source popup of a block or inline content with a + * preview. Returned by `useSourceBlockPreviewPopup` and + * `useSourceInlineContentPreviewPopup`. + */ +export type SourcePreviewPopup = { + /** + * Whether the source popup is open. + */ + isOpen: boolean; + /** + * Whether the block/inline content is selected, i.e. whether its preview is + * highlighted. + */ + isSelected: boolean; + /** + * Opens the popup, moves the cursor into the source, and focuses the + * editor. Does nothing when the editor isn't editable. + */ + open: () => void; + /** + * Closes the popup. + */ + close: () => void; +}; diff --git a/packages/react/src/blocks/SourceWithPreview/SourceWithPreview.tsx b/packages/react/src/blocks/SourceWithPreview/SourceWithPreview.tsx new file mode 100644 index 0000000000..0066b2e0f5 --- /dev/null +++ b/packages/react/src/blocks/SourceWithPreview/SourceWithPreview.tsx @@ -0,0 +1,204 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { MouseEvent, ReactNode, useRef } from "react"; + +import { PreviewPlaceholder } from "./PreviewPlaceholder.js"; +import { SourcePreviewPopup } from "./SourcePreviewPopup.js"; + +/** + * Props shared by {@link SourceBlockWithPreview} and + * {@link SourceInlineContentWithPreview}. + */ +export type SourceWithPreviewProps = { + /** + * Ref for the element holding the editable source content. + */ + contentRef: (node: HTMLElement | null) => void; + /** + * The source as plain text. When empty, an "add source" button is shown in + * place of the preview. + */ + source: string; + /** + * The rendered preview (e.g. a formula or diagram). When the current source + * has an error, pass the last successfully rendered preview so it stays up + * while the user edits, or `undefined` when there is none - the error state + * is shown in its place. The last-good preview only stays up within the + * editing session that introduced the error: once the popup closes on an + * erroneous source, the error state shows until the source renders + * successfully again. + */ + preview?: ReactNode; + /** + * Error from rendering the preview, shown below the source in the popup. + * Accepts arbitrary elements, so actions (e.g. a button that fixes the + * source) can be rendered alongside the error message. + */ + error?: ReactNode; + /** + * Shown in place of the preview when the source is empty. A string sets + * the text of the default "add source" button, while an element replaces + * the button entirely. Defaults to the editor dictionary's + * `code_block.add_source_button_text`. + */ + emptySourcePlaceholder?: ReactNode; + /** + * Shown in place of the preview when the source has an error, unless + * there's a last-good `preview` and the error hasn't been committed yet + * (the popup hasn't closed since it appeared). A string sets the text of + * the default compact error state, while an element replaces it entirely. + * The full `error` is only shown in the popup while editing. + */ + errorPreview?: ReactNode; +}; + +/** + * Renders a preview of source content (e.g. math or diagrams), with the + * editable source in a popup driven by the given popup controller: decides + * what shows in place of the preview (empty state, last-good preview, or + * error state) and opens/closes the popup on clicks. + * `SourceBlockWithPreview` and `SourceInlineContentWithPreview` are thin + * per-kind wrappers around this, obtaining the popup controller from their + * respective hooks. + */ +export const SourceWithPreview = ( + props: SourceWithPreviewProps & { + editor: BlockNoteEditor; + popup: SourcePreviewPopup; + /** + * Renders with `span` wrappers for inline content. + */ + inline?: boolean; + }, +) => { + const { + editor, + popup, + inline, + contentRef, + source, + preview, + error, + emptySourcePlaceholder, + errorPreview, + } = props; + + // Whether the error has been "committed": shown while the popup was + // closed. The last-good preview only stays up within the editing session + // that introduced the error - once committed, the error state also shows + // when the popup reopens, until the source renders successfully again. + // Updated during render (rather than in an effect) so reopening doesn't + // flash the stale preview for a frame first. + const errorCommittedRef = useRef(false); + if (error == null) { + errorCommittedRef.current = false; + } else if (!popup.isOpen) { + errorCommittedRef.current = true; + } + + // For both placeholder props, a string customizes the default element's + // text while any other element replaces it entirely. + const emptyState = + emptySourcePlaceholder == null || + typeof emptySourcePlaceholder === "string" ? ( + + ) : ( + emptySourcePlaceholder + ); + const errorState = + errorPreview == null || typeof errorPreview === "string" ? ( + + ) : ( + errorPreview + ); + + // What to show in place of the source: the empty state, the preview, or - + // when the source has an error that's committed (or no last-good preview + // to keep showing) - the error state. + const previewContent = + source.length === 0 + ? emptyState + : error != null && (errorCommittedRef.current || preview == null) + ? errorState + : preview; + + // The actions run on click (rather than mousedown), so keyboard & + // assistive technology activation also works. No mousedown focus guards: + // React's synthetic `onMouseDown` doesn't fire on node view elements, and + // testing with real input showed they aren't needed - `open`/`close` + // restore the editor's focus & selection themselves. + + // Opens the popup when clicking the preview. + const handlePreviewClick = (event: MouseEvent) => { + if (!editor.isEditable) { + return; + } + + event.stopPropagation(); + + popup.open(); + }; + + // Closes the popup when clicking the "OK" button. + const handleOkButtonClick = (event: MouseEvent) => { + event.stopPropagation(); + + popup.close(); + }; + + // `span` wrappers so inline content stays valid inside a paragraph. + const Wrapper = inline ? "span" : "div"; + const PreviewContainer = inline ? "span" : "div"; + + return ( + + + {previewContent} + +
+
+
+            
+            {inline && source.length === 0 && (
+              
+            )}
+          
+
+ +
+
+
+ {error} +
+
+
+ ); +}; diff --git a/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx b/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx new file mode 100644 index 0000000000..6d2b9c4247 --- /dev/null +++ b/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx @@ -0,0 +1,31 @@ +import { ReactCustomBlockRenderProps } from "../../../schema/ReactBlockSpec.js"; +import { + SourceWithPreview, + SourceWithPreviewProps, +} from "../SourceWithPreview.js"; +import { useSourceBlockPreviewPopup } from "./useSourceBlockPreviewPopup.js"; + +export type SourceBlockWithPreviewProps = Pick< + ReactCustomBlockRenderProps, + "block" | "editor" +> & + // `contentRef` comes from the shared props rather than being picked from + // `ReactCustomBlockRenderProps`, as it's conditional on the block's content + // type there, which TypeScript can't resolve for a generic block config. + SourceWithPreviewProps; + +/** + * Renders a block as a preview of its source content, with the editable + * source in a popup. The popup is controlled via + * {@link useSourceBlockPreviewPopup}, so the block's + * `SourceBlockWithPreviewExtension` (from `@blocknote/core`) must be registered + * with the block spec. The caller only provides the preview itself, making this + * the base for custom blocks rendered from source code (math, diagrams, etc). + */ +export const SourceBlockWithPreview = (props: SourceBlockWithPreviewProps) => { + const { block, editor, ...shared } = props; + + const popup = useSourceBlockPreviewPopup({ editor, block }); + + return ; +}; diff --git a/packages/react/src/blocks/SourceWithPreview/block/useSourceBlockPreviewPopup.ts b/packages/react/src/blocks/SourceWithPreview/block/useSourceBlockPreviewPopup.ts new file mode 100644 index 0000000000..f7e83df201 --- /dev/null +++ b/packages/react/src/blocks/SourceWithPreview/block/useSourceBlockPreviewPopup.ts @@ -0,0 +1,57 @@ +import { + BlockNoteEditor, + SourceBlockWithPreviewExtension, +} from "@blocknote/core"; + +import { + useExtension, + useExtensionState, +} from "../../../hooks/useExtension.js"; +import type { SourcePreviewPopup } from "../SourcePreviewPopup.js"; + +/** + * Controls the source popup of a block with a preview, e.g. to open it from a + * custom preview element. A block's popup is toggled explicitly - `open` and + * `close` set a flag, separate from the selection. The popup state itself is + * managed by the `SourceBlockWithPreviewExtension` registered with the block + * spec (so it survives node view re-creation and stays in sync with the + * keyboard handling) - this hook is the React API to it. + */ +export const useSourceBlockPreviewPopup = (props: { + editor: BlockNoteEditor; + block: { id: string }; +}): SourcePreviewPopup => { + const { editor, block } = props; + + const { store } = useExtension(SourceBlockWithPreviewExtension, { editor }); + + const isOpen = useExtensionState(SourceBlockWithPreviewExtension, { + editor, + selector: (state) => state.popupOpen === block.id, + }); + const isSelected = useExtensionState(SourceBlockWithPreviewExtension, { + editor, + selector: (state) => state.selected === block.id, + }); + + // Opens the popup with the cursor at the end of the source. + const open = () => { + if (!editor.isEditable) { + return; + } + + store.setState((state) => ({ ...state, popupOpen: block.id })); + editor.setTextCursorPosition(block.id, "end"); + editor.focus(); + }; + + const close = () => { + store.setState((state) => ({ ...state, popupOpen: undefined })); + // Restores focus in case closing was triggered by clicking the "OK" + // button, which moves focus to it - otherwise keyboard interactions (e.g. + // Enter to re-open the popup) stop reaching the editor. + editor.focus(); + }; + + return { isOpen, isSelected, open, close }; +}; diff --git a/packages/react/src/blocks/SourceWithPreview/inlineContent/SourceInlineContentWithPreview.tsx b/packages/react/src/blocks/SourceWithPreview/inlineContent/SourceInlineContentWithPreview.tsx new file mode 100644 index 0000000000..5b1eceaafe --- /dev/null +++ b/packages/react/src/blocks/SourceWithPreview/inlineContent/SourceInlineContentWithPreview.tsx @@ -0,0 +1,31 @@ +import { ReactCustomInlineContentRenderProps } from "../../../schema/ReactInlineContentSpec.js"; +import { + SourceWithPreview, + SourceWithPreviewProps, +} from "../SourceWithPreview.js"; +import { useSourceInlineContentPreviewPopup } from "./useSourceInlineContentPreviewPopup.js"; + +export type SourceInlineContentWithPreviewProps = Pick< + ReactCustomInlineContentRenderProps, + "editor" | "node" | "getPos" +> & + SourceWithPreviewProps; + +/** + * Renders inline content as a preview of its source, with the editable source + * in a popup - the inline counterpart of `SourceBlockWithPreview`. The popup + * is controlled via {@link useSourceInlineContentPreviewPopup}, so the inline + * content's `SourceInlineContentWithPreviewExtension` must be registered with + * the inline content spec. Unlike blocks, the popup is open exactly while the + * selection is inside the inline content's source, which is the same + * condition that marks it as selected. + */ +export const SourceInlineContentWithPreview = ( + props: SourceInlineContentWithPreviewProps, +) => { + const { editor, node, getPos, ...shared } = props; + + const popup = useSourceInlineContentPreviewPopup({ editor, node, getPos }); + + return ; +}; diff --git a/packages/react/src/blocks/SourceWithPreview/inlineContent/useSourceInlineContentPreviewPopup.ts b/packages/react/src/blocks/SourceWithPreview/inlineContent/useSourceInlineContentPreviewPopup.ts new file mode 100644 index 0000000000..30cc81b7a1 --- /dev/null +++ b/packages/react/src/blocks/SourceWithPreview/inlineContent/useSourceInlineContentPreviewPopup.ts @@ -0,0 +1,94 @@ +import { + BlockNoteEditor, + SourceInlineContentWithPreviewExtension, +} from "@blocknote/core"; +import { TextSelection } from "@tiptap/pm/state"; + +import { + useExtension, + useExtensionState, +} from "../../../hooks/useExtension.js"; +import type { SourcePreviewPopup } from "../SourcePreviewPopup.js"; + +/** + * Controls the source popup of inline content with a preview, e.g. to open it + * from a custom preview element. Unlike a block's, the popup is open exactly + * while the selection is inside the inline content's source - so `isOpen` and + * `isSelected` always agree, and `open`/`close` work by moving the selection + * into/out of the source. The popup state itself is managed by the + * `SourceInlineContentWithPreviewExtension` registered with the inline + * content spec (so it survives node view re-creation and stays in sync with + * the keyboard handling) - this hook is the React API to it. + */ +export const useSourceInlineContentPreviewPopup = (props: { + editor: BlockNoteEditor; + node: { nodeSize: number }; + getPos: () => number | undefined; +}): SourcePreviewPopup => { + const { editor, node, getPos } = props; + + const { store } = useExtension(SourceInlineContentWithPreviewExtension, { + editor, + }); + + // `getPos` is called fresh in the selector and actions rather than captured + // once per render, as the inline content's position can shift without its + // node view re-rendering. The `undefined` guard matters when rendered + // outside the editor (i.e. serialized to HTML): there `getPos()` returns + // `undefined`, which must not match the store's initial `undefined` state. + const isSelected = useExtensionState( + SourceInlineContentWithPreviewExtension, + { + editor, + selector: (state) => + state.selected !== undefined && state.selected === getPos(), + }, + ); + + // Opens the popup by moving the selection to the end of the source. + const open = () => { + if (!editor.isEditable) { + return; + } + + const pos = getPos(); + if (!pos) { + return; + } + + store.setState({ selected: pos }); + + const view = editor.prosemirrorView!; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.tr.doc, pos + node.nodeSize - 1), + ), + ); + editor.focus(); + }; + + // Closes the popup by moving the selection to just after the inline + // content. + const close = () => { + if (!editor.isEditable) { + return; + } + + const pos = getPos(); + if (!pos) { + return; + } + + const view = editor.prosemirrorView!; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.tr.doc, pos + node.nodeSize), + ), + ); + editor.focus(); + }; + + // The popup is open exactly when the selection is inside the source, which + // is the same condition that marks it as selected. + return { isOpen: isSelected, isSelected, open, close }; +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6beb5a7082..cd7137e7a7 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -16,6 +16,13 @@ export * from "./blocks/File/helpers/toExternalHTML/LinkWithCaption.js"; export * from "./blocks/File/useResolveUrl.js"; export * from "./blocks/Image/block.js"; export * from "./blocks/PageBreak/getPageBreakReactSlashMenuItems.js"; +export * from "./blocks/SourceWithPreview/PreviewPlaceholder.js"; +export * from "./blocks/SourceWithPreview/SourceWithPreview.js"; +export * from "./blocks/SourceWithPreview/SourcePreviewPopup.js"; +export * from "./blocks/SourceWithPreview/block/SourceBlockWithPreview.js"; +export * from "./blocks/SourceWithPreview/block/useSourceBlockPreviewPopup.js"; +export * from "./blocks/SourceWithPreview/inlineContent/SourceInlineContentWithPreview.js"; +export * from "./blocks/SourceWithPreview/inlineContent/useSourceInlineContentPreviewPopup.js"; export * from "./blocks/Video/block.js"; export * from "./blocks/ToggleWrapper/ToggleWrapper.js"; diff --git a/packages/react/src/schema/ReactInlineContentSpec.tsx b/packages/react/src/schema/ReactInlineContentSpec.tsx index 84df6cefb7..e6db968537 100644 --- a/packages/react/src/schema/ReactInlineContentSpec.tsx +++ b/packages/react/src/schema/ReactInlineContentSpec.tsx @@ -6,6 +6,8 @@ import { createInternalInlineContentSpec, CustomInlineContentConfig, CustomInlineContentImplementation, + Extension, + ExtensionFactoryInstance, getInlineContentParseRules, InlineContentFromConfig, InlineContentSchemaWithInlineContent, @@ -44,6 +46,16 @@ export type ReactCustomInlineContentRenderProps< S >; contentRef: (node: HTMLElement | null) => void; + /** + * The ProseMirror node backing this inline content. + */ + node: NodeViewProps["node"]; + /** + * Returns this inline content's position in the document. When rendered + * outside the editor (i.e. serialized to HTML) this is a no-op that returns + * `undefined`. + */ + getPos: NodeViewProps["getPos"]; }; // extend BlockConfig but use a React render function @@ -105,6 +117,8 @@ export function InlineContentWrapper< * @param inlineContentImplementation - The React implementation, including a * `render` component and optionally a `toExternalHTML` component and `parse` * rules. + * @param extensions - Optional editor extensions registered alongside this + * inline content (e.g. for keyboard handling), mirroring block specs. * @returns An `InlineContentSpec` that can be passed to the editor's schema. */ export function createReactInlineContentSpec< @@ -114,6 +128,7 @@ export function createReactInlineContentSpec< >( inlineContentConfig: T, inlineContentImplementation: ReactInlineContentImplementation, + extensions?: (Extension | ExtensionFactoryInstance)[], ): InlineContentSpec { const node = Node.create({ name: inlineContentConfig.type as T["type"], @@ -122,6 +137,7 @@ export function createReactInlineContentSpec< selectable: inlineContentConfig.content === "styled", atom: inlineContentConfig.content === "none", draggable: inlineContentImplementation.meta?.draggable, + code: inlineContentImplementation.meta?.code, content: (inlineContentConfig.content === "styled" ? "inline*" : "") as T["content"] extends "styled" ? "inline*" : "", @@ -138,6 +154,7 @@ export function createReactInlineContentSpec< return getInlineContentParseRules( inlineContentConfig, inlineContentImplementation.parse, + inlineContentImplementation.parseContent, ); }, @@ -166,6 +183,8 @@ export function createReactInlineContentSpec< // No-op }} editor={editor} + node={node} + getPos={() => undefined} /> ), editor, @@ -205,6 +224,8 @@ export function createReactInlineContentSpec< } }} editor={editor} + node={props.node} + getPos={props.getPos} inlineContent={ nodeToCustomInlineContent( props.node, @@ -248,6 +269,12 @@ export function createReactInlineContentSpec< node, render(inlineContent, updateInlineContent, editor) { const Content = inlineContentImplementation.render; + // Rendered outside the editor (serialization), so there's no live node + // view - derive the node from the content and stub out `getPos`. + const node = inlineContentToNodes( + [inlineContent] as any, + editor.pmSchema, + )[0]; const output = renderToDOMSpec((ref) => { return ( undefined} /> ); @@ -275,6 +304,12 @@ export function createReactInlineContentSpec< const Content = inlineContentImplementation.toExternalHTML || inlineContentImplementation.render; + // Rendered outside the editor (serialization), so there's no live node + // view - derive the node from the content and stub out `getPos`. + const node = inlineContentToNodes( + [inlineContent] as any, + editor.pmSchema, + )[0]; const output = renderToDOMSpec((ref) => { return ( { // no-op }} + node={node} + getPos={() => undefined} /> ); @@ -301,5 +338,6 @@ export function createReactInlineContentSpec< return output; }, }, + extensions, ) as any; } diff --git a/packages/xl-docx-exporter/package.json b/packages/xl-docx-exporter/package.json index 7a4b2615fb..f15ccf5794 100644 --- a/packages/xl-docx-exporter/package.json +++ b/packages/xl-docx-exporter/package.json @@ -46,6 +46,16 @@ "import": "./dist/style.css", "require": "./dist/style.css", "style": "./dist/style.css" + }, + "./diagram-block": { + "types": "./types/src/diagram-block/index.d.ts", + "import": "./dist/diagram-block.js", + "require": "./dist/diagram-block.cjs" + }, + "./math-block": { + "types": "./types/src/math-block/index.d.ts", + "import": "./dist/math-block.js", + "require": "./dist/math-block.cjs" } }, "scripts": { @@ -61,11 +71,15 @@ "@blocknote/xl-multi-column": "workspace:^", "buffer": "^6.0.3", "docx": "^9.6.1", - "image-meta": "^0.2.2" + "image-meta": "^0.2.2", + "mathml2omml": "^0.5.0" }, "devDependencies": { + "@blocknote/diagram-block": "workspace:^", "@blocknote/shared": "workspace:^", + "@types/katex": "^0.16.7", "@types/react": "^19.2.3", + "katex": "^0.16.11", "@types/react-dom": "^19.2.3", "@zip.js/zip.js": "^2.8.8", "react": "^19.2.5", @@ -73,11 +87,21 @@ "rimraf": "^5.0.10", "rollup-plugin-webpack-stats": "^0.2.6", "typescript": "^5.9.3", - "xml-formatter": "^3.6.7", - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "xml-formatter": "^3.6.7" }, "peerDependencies": { "react": "^18.0 || ^19.0 || >= 19.0.0-rc", - "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc", + "@blocknote/diagram-block": "workspace:^", + "katex": "^0.16.0" + }, + "peerDependenciesMeta": { + "@blocknote/diagram-block": { + "optional": true + }, + "katex": { + "optional": true + } } } diff --git a/packages/xl-docx-exporter/src/diagram-block/index.ts b/packages/xl-docx-exporter/src/diagram-block/index.ts new file mode 100644 index 0000000000..4a81bc0253 --- /dev/null +++ b/packages/xl-docx-exporter/src/diagram-block/index.ts @@ -0,0 +1,51 @@ +import { + getDiagramPlainTextContent, + renderDiagramToImage, +} from "@blocknote/diagram-block"; +import { AlignmentType, ImageRun, Paragraph } from "docx"; + +import { docxBlockMappingForDefaultSchema } from "../docx/defaultSchema/blocks.js"; + +/** + * Block mapping for `@blocknote/diagram-block` that embeds diagrams as + * images instead of their Mermaid source: + * + * ```ts + * new DOCXExporter(schema, { + * ...docxDefaultSchemaMappings, + * blockMapping: { + * ...docxDefaultSchemaMappings.blockMapping, + * diagram: diagramBlockMapping, + * }, + * }); + * ``` + * + * Rendering the diagram needs a browser; there (and for invalid sources), it + * falls back to the default source code rendering. Kept out of the default + * mappings so exporting without diagram blocks doesn't load Mermaid. + */ +export const diagramBlockMapping = async ( + ...args: Parameters +) => { + const [block] = args; + + try { + const { dataURL, width, height } = await renderDiagramToImage( + getDiagramPlainTextContent(block.content), + ); + const blob = await (await fetch(dataURL)).blob(); + + return new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new ImageRun({ + data: await blob.arrayBuffer(), + type: "png", + transformation: { width, height }, + }), + ], + }); + } catch { + return docxBlockMappingForDefaultSchema.diagram(...args); + } +}; diff --git a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml index 4a9074e6eb..b77c05abb5 100644 --- a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml +++ b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml @@ -714,6 +714,37 @@ All those moments will be lost in time, like tears in rain. + + + + + + a^2 = \sqrt{b^2 + c^2} + + + + + Inline math: + + + + + + e^{i\pi} + 1 = 0 + + + + + + + + graph TD + + + + A[Start] --> B[End] + + diff --git a/packages/xl-docx-exporter/src/docx/__snapshots__/withMathMappings/document.xml b/packages/xl-docx-exporter/src/docx/__snapshots__/withMathMappings/document.xml new file mode 100644 index 0000000000..a5b026caeb --- /dev/null +++ b/packages/xl-docx-exporter/src/docx/__snapshots__/withMathMappings/document.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + a + + + + + 2 + + + + + = + + + + + + + + + + + + + + b + + + + + 2 + + + + + + + + + + + + + + c + + + + + 2 + + + + + + + + + + Inline math: + + + + + + + + + e + + + + + + + + + + +1=0 + + + + + + + + + + + \ No newline at end of file diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts index 280a557725..4564d74493 100644 --- a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts +++ b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts @@ -1,4 +1,6 @@ import { + BlockConfig, + BlockFromConfigNoChildren, BlockMapping, COLORS_DEFAULT, createPageBreakBlockConfig, @@ -25,6 +27,12 @@ import { import { Table } from "../util/Table.js"; import { multiColumnSchema } from "@blocknote/xl-multi-column"; +type BSchema = DefaultBlockSchema & { + pageBreak: ReturnType; + math: BlockConfig<"math", {}, "inline">; + diagram: BlockConfig<"diagram", {}, "inline">; +} & typeof multiColumnSchema.blockSchema; + function blockPropsToStyles( props: Partial, colors: typeof COLORS_DEFAULT, @@ -69,10 +77,27 @@ function blockPropsToStyles( })(), }; } + +const codeMapping = ( + block: BlockFromConfigNoChildren, +) => { + const textContent = (block.content as StyledText[])[0]?.text || ""; + + return new Paragraph({ + style: "SourceCode", + children: [ + ...textContent.split("\n").map((line, index) => { + return new TextRun({ + text: line, + break: index > 0 ? 1 : 0, + }); + }), + ], + }); +}; + export const docxBlockMappingForDefaultSchema: BlockMapping< - DefaultBlockSchema & { - pageBreak: ReturnType; - } & typeof multiColumnSchema.blockSchema, + BSchema, any, any, | Promise @@ -162,21 +187,9 @@ export const docxBlockMappingForDefaultSchema: BlockMapping< ...caption(block.props, exporter), ]; }, - codeBlock: (block) => { - const textContent = (block.content as StyledText[])[0]?.text || ""; - - return new Paragraph({ - style: "SourceCode", - children: [ - ...textContent.split("\n").map((line, index) => { - return new TextRun({ - text: line, - break: index > 0 ? 1 : 0, - }); - }), - ], - }); - }, + codeBlock: codeMapping, + math: codeMapping, + diagram: codeMapping, pageBreak: () => { return new Paragraph({ children: [new PageBreak()], diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/inlinecontent.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/inlinecontent.ts index 5d8b0f442d..9ec65395b2 100644 --- a/packages/xl-docx-exporter/src/docx/defaultSchema/inlinecontent.ts +++ b/packages/xl-docx-exporter/src/docx/defaultSchema/inlinecontent.ts @@ -6,8 +6,16 @@ import { import { ExternalHyperlink, ParagraphChild, TextRun } from "docx"; import type { DOCXExporter } from "../docxExporter.js"; +type ICSchema = DefaultInlineContentSchema & { + inlineMath: { + type: "inlineMath"; + propSchema: Record; + content: "styled"; + }; +}; + export const docxInlineContentMappingForDefaultSchema: InlineContentMapping< - DefaultInlineContentSchema, + ICSchema, DefaultStyleSchema, ParagraphChild, TextRun @@ -26,4 +34,12 @@ export const docxInlineContentMappingForDefaultSchema: InlineContentMapping< text: (ic, t) => { return t.transformStyledText(ic); }, + // Renders inline math as its monospaced LaTeX source. + // TODO + inlineMath: (ic) => { + return new TextRun({ + text: ic.content.map((content) => content.text).join(""), + style: "VerbatimChar", + }); + }, }; diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts index a24340a7ab..cdd7894de5 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts @@ -14,6 +14,7 @@ import { import { Packer, Paragraph, TextRun } from "docx"; import { describe, expect, it } from "vite-plus/test"; import xmlFormat from "xml-formatter"; +import { inlineMathMapping, mathBlockMapping } from "../math-block/index.js"; import { docxDefaultSchemaMappings } from "./defaultSchema/index.js"; import { DOCXExporter } from "./docxExporter.js"; import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column"; @@ -32,6 +33,53 @@ const getZIPEntryContent = (entries: Entry[], fileName: string) => { return entry.getData!(new TextWriter()); }; describe("exporter", () => { + it( + "should export math as native equations with the math-block mappings", + { timeout: 10000 }, + async () => { + // Assembled outside the constructor call as the schema doesn't include + // the math specs - like the default mappings, the math entries just + // map the block JSON. + const mappings = { + ...docxDefaultSchemaMappings, + blockMapping: { + ...docxDefaultSchemaMappings.blockMapping, + math: mathBlockMapping, + }, + inlineContentMapping: { + ...docxDefaultSchemaMappings.inlineContentMapping, + inlineMath: inlineMathMapping, + }, + }; + const exporter = new DOCXExporter( + BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: createPageBreakBlockSpec(), + }, + }), + mappings, + { resolveFileUrl: testResolveFileUrl }, + ); + + // The math block & inline math paragraph from the shared test document. + const doc = await exporter.toDocxJsDocument( + testDocument.filter((block) => + ["math-block", "paragraph-with-inline-math"].includes(block.id), + ), + { sectionOptions: {}, documentOptions: {}, locale: "en-US" }, + ); + + const blob = await Packer.toBlob(doc); + const zip = new ZipReader(new BlobReader(blob)); + const entries = await zip.getEntries(); + + await expect( + prettify(await getZIPEntryContent(entries, "word/document.xml")), + ).toMatchFileSnapshot("__snapshots__/withMathMappings/document.xml"); + }, + ); + it("should export a document", { timeout: 10000 }, async () => { const exporter = new DOCXExporter( BlockNoteSchema.create({ diff --git a/packages/xl-docx-exporter/src/math-block/index.ts b/packages/xl-docx-exporter/src/math-block/index.ts new file mode 100644 index 0000000000..86afe9a6c1 --- /dev/null +++ b/packages/xl-docx-exporter/src/math-block/index.ts @@ -0,0 +1,123 @@ +import { AlignmentType, ImportedXmlComponent, Paragraph } from "docx"; +import katex from "katex"; +import { mml2omml } from "mathml2omml"; + +import { docxBlockMappingForDefaultSchema } from "../docx/defaultSchema/blocks.js"; +import { docxInlineContentMappingForDefaultSchema } from "../docx/defaultSchema/inlinecontent.js"; + +// The math block's inline content as plain text (its LaTeX source). Local +// copy of `@blocknote/math-block`'s `getMathPlainTextContent` - importing the +// package would break headless (Node) exports, as its build carries a +// top-level CSS import. +// TODO: remove after plain text PR lands +const getPlainTextContent = (content: unknown): string => { + if (typeof content === "string") { + return content; + } + + if (Array.isArray(content)) { + return content + .map((node) => + node && typeof node === "object" && "text" in node ? node.text : "", + ) + .join(""); + } + + return ""; +}; + +// Converts LaTeX to a native Word equation (OMML): KaTeX renders the LaTeX +// to MathML, which is then converted to OMML. Throws on invalid LaTeX, so +// callers can fall back to the default source-code rendering. +const latexToEquation = (latex: string, inline: boolean) => { + const katexOutput = katex.renderToString(latex, { + displayMode: !inline, + output: "mathml", + throwOnError: true, + }); + + // KaTeX wraps the MathML in a `span`; the equation only needs the `math` + // element itself. + const mathML = katexOutput.match(//)?.[0]; + if (!mathML) { + throw new Error("No MathML found in KaTeX output"); + } + + // `fromXmlString` parses the XML *document*, returning a nameless wrapper + // component around the `m:oMath` root element - unwrap it, or it would + // serialize as an (invalid) `` element. + const imported = ImportedXmlComponent.fromXmlString(mml2omml(mathML)) as any; + return imported.root[0] as ImportedXmlComponent; +}; + +/** + * Block mapping for `@blocknote/math-block` that renders math blocks as + * native (editable) Word equations instead of their LaTeX source: + * + * ```ts + * new DOCXExporter(schema, { + * ...docxDefaultSchemaMappings, + * blockMapping: { + * ...docxDefaultSchemaMappings.blockMapping, + * math: mathBlockMapping, + * }, + * }); + * ``` + * + * Requires `katex` (a dependency of `@blocknote/math-block`, so already + * installed when using math blocks). Kept out of the default mappings so + * exporting without math blocks doesn't load it. Invalid LaTeX falls back to + * the default source code rendering. + */ +export const mathBlockMapping = ( + ...args: Parameters +) => { + const [block] = args; + + try { + const source = getPlainTextContent(block.content); + if (!source.trim()) { + throw new Error("Empty math block"); + } + + return new Paragraph({ + alignment: AlignmentType.CENTER, + children: [latexToEquation(source, false) as any], + }); + } catch { + return docxBlockMappingForDefaultSchema.math(...args); + } +}; + +/** + * Inline content mapping for `@blocknote/math-block` that renders inline math + * as native (editable) Word equations instead of its LaTeX source: + * + * ```ts + * new DOCXExporter(schema, { + * ...docxDefaultSchemaMappings, + * inlineContentMapping: { + * ...docxDefaultSchemaMappings.inlineContentMapping, + * inlineMath: inlineMathMapping, + * }, + * }); + * ``` + */ +export const inlineMathMapping = ( + ...args: Parameters< + typeof docxInlineContentMappingForDefaultSchema.inlineMath + > +) => { + const [inlineContent] = args; + + try { + const source = getPlainTextContent(inlineContent.content); + if (!source.trim()) { + throw new Error("Empty inline math"); + } + + return latexToEquation(source, true) as any; + } catch { + return docxInlineContentMappingForDefaultSchema.inlineMath(...args); + } +}; diff --git a/packages/xl-docx-exporter/vite.config.ts b/packages/xl-docx-exporter/vite.config.ts index 8a88c957f6..e4b1ffc1c1 100644 --- a/packages/xl-docx-exporter/vite.config.ts +++ b/packages/xl-docx-exporter/vite.config.ts @@ -56,6 +56,8 @@ export default defineConfig( __dirname, "src/index.ts", ), + "diagram-block": path.resolve(__dirname, "src/diagram-block/index.ts"), + "math-block": path.resolve(__dirname, "src/math-block/index.ts"), }, name: "blocknote-xl-docx-exporter", formats: ["es", "cjs"], diff --git a/packages/xl-email-exporter/src/react-email/__snapshots__/reactEmailExporter.test.tsx.snap b/packages/xl-email-exporter/src/react-email/__snapshots__/reactEmailExporter.test.tsx.snap index 34b18adef0..104ec79d9f 100644 --- a/packages/xl-email-exporter/src/react-email/__snapshots__/reactEmailExporter.test.tsx.snap +++ b/packages/xl-email-exporter/src/react-email/__snapshots__/reactEmailExporter.test.tsx.snap @@ -1,10 +1,10 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`react email exporter > should export a document (HTML snapshot) > __snapshots__/reactEmailExporter 1`] = `"

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.


  • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

"`; +exports[`react email exporter > should export a document (HTML snapshot) > __snapshots__/reactEmailExporter 1`] = `"

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.


  • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

a^2 ‍​= ‍​\\sqrt{b^2 ‍​+ ‍​c^2}

Inline math: e^{i\\pi} + 1 = 0

graph ‍​TD
 ‍​ ‍​A[Start] ‍​--> ‍​B[End]
"`; -exports[`react email exporter > should export a document with multiple preview lines > __snapshots__/reactEmailExporterWithMultiplePreview 1`] = `"
First preview lineSecond preview line
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.


  • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

"`; +exports[`react email exporter > should export a document with multiple preview lines > __snapshots__/reactEmailExporterWithMultiplePreview 1`] = `"
First preview lineSecond preview line
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.


  • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

a^2 ‍​= ‍​\\sqrt{b^2 ‍​+ ‍​c^2}

Inline math: e^{i\\pi} + 1 = 0

graph ‍​TD
 ‍​ ‍​A[Start] ‍​--> ‍​B[End]
"`; -exports[`react email exporter > should export a document with preview > __snapshots__/reactEmailExporterWithPreview 1`] = `"
This is a preview of the email content
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.


  • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

"`; +exports[`react email exporter > should export a document with preview > __snapshots__/reactEmailExporterWithPreview 1`] = `"
This is a preview of the email content
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.


  • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

a^2 ‍​= ‍​\\sqrt{b^2 ‍​+ ‍​c^2}

Inline math: e^{i\\pi} + 1 = 0

graph ‍​TD
 ‍​ ‍​A[Start] ‍​--> ‍​B[End]
"`; exports[`react email exporter > should handle document with background colors > __snapshots__/reactEmailExporterBackgroundColor 1`] = `"

Text with background color

"`; @@ -14,7 +14,7 @@ exports[`react email exporter > should handle document with code blocks > __snap exports[`react email exporter > should handle document with complex nested structure > __snapshots__/reactEmailExporterComplexNested 1`] = `"

Complex Document

This is a paragraph with bold and italic text, plus a link.

  • List item with nested content

    Nested paragraph

    1. Nested numbered item

"`; -exports[`react email exporter > should handle document with custom body styles > __snapshots__/reactEmailExporterCustomBodyStyles 1`] = `"

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.


  • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

"`; +exports[`react email exporter > should handle document with custom body styles > __snapshots__/reactEmailExporterCustomBodyStyles 1`] = `"

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.


  • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

a^2 ‍​= ‍​\\sqrt{b^2 ‍​+ ‍​c^2}

Inline math: e^{i\\pi} + 1 = 0

graph ‍​TD
 ‍​ ‍​A[Start] ‍​--> ‍​B[End]
"`; exports[`react email exporter > should handle document with headings of different levels > __snapshots__/reactEmailExporterHeadings 1`] = `"

Heading 1

Heading 2

Heading 3

"`; diff --git a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx index a0befbcb32..b2828e7a83 100644 --- a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx +++ b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx @@ -1,4 +1,6 @@ import { + BlockConfig, + BlockFromConfigNoChildren, BlockMapping, createPageBreakBlockConfig, DefaultBlockSchema, @@ -115,12 +117,41 @@ export const defaultReactEmailTextStyles = { }, } satisfies ReactEmailTextStyles; +type BSchema = DefaultBlockSchema & { + pageBreak: ReturnType; + math: BlockConfig<"math", {}, "inline">; + diagram: BlockConfig<"diagram", {}, "inline">; +}; + +// Renders a block's inline content as a code block. Used for both code blocks +// and math blocks (which store their LaTeX source as content); math has no +// language, so it's passed explicitly. +const codeMapping = ( + block: BlockFromConfigNoChildren, + language: PrismLanguage, + textStyles: ReactEmailTextStyles, +) => { + const textContent = (block.content as StyledText[])[0]?.text || ""; + + return ( + + ); +}; + export const createReactEmailBlockMappingForDefaultSchema = ( textStyles: ReactEmailTextStyles = defaultReactEmailTextStyles, ): BlockMapping< - DefaultBlockSchema & { - pageBreak: ReturnType; - }, + BSchema, any, any, React.ReactElement, @@ -259,23 +290,11 @@ export const createReactEmailBlockMappingForDefaultSchema = ( ); }, - codeBlock: (block) => { - const textContent = (block.content as StyledText[])[0]?.text || ""; - - return ( - - ); - }, + codeBlock: (block) => + codeMapping(block, block.props.language as PrismLanguage, textStyles), + math: (block) => codeMapping(block, "latex" as PrismLanguage, textStyles), + diagram: (block) => + codeMapping(block, "mermaid" as PrismLanguage, textStyles), audio: (block) => { // Audio icon SVG const icon = ( diff --git a/packages/xl-email-exporter/src/react-email/defaultSchema/inlinecontent.tsx b/packages/xl-email-exporter/src/react-email/defaultSchema/inlinecontent.tsx index 96adc8dfba..0fa893c812 100644 --- a/packages/xl-email-exporter/src/react-email/defaultSchema/inlinecontent.tsx +++ b/packages/xl-email-exporter/src/react-email/defaultSchema/inlinecontent.tsx @@ -15,10 +15,18 @@ export const defaultReactEmailLinkStyles: ReactEmailLinkStyles = { link: {}, }; +type ICSchema = DefaultInlineContentSchema & { + inlineMath: { + type: "inlineMath"; + propSchema: Record; + content: "styled"; + }; +}; + export const createReactEmailInlineContentMappingForDefaultSchema = ( linkStyles: ReactEmailLinkStyles = defaultReactEmailLinkStyles, ): InlineContentMapping< - DefaultInlineContentSchema, + ICSchema, DefaultStyleSchema, React.ReactElement | React.ReactElement, React.ReactElement @@ -35,6 +43,14 @@ export const createReactEmailInlineContentMappingForDefaultSchema = ( text: (ic, t) => { return t.transformStyledText(ic); }, + // Renders inline math as its monospaced LaTeX source. + inlineMath: (ic, t) => { + return ( + + {...ic.content.map((content) => t.transformStyledText(content))} + + ); + }, }); // Export the original mapping for backward compatibility diff --git a/packages/xl-odt-exporter/package.json b/packages/xl-odt-exporter/package.json index 805ca7831b..3003aa0821 100644 --- a/packages/xl-odt-exporter/package.json +++ b/packages/xl-odt-exporter/package.json @@ -46,6 +46,16 @@ "import": "./dist/style.css", "require": "./dist/style.css", "style": "./dist/style.css" + }, + "./diagram-block": { + "types": "./types/src/diagram-block/index.d.ts", + "import": "./dist/diagram-block.js", + "require": "./dist/diagram-block.cjs" + }, + "./math-block": { + "types": "./types/src/math-block/index.d.ts", + "import": "./dist/math-block.js", + "require": "./dist/math-block.cjs" } }, "scripts": { @@ -62,20 +72,33 @@ "image-meta": "^0.2.2" }, "devDependencies": { + "@blocknote/diagram-block": "workspace:^", "@blocknote/shared": "workspace:^", "@testing-library/react": "^16.3.0", + "@types/katex": "^0.16.7", "@types/react": "^19.2.3", + "katex": "^0.16.11", "@types/react-dom": "^19.2.3", "react": "^19.2.5", "react-dom": "^19.2.5", "rimraf": "^5.0.10", "rollup-plugin-webpack-stats": "^0.2.6", "typescript": "^5.9.3", - "xml-formatter": "^3.6.7", - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "xml-formatter": "^3.6.7" }, "peerDependencies": { "react": "^18.0 || ^19.0 || >= 19.0.0-rc", - "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc", + "@blocknote/diagram-block": "workspace:^", + "katex": "^0.16.0" + }, + "peerDependenciesMeta": { + "@blocknote/diagram-block": { + "optional": true + }, + "katex": { + "optional": true + } } } diff --git a/packages/xl-odt-exporter/src/diagram-block/index.ts b/packages/xl-odt-exporter/src/diagram-block/index.ts new file mode 100644 index 0000000000..5c5dc5c35d --- /dev/null +++ b/packages/xl-odt-exporter/src/diagram-block/index.ts @@ -0,0 +1,48 @@ +import { + getDiagramPlainTextContent, + renderDiagramToImage, +} from "@blocknote/diagram-block"; + +import { odtBlockMappingForDefaultSchema } from "../odt/defaultSchema/blocks.js"; +import { ODTExporter } from "../odt/odtExporter.js"; +import { createODTImageParagraph } from "../odt/util/createODTImageParagraph.js"; + +/** + * Block mapping for `@blocknote/diagram-block` that embeds diagrams as + * images instead of their Mermaid source: + * + * ```ts + * new ODTExporter(schema, { + * ...odtDefaultSchemaMappings, + * blockMapping: { + * ...odtDefaultSchemaMappings.blockMapping, + * diagram: diagramBlockMapping, + * }, + * }); + * ``` + * + * Rendering the diagram needs a browser; there (and for invalid sources), it + * falls back to the default source code rendering. Kept out of the default + * mappings so exporting without diagram blocks doesn't load Mermaid. + */ +export const diagramBlockMapping = async ( + ...args: Parameters +) => { + const [block, exporter] = args; + + try { + const { dataURL, width, height } = await renderDiagramToImage( + getDiagramPlainTextContent(block.content), + ); + + // The image is rendered at 2x, so pass the diagram's logical dimensions + // rather than the picture's own. + return await createODTImageParagraph( + exporter as ODTExporter, + dataURL, + { width, height, align: "center" }, + ); + } catch { + return odtBlockMappingForDefaultSchema.diagram(...args); + } +}; diff --git a/packages/xl-odt-exporter/src/math-block/index.tsx b/packages/xl-odt-exporter/src/math-block/index.tsx new file mode 100644 index 0000000000..846f171df3 --- /dev/null +++ b/packages/xl-odt-exporter/src/math-block/index.tsx @@ -0,0 +1,171 @@ +import katex from "katex"; + +import { odtBlockMappingForDefaultSchema } from "../odt/defaultSchema/blocks.js"; +import { odtInlineContentMappingForDefaultSchema } from "../odt/defaultSchema/inlineContent.js"; +import { ODTExporter } from "../odt/odtExporter.js"; + +// The math block's inline content as plain text (its LaTeX source). Local +// copy of `@blocknote/math-block`'s `getMathPlainTextContent` - importing the +// package would break headless (Node) exports, as its build carries a +// top-level CSS import. +const getPlainTextContent = (content: unknown): string => { + if (typeof content === "string") { + return content; + } + + if (Array.isArray(content)) { + return content + .map((node) => + node && typeof node === "object" && "text" in node ? node.text : "", + ) + .join(""); + } + + return ""; +}; + +// Converts LaTeX to MathML (via KaTeX), which ODT embeds natively as formula +// objects. Throws on invalid LaTeX, so callers can fall back to the default +// source-code rendering. +const latexToMathML = (latex: string, inline: boolean): string => { + const katexOutput = katex.renderToString(latex, { + displayMode: !inline, + output: "mathml", + throwOnError: true, + }); + + // KaTeX wraps the MathML in a `span`; the formula object only needs the + // `math` element itself. + const mathML = katexOutput.match(//)?.[0]; + if (!mathML) { + throw new Error("No MathML found in KaTeX output"); + } + + return mathML; +}; + +// A formula object, anchored as a character so it can sit inline among text. +// The MathML goes into an object sub-document (rather than inline into the +// frame) and the frame gets no explicit size, with a graphic style derived +// from the built-in "Formula" style - this exact combination makes +// LibreOffice load the formula as a real formula object and compute its +// natural size (sized frames get the formula scaled-to-fit instead, and +// inline MathML renders at zero size). +const formulaFrame = ( + exporter: ODTExporter, + mathML: string, +) => { + const objectPath = exporter.registerObject( + '\n' + mathML, + ); + const styleName = exporter.registerStyle((name) => ( + + + + )); + + return ( + + + + ); +}; + +/** + * Block mapping for `@blocknote/math-block` that renders math blocks as + * native (editable) formula objects instead of their LaTeX source: + * + * ```ts + * new ODTExporter(schema, { + * ...odtDefaultSchemaMappings, + * blockMapping: { + * ...odtDefaultSchemaMappings.blockMapping, + * math: mathBlockMapping, + * }, + * }); + * ``` + * + * Requires `katex` (a dependency of `@blocknote/math-block`, so already + * installed when using math blocks). Kept out of the default mappings so + * exporting without math blocks doesn't load it. Invalid LaTeX falls back to + * the default source code rendering. + */ +export const mathBlockMapping = ( + ...args: Parameters +) => { + const [block, exporter] = args; + + try { + const source = getPlainTextContent(block.content); + if (!source.trim()) { + throw new Error("Empty math block"); + } + + const odtExporter = exporter as ODTExporter; + const mathML = latexToMathML(source, false); + + const styleName = odtExporter.registerStyle((name) => ( + + + + )); + + return ( + + {formulaFrame(odtExporter, mathML)} + + ); + } catch { + return odtBlockMappingForDefaultSchema.math(...args); + } +}; + +/** + * Inline content mapping for `@blocknote/math-block` that renders inline math + * as native (editable) formula objects instead of its LaTeX source: + * + * ```ts + * new ODTExporter(schema, { + * ...odtDefaultSchemaMappings, + * inlineContentMapping: { + * ...odtDefaultSchemaMappings.inlineContentMapping, + * inlineMath: inlineMathMapping, + * }, + * }); + * ``` + */ +export const inlineMathMapping = ( + ...args: Parameters +) => { + const [inlineContent, exporter] = args; + + try { + const source = getPlainTextContent(inlineContent.content); + if (!source.trim()) { + throw new Error("Empty inline math"); + } + + return formulaFrame( + exporter as ODTExporter, + latexToMathML(source, true), + ); + } catch { + return odtInlineContentMappingForDefaultSchema.inlineMath(...args); + } +}; diff --git a/packages/xl-odt-exporter/src/odt/__snapshots__/basic/content.xml b/packages/xl-odt-exporter/src/odt/__snapshots__/basic/content.xml index 22f5bde3d1..4ac648c903 100644 --- a/packages/xl-odt-exporter/src/odt/__snapshots__/basic/content.xml +++ b/packages/xl-odt-exporter/src/odt/__snapshots__/basic/content.xml @@ -468,6 +468,20 @@ All those moments will be lost in time, like tears in rain. + + a^2 = \sqrt{b^2 + c^2} + + + Inline math: + + e^{i\pi} + 1 = 0 + + + + graph TD + + A[Start] --> B[End] + \ No newline at end of file diff --git a/packages/xl-odt-exporter/src/odt/__snapshots__/withCustomOptions/content.xml b/packages/xl-odt-exporter/src/odt/__snapshots__/withCustomOptions/content.xml index b3081d8610..7830679429 100644 --- a/packages/xl-odt-exporter/src/odt/__snapshots__/withCustomOptions/content.xml +++ b/packages/xl-odt-exporter/src/odt/__snapshots__/withCustomOptions/content.xml @@ -482,6 +482,20 @@ All those moments will be lost in time, like tears in rain. + + a^2 = \sqrt{b^2 + c^2} + + + Inline math: + + e^{i\pi} + 1 = 0 + + + + graph TD + + A[Start] --> B[End] + \ No newline at end of file diff --git a/packages/xl-odt-exporter/src/odt/__snapshots__/withMathMappings/content.xml b/packages/xl-odt-exporter/src/odt/__snapshots__/withMathMappings/content.xml new file mode 100644 index 0000000000..3ae4641846 --- /dev/null +++ b/packages/xl-odt-exporter/src/odt/__snapshots__/withMathMappings/content.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Inline math: + + + + + + + \ No newline at end of file diff --git a/packages/xl-odt-exporter/src/odt/__snapshots__/withMathMappings/styles.xml b/packages/xl-odt-exporter/src/odt/__snapshots__/withMathMappings/styles.xml new file mode 100644 index 0000000000..9a53dac929 --- /dev/null +++ b/packages/xl-odt-exporter/src/odt/__snapshots__/withMathMappings/styles.xml @@ -0,0 +1,599 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx index 0889c2e2ac..90a1b2ca36 100644 --- a/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx +++ b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx @@ -1,5 +1,7 @@ import { + BlockConfig, BlockFromConfig, + BlockFromConfigNoChildren, BlockMapping, createPageBreakBlockConfig, DefaultBlockSchema, @@ -8,8 +10,35 @@ import { StyledText, TableCell, } from "@blocknote/core"; -import { ODTExporter } from "../odtExporter.js"; import { multiColumnSchema } from "@blocknote/xl-multi-column"; +import { ODTExporter } from "../odtExporter.js"; + +type BSchema = DefaultBlockSchema & { + pageBreak: ReturnType; + math: BlockConfig<"math", {}, "inline">; + diagram: BlockConfig<"diagram", {}, "inline">; +} & typeof multiColumnSchema.blockSchema; + +// Renders a block's inline content as a code paragraph. Used for both code +// blocks and math blocks (which store their LaTeX source as content). +const codeMapping = ( + block: BlockFromConfigNoChildren, +) => { + const textContent = (block.content as StyledText[])[0]?.text || ""; + + return ( + + {...textContent.split("\n").map((line, index) => { + return ( + <> + {index !== 0 && } + {line} + + ); + })} + + ); +}; export const getTabs = (nestingLevel: number) => { return Array.from({ length: nestingLevel }, (_, i) => ); @@ -164,9 +193,7 @@ const wrapWithLists = ( }; export const odtBlockMappingForDefaultSchema: BlockMapping< - DefaultBlockSchema & { - pageBreak: ReturnType; - } & typeof multiColumnSchema.blockSchema, + BSchema, any, any, React.ReactNode, @@ -499,23 +526,10 @@ export const odtBlockMappingForDefaultSchema: BlockMapping< ); }, - - codeBlock: (block) => { - const textContent = (block.content as StyledText[])[0]?.text || ""; - - return ( - - {...textContent.split("\n").map((line, index) => { - return ( - <> - {index !== 0 && } - {line} - - ); - })} - - ); - }, +// TODO + codeBlock: codeMapping, + math: codeMapping, + diagram: codeMapping, file: async (block) => { return ( diff --git a/packages/xl-odt-exporter/src/odt/defaultSchema/inlineContent.tsx b/packages/xl-odt-exporter/src/odt/defaultSchema/inlineContent.tsx index 94f0fcaae4..f007a82c3f 100644 --- a/packages/xl-odt-exporter/src/odt/defaultSchema/inlineContent.tsx +++ b/packages/xl-odt-exporter/src/odt/defaultSchema/inlineContent.tsx @@ -3,11 +3,21 @@ import { InlineContentMapping, } from "@blocknote/core"; +type ICSchema = DefaultInlineContentSchema & { + inlineMath: { + type: "inlineMath"; + propSchema: Record; + content: "styled"; + }; +}; + +// `React.ReactNode` result types, matching `ODTExporter`'s `Exporter` +// generics - mismatched result types make the mappings unassignable. export const odtInlineContentMappingForDefaultSchema: InlineContentMapping< - DefaultInlineContentSchema, + ICSchema, any, - React.JSX.Element, - React.JSX.Element + React.ReactNode, + React.ReactNode > = { link: (ic, exporter) => { const content = ic.content.map((c) => exporter.transformStyledText(c)); @@ -28,4 +38,13 @@ export const odtInlineContentMappingForDefaultSchema: InlineContentMapping< text: (ic, exporter) => { return exporter.transformStyledText(ic); }, +// TODO + // Renders inline math as its LaTeX source. + inlineMath: (ic, exporter) => { + return ( + + {ic.content.map((content) => exporter.transformStyledText(content))} + + ); + }, }; diff --git a/packages/xl-odt-exporter/src/odt/index.ts b/packages/xl-odt-exporter/src/odt/index.ts index 3fadfc7a6a..f4a1c2c0c4 100644 --- a/packages/xl-odt-exporter/src/odt/index.ts +++ b/packages/xl-odt-exporter/src/odt/index.ts @@ -1,2 +1,3 @@ export * from "./defaultSchema/index.js"; export * from "./odtExporter.js"; +export * from "./util/createODTImageParagraph.js"; diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.test.ts b/packages/xl-odt-exporter/src/odt/odtExporter.test.ts index 971d12ba1e..9ee61dfb87 100644 --- a/packages/xl-odt-exporter/src/odt/odtExporter.test.ts +++ b/packages/xl-odt-exporter/src/odt/odtExporter.test.ts @@ -7,6 +7,7 @@ import { testDocument } from "@shared/testDocument.js"; import { BlobReader, FileEntry, TextWriter, ZipReader } from "@zip.js/zip.js"; import { beforeAll, describe, expect, it } from "vite-plus/test"; import xmlFormat from "xml-formatter"; +import { inlineMathMapping, mathBlockMapping } from "../math-block/index.js"; import { odtDefaultSchemaMappings } from "./defaultSchema/index.js"; import { ODTExporter } from "./odtExporter.js"; import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column"; @@ -19,6 +20,48 @@ beforeAll(async () => { }); describe("exporter", () => { + it( + "should export math as native formulas with the math-block mappings", + { timeout: 10000 }, + async () => { + // Assembled outside the constructor call as the schema doesn't include + // the math specs - like the default mappings, the math entries just + // map the block JSON. + const mappings = { + ...odtDefaultSchemaMappings, + blockMapping: { + ...odtDefaultSchemaMappings.blockMapping, + math: mathBlockMapping, + }, + inlineContentMapping: { + ...odtDefaultSchemaMappings.inlineContentMapping, + inlineMath: inlineMathMapping, + }, + }; + const exporter = new ODTExporter( + BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: createPageBreakBlockSpec(), + }, + }), + mappings, + { resolveFileUrl: testResolveFileUrl }, + ); + + // The math block & inline math paragraph from the shared test document. + const odt = await exporter.toODTDocument( + testDocument.filter((block) => + ["math-block", "paragraph-with-inline-math"].includes(block.id), + ), + ); + await testODTDocumentAgainstSnapshot(odt, { + styles: "__snapshots__/withMathMappings/styles.xml", + content: "__snapshots__/withMathMappings/content.xml", + }); + }, + ); + it("should export a document", { timeout: 10000 }, async () => { const exporter = new ODTExporter( BlockNoteSchema.create({ diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.tsx b/packages/xl-odt-exporter/src/odt/odtExporter.tsx index de9567c59e..a68cdc843c 100644 --- a/packages/xl-odt-exporter/src/odt/odtExporter.tsx +++ b/packages/xl-odt-exporter/src/odt/odtExporter.tsx @@ -44,6 +44,14 @@ export class ODTExporter< } >(); + // Embedded object sub-documents (e.g. formulas), added as + // "Object N/content.xml" entries in the ODT file. + private objects: Array<{ + path: string; + contentXml: string; + mediaType: string; + }> = []; + private styleCounter = 0; public readonly options: ExporterOptions; @@ -290,6 +298,18 @@ export class ODTExporter< /> ); })} + {this.objects.flatMap((object) => [ + , + , + ])} ); const zipWriter = new ZipWriter( @@ -323,6 +343,12 @@ export class ODTExporter< new BlobReader(picture.file), ); }); + this.objects.forEach((object) => { + void zipWriter.add( + `${object.path}content.xml`, + new TextReader(object.contentXml), + ); + }); return zipWriter.close(); } @@ -333,6 +359,30 @@ export class ODTExporter< return styleName; } + /** + * Registers an embedded object sub-document (e.g. a formula) and returns + * its path, for referencing from a `draw:object`'s `xlink:href`: + * + * ```tsx + * + * + * + * ``` + */ + public registerObject( + contentXml: string, + mediaType = "application/vnd.oasis.opendocument.formula", + ): string { + const path = `Object ${this.objects.length + 1}/`; + this.objects.push({ path, contentXml, mediaType }); + return path; + } + public async registerPicture(url: string): Promise<{ path: string; mimeType: string; diff --git a/packages/xl-odt-exporter/src/odt/util/createODTImageParagraph.tsx b/packages/xl-odt-exporter/src/odt/util/createODTImageParagraph.tsx new file mode 100644 index 0000000000..e3008cc342 --- /dev/null +++ b/packages/xl-odt-exporter/src/odt/util/createODTImageParagraph.tsx @@ -0,0 +1,68 @@ +import { ODTExporter } from "../odtExporter.js"; + +/** + * Registers a picture (from a URL or data URL) with the exporter and returns + * a paragraph embedding it, sized to the given dimensions (or the image's own + * by default) and aligned as requested. Lets custom block mappings embed + * images without having to build the ODT exporter's internal XML JSX elements + * themselves. + */ +export const createODTImageParagraph = async ( + exporter: ODTExporter, + url: string, + options?: { + /** + * Dimensions to render the image at. Defaults to the image's own. + */ + width?: number; + height?: number; + /** + * Horizontal alignment of the image within the paragraph. + * + * @default "left" + */ + align?: "left" | "center" | "right"; + }, +): Promise => { + const { path, mimeType, ...originalDimensions } = + await exporter.registerPicture(url); + const width = options?.width ?? originalDimensions.width; + const height = options?.height ?? originalDimensions.height; + + const align = options?.align ?? "left"; + const styleName = + align === "left" + ? "Standard" + : exporter.registerStyle((name) => ( + + + + )); + + return ( + + + + + + ); +}; diff --git a/packages/xl-odt-exporter/src/odt/util/jsx.d.ts b/packages/xl-odt-exporter/src/odt/util/jsx.d.ts index 8f996ea6df..5d89aaa19e 100644 --- a/packages/xl-odt-exporter/src/odt/util/jsx.d.ts +++ b/packages/xl-odt-exporter/src/odt/util/jsx.d.ts @@ -22,6 +22,7 @@ declare module "react/jsx-runtime" { "text:tab": any; "draw:frame": any; "draw:image": any; + "draw:object": any; "draw:text-box": any; "table:table": any; "table:table-row": any; @@ -30,6 +31,7 @@ declare module "react/jsx-runtime" { "manifest:manifest": any; "manifest:file-entry": any; "style:paragraph-properties": any; + "style:graphic-properties": any; "style:background-fill": any; "style:table-properties": any; "style:table-cell-properties": any; diff --git a/packages/xl-odt-exporter/vite.config.ts b/packages/xl-odt-exporter/vite.config.ts index e495c78429..1c24710f25 100644 --- a/packages/xl-odt-exporter/vite.config.ts +++ b/packages/xl-odt-exporter/vite.config.ts @@ -50,9 +50,21 @@ export default defineConfig( build: { sourcemap: true, lib: { - entry: path.resolve(__dirname, "src/index.ts"), + entry: { + "blocknote-xl-odt-exporter": path.resolve( + __dirname, + "src/index.ts", + ), + "diagram-block": path.resolve( + __dirname, + "src/diagram-block/index.ts", + ), + "math-block": path.resolve(__dirname, "src/math-block/index.tsx"), + }, name: "blocknote-xl-odt-exporter", - fileName: "blocknote-xl-odt-exporter", + formats: ["es", "cjs"], + fileName: (format, entryName) => + format === "es" ? `${entryName}.js` : `${entryName}.cjs`, }, rollupOptions: { // make sure to externalize deps that shouldn't be bundled diff --git a/packages/xl-pdf-exporter/package.json b/packages/xl-pdf-exporter/package.json index 213ba97465..fef516e6c8 100644 --- a/packages/xl-pdf-exporter/package.json +++ b/packages/xl-pdf-exporter/package.json @@ -45,6 +45,16 @@ "import": "./dist/style.css", "require": "./dist/style.css", "style": "./dist/style.css" + }, + "./diagram-block": { + "types": "./types/src/diagram-block/index.d.ts", + "import": "./dist/diagram-block.js", + "require": "./dist/diagram-block.cjs" + }, + "./math-block": { + "types": "./types/src/math-block/index.d.ts", + "import": "./dist/math-block.js", + "require": "./dist/math-block.cjs" } }, "scripts": { @@ -58,9 +68,11 @@ "dependencies": { "@blocknote/core": "workspace:^", "@blocknote/xl-multi-column": "workspace:^", - "@react-pdf/renderer": "^4.3.0" + "@react-pdf/renderer": "^4.5.1" }, "devDependencies": { + "@blocknote/diagram-block": "workspace:^", + "@react-pdf/math": "^2.0.1", "@blocknote/shared": "workspace:^", "@testing-library/react": "^16.3.0", "@types/jest-image-snapshot": "^6.4.0", @@ -79,7 +91,17 @@ }, "peerDependencies": { "react": "^18.0 || ^19.0 || >= 19.0.0-rc", - "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc", + "@blocknote/diagram-block": "workspace:^", + "@react-pdf/math": "^2.0.0" }, - "gitHead": "37614ab348dcc7faa830a9a88437b37197a2162d" + "gitHead": "37614ab348dcc7faa830a9a88437b37197a2162d", + "peerDependenciesMeta": { + "@blocknote/diagram-block": { + "optional": true + }, + "@react-pdf/math": { + "optional": true + } + } } diff --git a/packages/xl-pdf-exporter/src/diagram-block/index.tsx b/packages/xl-pdf-exporter/src/diagram-block/index.tsx new file mode 100644 index 0000000000..03cd3ce537 --- /dev/null +++ b/packages/xl-pdf-exporter/src/diagram-block/index.tsx @@ -0,0 +1,52 @@ +import { + getDiagramPlainTextContent, + renderDiagramToImage, +} from "@blocknote/diagram-block"; +import { Image } from "@react-pdf/renderer"; + +import { pdfBlockMappingForDefaultSchema } from "../pdf/defaultSchema/blocks.js"; + +const PIXELS_PER_POINT = 0.75; +const MAX_WIDTH_POINTS = 400; + +/** + * Block mapping for `@blocknote/diagram-block` that embeds diagrams as + * images instead of their Mermaid source: + * + * ```ts + * new PDFExporter(schema, { + * ...pdfDefaultSchemaMappings, + * blockMapping: { + * ...pdfDefaultSchemaMappings.blockMapping, + * diagram: diagramBlockMapping, + * }, + * }); + * ``` + * + * Rendering the diagram needs a browser; there (and for invalid sources), it + * falls back to the default source code rendering. Kept out of the default + * mappings so exporting without diagram blocks doesn't load Mermaid. + */ +export const diagramBlockMapping = async ( + ...args: Parameters +) => { + const [block] = args; + + try { + const { dataURL, width } = await renderDiagramToImage( + getDiagramPlainTextContent(block.content), + ); + + return ( + + ); + } catch { + return pdfBlockMappingForDefaultSchema.diagram(...args); + } +}; diff --git a/packages/xl-pdf-exporter/src/math-block/index.tsx b/packages/xl-pdf-exporter/src/math-block/index.tsx new file mode 100644 index 0000000000..521461d10c --- /dev/null +++ b/packages/xl-pdf-exporter/src/math-block/index.tsx @@ -0,0 +1,63 @@ +import { Math } from "@react-pdf/math"; +import { View } from "@react-pdf/renderer"; + +import { pdfBlockMappingForDefaultSchema } from "../pdf/defaultSchema/blocks.js"; + +// The math block's inline content as plain text (its LaTeX source). Local +// copy of `@blocknote/math-block`'s `getMathPlainTextContent` - importing the +// package would break headless (Node) exports, as its build carries a +// top-level CSS import. +const getPlainTextContent = (content: unknown): string => { + if (typeof content === "string") { + return content; + } + + if (Array.isArray(content)) { + return content + .map((node) => + node && typeof node === "object" && "text" in node ? node.text : "", + ) + .join(""); + } + + return ""; +}; + +/** + * Block mapping for `@blocknote/math-block` that renders math blocks as + * actual formulas (via `@react-pdf/math`, which converts the LaTeX to SVG + * paths with MathJax) instead of their LaTeX source: + * + * ```ts + * new PDFExporter(schema, { + * ...pdfDefaultSchemaMappings, + * blockMapping: { + * ...pdfDefaultSchemaMappings.blockMapping, + * math: mathBlockMapping, + * }, + * }); + * ``` + * + * Kept out of the default mappings so exporting without math blocks doesn't + * load MathJax. Invalid LaTeX renders as MathJax's own error output. + * + * There's no counterpart for inline math: react-pdf silently drops SVG + * elements inside `Text`, and paragraphs are rendered as `Text`, so inline + * math keeps the default mapping (its LaTeX source in a monospaced font). + */ +export const mathBlockMapping = ( + ...args: Parameters +) => { + const [block] = args; + + const source = getPlainTextContent(block.content); + if (!source.trim()) { + return pdfBlockMappingForDefaultSchema.math(...args); + } + + return ( + + {source} + + ); +}; diff --git a/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx b/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx index 71684acf42..131c45d6b8 100644 --- a/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx +++ b/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx @@ -1186,5 +1186,96 @@ + + + + + {`a^2 = \sqrt{b^2 + c^2}`} + + + + + + + + + Inline math:{' '} + + + + {`e^{i\pi} + 1 = 0`} + + + + + + + + + + graph TD + + + {`A[Start] --> B[End]`} + + + + \ No newline at end of file diff --git a/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx b/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx index dc11a90c93..0e7b485542 100644 --- a/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx +++ b/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx @@ -1194,6 +1194,97 @@ + + + + + {`a^2 = \sqrt{b^2 + c^2}`} + + + + + + + + + Inline math:{' '} + + + + {`e^{i\pi} + 1 = 0`} + + + + + + + + + + graph TD + + + {`A[Start] --> B[End]`} + + + + + + + + + + {`a^2 = \sqrt{b^2 + c^2}`} + + + + + + + + + Inline math:{' '} + + + + {`e^{i\pi} + 1 = 0`} + + + + + + + \ No newline at end of file diff --git a/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx b/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx index 34985a1753..ad3881671c 100644 --- a/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx +++ b/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx @@ -1,8 +1,10 @@ import { + BlockConfig, + BlockFromConfigNoChildren, BlockMapping, + createPageBreakBlockConfig, DefaultBlockSchema, DefaultProps, - createPageBreakBlockConfig, StyledText, } from "@blocknote/core"; import { multiColumnSchema } from "@blocknote/xl-multi-column"; @@ -19,10 +21,57 @@ import { Table } from "../util/table/Table.js"; const PIXELS_PER_POINT = 0.75; const FONT_SIZE = 16; +type BSchema = DefaultBlockSchema & { + pageBreak: ReturnType; + math: BlockConfig<"math", {}, "inline">; + diagram: BlockConfig<"diagram", {}, "inline">; +} & typeof multiColumnSchema.blockSchema; + +// Renders a block's inline content as monospaced source code. Used for both +// code blocks and math blocks (which store their LaTeX source as content). +const codeMapping = ( + block: BlockFromConfigNoChildren, +) => { + // Code blocks should always contain a single `StyledText` inline content. + // However, if this is not the case for whatever reason, we can merge the + // text content of all `StyledText` content in them. + const textContent = (block.content as StyledText[]) + .map((item) => item.text) + .join(""); + const lines = textContent.split("\n").map((line, index) => { + const indent = line.match(/^\s*/)?.[0].length || 0; + + return ( + + {line.trimStart() || <> } + + ); + }); + + return ( + + {lines} + + ); +}; + export const pdfBlockMappingForDefaultSchema: BlockMapping< - DefaultBlockSchema & { - pageBreak: ReturnType; - } & typeof multiColumnSchema.blockSchema, + BSchema, any, any, React.ReactElement, @@ -113,44 +162,10 @@ export const pdfBlockMappingForDefaultSchema: BlockMapping< ); }, - codeBlock: (block) => { - // Code blocks should always contain a single `StyledText` inline content. - // However, if this is not the case for whatever reason, we can merge the - // text content of all `StyledText` content in them. - const textContent = (block.content as StyledText[]) - .map((item) => item.text) - .join(""); - const lines = textContent.split("\n").map((line, index) => { - const indent = line.match(/^\s*/)?.[0].length || 0; - - return ( - - {line.trimStart() || <> } - - ); - }); - - return ( - - {lines} - - ); - }, + // TODO + codeBlock: codeMapping, + math: codeMapping, + diagram: codeMapping, pageBreak: () => { return ; }, diff --git a/packages/xl-pdf-exporter/src/pdf/defaultSchema/inlinecontent.tsx b/packages/xl-pdf-exporter/src/pdf/defaultSchema/inlinecontent.tsx index a1ea3cc7e2..5398c77d5a 100644 --- a/packages/xl-pdf-exporter/src/pdf/defaultSchema/inlinecontent.tsx +++ b/packages/xl-pdf-exporter/src/pdf/defaultSchema/inlinecontent.tsx @@ -4,8 +4,16 @@ import { } from "@blocknote/core"; import { Link, Text } from "@react-pdf/renderer"; +type ICSchema = DefaultInlineContentSchema & { + inlineMath: { + type: "inlineMath"; + propSchema: Record; + content: "styled"; + }; +}; + export const pdfInlineContentMappingForDefaultSchema: InlineContentMapping< - DefaultInlineContentSchema, + ICSchema, any, React.ReactElement | React.ReactElement, React.ReactElement @@ -20,4 +28,13 @@ export const pdfInlineContentMappingForDefaultSchema: InlineContentMapping< text: (ic, exporter) => { return exporter.transformStyledText(ic); }, + // TODO + // Renders inline math as its monospaced LaTeX source. + inlineMath: (ic, exporter) => { + return ( + + {ic.content.map((content) => exporter.transformStyledText(content))} + + ); + }, }; diff --git a/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx b/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx index 738e931a1e..09b270036a 100644 --- a/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx +++ b/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx @@ -13,6 +13,7 @@ import { Text } from "@react-pdf/renderer"; import { testDocument } from "@shared/testDocument.js"; import reactElementToJSXString from "react-element-to-jsx-string"; import { describe, expect, it } from "vite-plus/test"; +import { mathBlockMapping } from "../math-block/index.js"; import { pdfDefaultSchemaMappings } from "./defaultSchema/index.js"; import { PDFExporter } from "./pdfExporter.js"; import { partialBlocksToBlocksForTesting } from "@shared/formatConversionTestUtil.js"; @@ -159,6 +160,42 @@ describe("exporter", () => { new PDFExporter(schema, pdfDefaultSchemaMappings); }); + it("should export math as formulas with the math-block mappings", async () => { + // Assembled outside the constructor call as the schema doesn't include + // the math specs - like the default mappings, the math entries just map + // the block JSON. + const mappings = { + ...pdfDefaultSchemaMappings, + blockMapping: { + ...pdfDefaultSchemaMappings.blockMapping, + math: mathBlockMapping, + }, + }; + const exporter = new PDFExporter( + BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: createPageBreakBlockSpec(), + column: ColumnBlock, + columnList: ColumnListBlock, + }, + }), + mappings, + ); + + // The math block & inline math paragraph from the shared test document. + const transformed = await exporter.toReactPDFDocument( + testDocument.filter((block) => + ["math-block", "paragraph-with-inline-math"].includes(block.id), + ), + ); + const str = reactElementToJSXString(transformed); + + await expect(str).toMatchFileSnapshot( + "__snapshots__/exampleWithMathMappings.jsx", + ); + }); + it("should export a document", async () => { const exporter = new PDFExporter( BlockNoteSchema.create({ diff --git a/packages/xl-pdf-exporter/vite.config.ts b/packages/xl-pdf-exporter/vite.config.ts index 66fcb084de..71939de4a0 100644 --- a/packages/xl-pdf-exporter/vite.config.ts +++ b/packages/xl-pdf-exporter/vite.config.ts @@ -64,6 +64,8 @@ export default defineConfig( __dirname, "src/index.ts", ), + "diagram-block": path.resolve(__dirname, "src/diagram-block/index.tsx"), + "math-block": path.resolve(__dirname, "src/math-block/index.tsx"), }, name: "blocknote-xl-pdf-exporter", formats: ["es", "cjs"], diff --git a/playground/package.json b/playground/package.json index a14ad91238..bdd529a005 100644 --- a/playground/package.json +++ b/playground/package.json @@ -17,6 +17,8 @@ "@blocknote/code-block": "workspace:^", "@blocknote/core": "workspace:^", "@blocknote/mantine": "workspace:^", + "@blocknote/math-block": "workspace:^", + "@blocknote/diagram-block": "workspace:^", "@blocknote/react": "workspace:^", "@blocknote/server-util": "workspace:^", "@blocknote/shadcn": "workspace:^", @@ -26,6 +28,8 @@ "@blocknote/xl-multi-column": "workspace:^", "@blocknote/xl-odt-exporter": "workspace:^", "@blocknote/xl-pdf-exporter": "workspace:^", + "@react-pdf/math": "^2.0.1", + "katex": "^0.16.11", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", "@liveblocks/client": "^3.17.0", diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index eb6b499d53..18bca95175 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1127,9 +1127,12 @@ export const examples = { author: "yousefed", tags: ["Interoperability"], dependencies: { - "@blocknote/xl-pdf-exporter": "latest", + "@blocknote/diagram-block": "latest", + "@blocknote/math-block": "latest", "@blocknote/xl-multi-column": "latest", - "@react-pdf/renderer": "^4.3.0", + "@blocknote/xl-pdf-exporter": "latest", + "@react-pdf/math": "^2.0.1", + "@react-pdf/renderer": "^4.5.1", } as any, pro: true, }, @@ -1152,8 +1155,11 @@ export const examples = { author: "yousefed", tags: [""], dependencies: { + "@blocknote/diagram-block": "latest", + "@blocknote/math-block": "latest", "@blocknote/xl-docx-exporter": "latest", "@blocknote/xl-multi-column": "latest", + katex: "^0.16.11", } as any, pro: true, }, @@ -1176,8 +1182,11 @@ export const examples = { author: "areknawo", tags: [""], dependencies: { - "@blocknote/xl-odt-exporter": "latest", + "@blocknote/diagram-block": "latest", + "@blocknote/math-block": "latest", "@blocknote/xl-multi-column": "latest", + "@blocknote/xl-odt-exporter": "latest", + katex: "^0.16.11", } as any, pro: true, }, @@ -1445,6 +1454,64 @@ 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: "diagram-block", + fullSlug: "custom-schema/diagram-block", + pathFromRoot: "examples/06-custom-schema/10-diagram-block", + config: { + playground: true, + docs: true, + author: "yousefed", + tags: [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu", + ], + dependencies: { + "@blocknote/code-block": "latest", + "@blocknote/diagram-block": "latest", + "react-icons": "^5.5.0", + } as any, + }, + title: "Diagram Block", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + "In this example, we register the `@blocknote/diagram-block` block in a custom schema. The block renders diagrams from [Mermaid](https://mermaid.js.org/) source code, showing the rendered diagram in place of the source and revealing an editable source popup when selected - built from the same `SourceBlockWithPreview` component the math block uses, so the block itself is only a few dozen lines.\n\n**Try it out:** Click a diagram to edit its Mermaid source!\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/vercel.json b/playground/vercel.json index 3a48e56ba5..07573f9198 100644 --- a/playground/vercel.json +++ b/playground/vercel.json @@ -1,3 +1,6 @@ { + "installCommand": "cd .. && corepack enable && pnpm install", + "buildCommand": "cd .. && GOMAXPROCS=4 RAYON_NUM_THREADS=4 pnpm exec vp run --concurrency-limit 1 -r build", + "outputDirectory": "dist", "rewrites": [{ "source": "/(.*)", "destination": "/" }] } diff --git a/playground/vite.config.ts b/playground/vite.config.ts index c513f5c347..9517071488 100644 --- a/playground/vite.config.ts +++ b/playground/vite.config.ts @@ -35,6 +35,11 @@ const devAliases: Record = { __dirname, "../packages/xl-email-exporter/src", ), + "@blocknote/math-block": resolve(__dirname, "../packages/math-block/src"), + "@blocknote/diagram-block": resolve( + __dirname, + "../packages/diagram-block/src", + ), // "@liveblocks/react-blocknote": resolve( // __dirname, // "../../liveblocks/packages/liveblocks-react-blocknote/src/", @@ -70,7 +75,16 @@ export default defineConfig(((conf: { command: string }) => ({ }, }, }, - plugins: [react(), webpackStats(), Inspect(), tailwindcss()], + plugins: [ + react(), + // The stats are only consumed by RelativeCI, which uploads them from the + // GitHub Actions build. Serializing the (huge) module graph at the end of + // the build costs a lot of memory, which the Vercel build container can't + // spare - it fails spawning processes (EAGAIN) right at that point. + ...(process.env.VERCEL ? [] : [webpackStats()]), + Inspect(), + tailwindcss(), + ], optimizeDeps: { // Exclude @blocknote/* source-aliased packages from pre-bundling so that // when Vite pre-bundles @liveblocks/react-blocknote, it treats @@ -104,6 +118,24 @@ export default defineConfig(((conf: { command: string }) => ({ allowedHosts: ["host.docker.internal"], }, resolve: { - alias: conf.command === "build" ? undefined : devAliases, + alias: + conf.command === "build" + ? { + // TODO: review + // The exporters' optional peer dependencies, used by their + // subpath entries (`…/diagram-block`, `…/math-block`). They + // can't be resolved from the workspace-linked exporter packages + // when those packages' devDependencies aren't installed (e.g. + // Vercel's filtered install), making Vite substitute an empty + // `__vite-optional-peer-dep` stub that fails the build - so + // resolve them from the playground's own dependencies instead. + "@blocknote/diagram-block": resolve( + __dirname, + "../packages/diagram-block", + ), + "@react-pdf/math": resolve(__dirname, "node_modules/@react-pdf/math"), + katex: resolve(__dirname, "node_modules/katex"), + } + : devAliases, }, })) as Parameters[0]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 234764f35c..9cd30aca6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,9 +70,15 @@ importers: '@blocknote/core': specifier: workspace:* version: link:../packages/core + '@blocknote/diagram-block': + specifier: workspace:* + version: link:../packages/diagram-block '@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 @@ -148,9 +154,12 @@ importers: '@react-email/render': specifier: ^2.0.4 version: 2.0.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@react-pdf/math': + specifier: ^2.0.1 + version: 2.0.1(@react-pdf/renderer@4.5.1(react@19.2.5))(react@19.2.5) '@react-pdf/renderer': - specifier: ^4.3.0 - version: 4.3.2(react@19.2.5) + specifier: ^4.5.1 + version: 4.5.1(react@19.2.5) '@sentry/nextjs': specifier: ^10.34.0 version: 10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.105.4(esbuild@0.27.5)) @@ -223,6 +232,9 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 + docx: + specifier: ^9.6.1 + version: 9.6.1 framer-motion: specifier: ^12.26.2 version: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -241,9 +253,15 @@ importers: fumadocs-ui: specifier: npm:@fumadocs/base-ui@16.5.0 version: '@fumadocs/base-ui@16.5.0(@types/react@19.2.14)(fumadocs-core@16.5.0(@types/react@19.2.14)(lucide-react@0.562.0(react@19.2.5))(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6))(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.2)' + katex: + specifier: ^0.16.11 + version: 0.16.47 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.5) + mermaid: + specifier: ^11.0.0 + version: 11.16.0 motion: specifier: ^12.28.1 version: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -2725,9 +2743,15 @@ importers: '@blocknote/core': specifier: latest version: link:../../../packages/core + '@blocknote/diagram-block': + specifier: latest + version: link:../../../packages/diagram-block '@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 @@ -2746,9 +2770,12 @@ importers: '@mantine/hooks': specifier: ^9.0.2 version: 9.1.1(react@19.2.5) + '@react-pdf/math': + specifier: ^2.0.1 + version: 2.0.1(@react-pdf/renderer@4.5.1(react@19.2.5))(react@19.2.5) '@react-pdf/renderer': - specifier: ^4.3.0 - version: 4.3.2(react@19.2.5) + specifier: ^4.5.1 + version: 4.5.1(react@19.2.5) react: specifier: ^19.2.3 version: 19.2.5 @@ -2777,9 +2804,15 @@ importers: '@blocknote/core': specifier: latest version: link:../../../packages/core + '@blocknote/diagram-block': + specifier: latest + version: link:../../../packages/diagram-block '@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 @@ -2798,6 +2831,9 @@ importers: '@mantine/hooks': specifier: ^9.0.2 version: 9.1.1(react@19.2.5) + katex: + specifier: ^0.16.11 + version: 0.16.47 react: specifier: ^19.2.3 version: 19.2.5 @@ -2826,9 +2862,15 @@ importers: '@blocknote/core': specifier: latest version: link:../../../packages/core + '@blocknote/diagram-block': + specifier: latest + version: link:../../../packages/diagram-block '@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 @@ -2847,6 +2889,9 @@ importers: '@mantine/hooks': specifier: ^9.0.2 version: 9.1.1(react@19.2.5) + katex: + specifier: ^0.16.11 + version: 0.16.47 react: specifier: ^19.2.3 version: 19.2.5 @@ -3358,6 +3403,110 @@ 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/10-diagram-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/diagram-block': + specifier: latest + version: link:../../../packages/diagram-block + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@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': @@ -4633,8 +4782,8 @@ importers: specifier: ^0.2.99 version: 0.2.117 prosemirror-highlight: - specifier: ^0.15.1 - version: 0.15.1(@shikijs/types@4.0.2)(@types/hast@3.0.4)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8) + specifier: ^0.15.3 + version: 0.15.3(@shikijs/types@4.0.2)(@types/hast@3.0.4)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8) prosemirror-model: specifier: ^1.25.4 version: 1.25.4 @@ -4709,6 +4858,46 @@ 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/diagram-block: + dependencies: + '@blocknote/core': + specifier: workspace:^ + version: link:../core + mermaid: + specifier: ^11.0.0 + version: 11.16.0 + devDependencies: + '@blocknote/react': + specifier: workspace:^ + version: link:../react + '@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) + react: + specifier: ^19.2.5 + version: 19.2.5 + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) + react-icons: + specifier: ^5.5.0 + version: 5.6.0(react@19.2.5) + 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/mantine: dependencies: '@blocknote/core': @@ -4758,6 +4947,67 @@ 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 + '@handlewithcare/prosemirror-inputrules': + specifier: ^0.1.4 + version: 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8) + katex: + specifier: ^0.16.11 + version: 0.16.47 + 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 + prosemirror-view: + specifier: ^1.41.4 + version: 1.41.8 + devDependencies: + '@blocknote/react': + specifier: workspace:^ + version: link:../react + '@blocknote/xl-multi-column': + specifier: workspace:^ + version: link:../xl-multi-column + '@types/katex': + specifier: ^0.16.7 + version: 0.16.8 + '@types/react': + specifier: ^19.2.3 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + react: + specifier: ^19.2.5 + version: 19.2.5 + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) + react-icons: + specifier: ^5.5.0 + version: 5.6.0(react@19.2.5) + 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': @@ -5189,10 +5439,19 @@ importers: image-meta: specifier: ^0.2.2 version: 0.2.2 + mathml2omml: + specifier: ^0.5.0 + version: 0.5.0 devDependencies: + '@blocknote/diagram-block': + specifier: workspace:^ + version: link:../diagram-block '@blocknote/shared': specifier: workspace:^ version: link:../../shared + '@types/katex': + specifier: ^0.16.7 + version: 0.16.8 '@types/react': specifier: ^19.2.3 version: 19.2.14 @@ -5202,6 +5461,9 @@ importers: '@zip.js/zip.js': specifier: ^2.8.8 version: 2.8.26 + katex: + specifier: ^0.16.11 + version: 0.16.47 react: specifier: ^19.2.5 version: 19.2.5 @@ -5343,18 +5605,27 @@ importers: specifier: ^0.2.2 version: 0.2.2 devDependencies: + '@blocknote/diagram-block': + specifier: workspace:^ + version: link:../diagram-block '@blocknote/shared': specifier: workspace:^ version: link:../../shared '@testing-library/react': specifier: ^16.3.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@types/katex': + specifier: ^0.16.7 + version: 0.16.8 '@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) + katex: + specifier: ^0.16.11 + version: 0.16.47 react: specifier: ^19.2.5 version: 19.2.5 @@ -5386,12 +5657,18 @@ importers: specifier: workspace:^ version: link:../xl-multi-column '@react-pdf/renderer': - specifier: ^4.3.0 - version: 4.3.2(react@19.2.5) + specifier: ^4.5.1 + version: 4.5.1(react@19.2.5) devDependencies: + '@blocknote/diagram-block': + specifier: workspace:^ + version: link:../diagram-block '@blocknote/shared': specifier: workspace:^ version: link:../../shared + '@react-pdf/math': + specifier: ^2.0.1 + version: 2.0.1(@react-pdf/renderer@4.5.1(react@19.2.5))(react@19.2.5) '@testing-library/react': specifier: ^16.3.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -5455,9 +5732,15 @@ importers: '@blocknote/core': specifier: workspace:^ version: link:../packages/core + '@blocknote/diagram-block': + specifier: workspace:^ + version: link:../packages/diagram-block '@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 @@ -5518,6 +5801,9 @@ importers: '@mui/material': specifier: ^5.18.0 version: 5.18.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@react-pdf/math': + specifier: ^2.0.1 + version: 2.0.1(@react-pdf/renderer@4.5.1(react@19.2.5))(react@19.2.5) '@uppy/core': specifier: ^3.13.1 version: 3.13.1 @@ -5563,6 +5849,9 @@ importers: docx: specifier: ^9.5.1 version: 9.5.1 + katex: + specifier: ^0.16.11 + version: 0.16.47 react: specifier: ^19.2.5 version: 19.2.5 @@ -5644,9 +5933,15 @@ importers: '@blocknote/core': specifier: workspace:^ version: link:../packages/core + '@blocknote/diagram-block': + specifier: workspace:^ + version: link:../packages/diagram-block '@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 @@ -5760,6 +6055,9 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@ariakit/core@0.4.18': resolution: {integrity: sha512-9urEa+GbZTSyredq3B/3thQjTcSZSUC68XctwCkJNH/xNfKN5O+VThiem2rcJxpsGw8sRUQenhagZi0yB4foyg==} @@ -6088,10 +6386,16 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -6717,6 +7021,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.3': + resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -7122,6 +7432,9 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + '@mswjs/interceptors@0.37.6': resolution: {integrity: sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==} engines: {node: '>=18'} @@ -7279,6 +7592,10 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + '@noble/ciphers@2.1.1': resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} @@ -8786,48 +9103,54 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-pdf/fns@3.1.2': - resolution: {integrity: sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==} + '@react-pdf/fns@3.1.3': + resolution: {integrity: sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==} - '@react-pdf/font@4.0.4': - resolution: {integrity: sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==} + '@react-pdf/font@4.0.8': + resolution: {integrity: sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==} - '@react-pdf/image@3.0.4': - resolution: {integrity: sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==} + '@react-pdf/image@3.1.0': + resolution: {integrity: sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==} - '@react-pdf/layout@4.4.2': - resolution: {integrity: sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==} + '@react-pdf/layout@4.6.1': + resolution: {integrity: sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==} - '@react-pdf/pdfkit@4.1.0': - resolution: {integrity: sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==} + '@react-pdf/math@2.0.1': + resolution: {integrity: sha512-o+QQA03iT76RiKe8uhOgof+5X7GzA4SUjjPbffi6IGwcOotuxTKm3uhZAnx18ioylSpJf4BcVgUazzb5Ycv8qw==} + peerDependencies: + '@react-pdf/renderer': '>=4.5.1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@react-pdf/png-js@3.0.0': - resolution: {integrity: sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==} + '@react-pdf/pdfkit@5.1.1': + resolution: {integrity: sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==} - '@react-pdf/primitives@4.1.1': - resolution: {integrity: sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==} + '@react-pdf/primitives@4.3.0': + resolution: {integrity: sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==} '@react-pdf/reconciler@2.0.0': resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@react-pdf/render@4.3.2': - resolution: {integrity: sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==} + '@react-pdf/render@4.5.1': + resolution: {integrity: sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==} - '@react-pdf/renderer@4.3.2': - resolution: {integrity: sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==} + '@react-pdf/renderer@4.5.1': + resolution: {integrity: sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@react-pdf/stylesheet@6.1.2': - resolution: {integrity: sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==} + '@react-pdf/stylesheet@6.2.1': + resolution: {integrity: sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==} + + '@react-pdf/svg@1.1.0': + resolution: {integrity: sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==} - '@react-pdf/textkit@6.1.0': - resolution: {integrity: sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==} + '@react-pdf/textkit@6.3.0': + resolution: {integrity: sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==} - '@react-pdf/types@2.9.2': - resolution: {integrity: sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==} + '@react-pdf/types@2.11.1': + resolution: {integrity: sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==} '@reduxjs/toolkit@2.11.2': resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} @@ -9806,30 +10129,96 @@ packages: '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + '@types/d3-interpolate@3.0.4': resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} '@types/d3-path@3.1.1': resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + '@types/d3-shape@3.1.8': resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + '@types/d3-time@3.0.4': resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -9851,6 +10240,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -9881,6 +10273,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==} @@ -9975,6 +10370,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -10148,6 +10546,9 @@ packages: peerDependencies: '@uppy/core': ^3.13.0 + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/analytics@1.6.1': resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==} peerDependencies: @@ -10429,6 +10830,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==} @@ -10974,8 +11379,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - color-string@1.9.1: - resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} color-support@1.1.3: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} @@ -10999,6 +11409,14 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} @@ -11060,6 +11478,12 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} @@ -11074,9 +11498,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - crypto-js@4.2.0: - resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} - css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -11093,34 +11514,129 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + d3-color@3.1.0: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + d3-format@3.1.2: resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} engines: {node: '>=12'} + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + d3-interpolate@3.0.1: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + d3-path@3.1.0: resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} engines: {node: '>=12'} + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + d3-scale@4.0.2: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -11129,14 +11645,31 @@ packages: resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} engines: {node: '>=12'} - d3-time@3.1.0: - resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} engines: {node: '>=12'} - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} engines: {node: '>=12'} + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -11163,6 +11696,9 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debounce-fn@6.0.0: resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} engines: {node: '>=18'} @@ -11247,6 +11783,9 @@ packages: defu@6.1.6: resolution: {integrity: sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -11306,6 +11845,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -11511,6 +12053,10 @@ packages: jiti: optional: true + esm@3.2.25: + resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} + engines: {node: '>=6'} + espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -11978,6 +12524,9 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -12121,6 +12670,9 @@ packages: resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} engines: {node: '>=18'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -12148,6 +12700,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} @@ -12165,9 +12720,6 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-arrayish@0.3.4: - resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} - is-async-function@2.1.1: resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} @@ -12427,6 +12979,9 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-md5@0.8.3: + resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -12494,9 +13049,16 @@ 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==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -12509,6 +13071,12 @@ packages: resolution: {integrity: sha512-r2clcf7HLWvDXaVUEvQymXJY4i3bSOIV3xsL/Upy3ZfSv5HeKsk9tsqbBptLvth5qHEIhxeHTA2jNLyQABkLBA==} engines: {node: '>=20.0.0'} + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + leac@0.6.0: resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} @@ -12612,6 +13180,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -12702,10 +13273,25 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mathjax-full@3.2.2: + resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==} + deprecated: Version 4 replaces this package with the scoped package @mathjax/src + + mathml-to-latex@1.8.0: + resolution: {integrity: sha512-gQ0uK3zqB8HwlfaXJkEL5rgaZNbKUiBMmBP/B/W+b+t6KcseLSuYb1b0BjLgS9ZiQa24ePkqTX8/6FaQuDL7wQ==} + + mathml2omml@0.5.0: + resolution: {integrity: sha512-4eLs37a+TH+CL/M5XZZrlc75SNRJNPiZzIaeSSvH0UFCRaCdSYRHWvrlA7MUbeQks/z4sVhfg+GlcZhVVUHzqg==} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -12766,6 +13352,12 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + mermaid@11.16.0: + resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} + + mhchemparser@4.2.1: + resolution: {integrity: sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -12947,6 +13539,9 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} + mj-context-menu@0.6.1: + resolution: {integrity: sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -13293,6 +13888,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.7.0: + resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==} + pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} @@ -13325,6 +13923,9 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -13456,6 +14057,9 @@ packages: engines: {node: '>=18'} hasBin: true + png-js@2.0.0: + resolution: {integrity: sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==} + pngjs@3.4.0: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} @@ -13468,6 +14072,12 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -13566,8 +14176,8 @@ packages: prosemirror-gapcursor@1.4.1: resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} - prosemirror-highlight@0.15.1: - resolution: {integrity: sha512-KcJUGNgqLED+eK/cisNtY3M+eDNLkZyWCdyi7B3RoW3rKHnhkKawnJAcr9p1F/e3q+oDB5Y5OiIrC11bxP7tFA==} + prosemirror-highlight@0.15.3: + resolution: {integrity: sha512-WVV2st0fX1w2TkAgmTmdbj77BlWYuLfW4BGXPo8JfIWsSly5xYcfID8QJQL+GT8kisMRuu4jgT6JAqj1DOwvvg==} peerDependencies: '@lezer/common': ^1.0.0 '@lezer/highlight': ^1.0.0 @@ -13969,6 +14579,9 @@ packages: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.0.0-rc.15: resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -13991,6 +14604,9 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + rrweb-cssom@0.7.1: resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} @@ -14001,6 +14617,9 @@ packages: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -14149,9 +14768,6 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - simple-swizzle@0.2.4: - resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} - sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -14216,6 +14832,10 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + speech-rule-engine@4.1.4: + resolution: {integrity: sha512-i/VCLG1fvRc95pMHRqG4aQNscv+9aIsqA2oI7ZQS51sTdUcDHYX6cpT8/tqZ+enjs1tKVwbRBWgxut9SWn+f9g==} + hasBin: true + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -14353,6 +14973,9 @@ packages: stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -14535,6 +15158,10 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -14725,6 +15352,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -14979,6 +15610,9 @@ packages: engines: {node: '>=8'} hasBin: true + wicked-good-xpath@1.3.0: + resolution: {integrity: sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==} + wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} @@ -15206,6 +15840,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.7.0 + tinyexec: 1.0.4 + '@ariakit/core@0.4.18': {} '@ariakit/react-core@0.4.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': @@ -15879,10 +16518,14 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 + '@chevrotain/types@11.1.2': {} + '@csstools/color-helpers@5.1.0': {} '@csstools/color-helpers@6.0.2': {} @@ -16365,6 +17008,14 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.3': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': optional: true @@ -16893,6 +17544,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@mermaid-js/parser@1.2.0': + dependencies: + '@chevrotain/types': 11.1.2 + '@mswjs/interceptors@0.37.6': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -17028,6 +17683,8 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.7': optional: true + '@noble/ciphers@1.3.0': {} + '@noble/ciphers@2.1.1': {} '@noble/hashes@1.8.0': {} @@ -18473,48 +19130,54 @@ snapshots: dependencies: react: 19.2.5 - '@react-pdf/fns@3.1.2': {} + '@react-pdf/fns@3.1.3': {} - '@react-pdf/font@4.0.4': + '@react-pdf/font@4.0.8': dependencies: - '@react-pdf/pdfkit': 4.1.0 - '@react-pdf/types': 2.9.2 + '@react-pdf/pdfkit': 5.1.1 + '@react-pdf/types': 2.11.1 fontkit: 2.0.4 is-url: 1.2.4 - '@react-pdf/image@3.0.4': + '@react-pdf/image@3.1.0': dependencies: - '@react-pdf/png-js': 3.0.0 + '@react-pdf/svg': 1.1.0 jay-peg: 1.1.1 + png-js: 2.0.0 - '@react-pdf/layout@4.4.2': + '@react-pdf/layout@4.6.1': dependencies: - '@react-pdf/fns': 3.1.2 - '@react-pdf/image': 3.0.4 - '@react-pdf/primitives': 4.1.1 - '@react-pdf/stylesheet': 6.1.2 - '@react-pdf/textkit': 6.1.0 - '@react-pdf/types': 2.9.2 + '@react-pdf/fns': 3.1.3 + '@react-pdf/image': 3.1.0 + '@react-pdf/primitives': 4.3.0 + '@react-pdf/stylesheet': 6.2.1 + '@react-pdf/textkit': 6.3.0 + '@react-pdf/types': 2.11.1 emoji-regex-xs: 1.0.0 queue: 6.0.2 yoga-layout: 3.2.1 - '@react-pdf/pdfkit@4.1.0': + '@react-pdf/math@2.0.1(@react-pdf/renderer@4.5.1(react@19.2.5))(react@19.2.5)': + dependencies: + '@react-pdf/renderer': 4.5.1(react@19.2.5) + '@react-pdf/svg': 1.1.0 + mathjax-full: 3.2.2 + react: 19.2.5 + + '@react-pdf/pdfkit@5.1.1': dependencies: '@babel/runtime': 7.29.2 - '@react-pdf/png-js': 3.0.0 + '@noble/ciphers': 1.3.0 + '@noble/hashes': 1.8.0 browserify-zlib: 0.2.0 - crypto-js: 4.2.0 fontkit: 2.0.4 jay-peg: 1.1.1 + js-md5: 0.8.3 linebreak: 1.1.0 + png-js: 2.0.0 vite-compatible-readable-stream: 3.6.1 - '@react-pdf/png-js@3.0.0': - dependencies: - browserify-zlib: 0.2.0 - - '@react-pdf/primitives@4.1.1': {} + '@react-pdf/primitives@4.3.0': {} '@react-pdf/reconciler@2.0.0(react@19.2.5)': dependencies: @@ -18522,57 +19185,61 @@ snapshots: react: 19.2.5 scheduler: 0.25.0-rc-603e6108-20241029 - '@react-pdf/render@4.3.2': + '@react-pdf/render@4.5.1': dependencies: '@babel/runtime': 7.29.2 - '@react-pdf/fns': 3.1.2 - '@react-pdf/primitives': 4.1.1 - '@react-pdf/textkit': 6.1.0 - '@react-pdf/types': 2.9.2 + '@react-pdf/fns': 3.1.3 + '@react-pdf/primitives': 4.3.0 + '@react-pdf/textkit': 6.3.0 + '@react-pdf/types': 2.11.1 abs-svg-path: 0.1.1 - color-string: 1.9.1 + color-string: 2.1.4 normalize-svg-path: 1.1.0 parse-svg-path: 0.1.2 svg-arc-to-cubic-bezier: 3.2.0 - '@react-pdf/renderer@4.3.2(react@19.2.5)': + '@react-pdf/renderer@4.5.1(react@19.2.5)': dependencies: '@babel/runtime': 7.29.2 - '@react-pdf/fns': 3.1.2 - '@react-pdf/font': 4.0.4 - '@react-pdf/layout': 4.4.2 - '@react-pdf/pdfkit': 4.1.0 - '@react-pdf/primitives': 4.1.1 + '@react-pdf/fns': 3.1.3 + '@react-pdf/font': 4.0.8 + '@react-pdf/layout': 4.6.1 + '@react-pdf/pdfkit': 5.1.1 + '@react-pdf/primitives': 4.3.0 '@react-pdf/reconciler': 2.0.0(react@19.2.5) - '@react-pdf/render': 4.3.2 - '@react-pdf/types': 2.9.2 + '@react-pdf/render': 4.5.1 + '@react-pdf/types': 2.11.1 events: 3.3.0 object-assign: 4.1.1 prop-types: 15.8.1 queue: 6.0.2 react: 19.2.5 - '@react-pdf/stylesheet@6.1.2': + '@react-pdf/stylesheet@6.2.1': dependencies: - '@react-pdf/fns': 3.1.2 - '@react-pdf/types': 2.9.2 - color-string: 1.9.1 + '@react-pdf/fns': 3.1.3 + '@react-pdf/types': 2.11.1 + color-string: 2.1.4 hsl-to-hex: 1.0.0 media-engine: 1.0.3 postcss-value-parser: 4.2.0 - '@react-pdf/textkit@6.1.0': + '@react-pdf/svg@1.1.0': dependencies: - '@react-pdf/fns': 3.1.2 + '@react-pdf/primitives': 4.3.0 + + '@react-pdf/textkit@6.3.0': + dependencies: + '@react-pdf/fns': 3.1.3 bidi-js: 1.0.3 hyphen: 1.14.1 unicode-properties: 1.4.1 - '@react-pdf/types@2.9.2': + '@react-pdf/types@2.11.1': dependencies: - '@react-pdf/font': 4.0.4 - '@react-pdf/primitives': 4.1.1 - '@react-pdf/stylesheet': 6.1.2 + '@react-pdf/font': 4.0.8 + '@react-pdf/primitives': 4.3.0 + '@react-pdf/stylesheet': 6.2.1 '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)': dependencies: @@ -19632,7 +20299,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/cors@2.8.19': dependencies: @@ -19640,28 +20307,121 @@ snapshots: '@types/d3-array@3.2.2': {} + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + '@types/d3-color@3.1.3': {} + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + '@types/d3-ease@3.0.2': {} + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + '@types/d3-interpolate@3.0.4': dependencies: '@types/d3-color': 3.1.3 '@types/d3-path@3.1.1': {} + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + '@types/d3-scale@4.0.9': dependencies: '@types/d3-time': 3.0.4 + '@types/d3-selection@3.0.11': {} + '@types/d3-shape@3.1.8': dependencies: '@types/d3-path': 3.1.1 + '@types/d3-time-format@4.0.3': {} + '@types/d3-time@3.0.4': {} '@types/d3-timer@3.0.2': {} + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -19686,6 +20446,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -19723,6 +20485,8 @@ snapshots: '@types/json5@0.0.29': {} + '@types/katex@0.16.8': {} + '@types/lodash.foreach@4.5.9': dependencies: '@types/lodash': 4.17.24 @@ -19751,7 +20515,7 @@ snapshots: '@types/mysql@2.15.27': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/node@20.19.37': dependencies: @@ -19789,7 +20553,7 @@ snapshots: '@types/pg@8.15.6': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 pg-protocol: 1.13.0 pg-types: 2.2.0 @@ -19825,10 +20589,13 @@ snapshots: '@types/tedious@4.0.14': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -20015,6 +20782,11 @@ snapshots: '@uppy/core': 3.13.1 '@uppy/utils': 5.9.0 + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/analytics@1.6.1(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': optionalDependencies: next: 16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -20077,7 +20849,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 +20877,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 +21241,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': {} @@ -20986,10 +21759,11 @@ snapshots: color-name@1.1.4: {} - color-string@1.9.1: + color-name@2.1.0: {} + + color-string@2.1.4: dependencies: - color-name: 1.1.4 - simple-swizzle: 0.2.4 + color-name: 2.1.0 color-support@1.1.3: optional: true @@ -21006,6 +21780,10 @@ snapshots: commander@4.1.1: {} + commander@7.2.0: {} + + commander@8.3.0: {} + commondir@1.0.1: {} compressible@2.0.18: @@ -21074,6 +21852,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cosmiconfig@7.1.0: dependencies: '@types/parse-json': 4.0.2 @@ -21092,8 +21878,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - crypto-js@4.2.0: {} - css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -21108,22 +21892,107 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + + cytoscape@3.34.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + d3-array@3.2.4: dependencies: internmap: 2.0.3 + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + d3-color@3.1.0: {} + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + d3-ease@3.0.1: {} + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + d3-format@3.1.2: {} + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + d3-interpolate@3.0.1: dependencies: d3-color: 3.1.0 + d3-path@1.0.9: {} + d3-path@3.1.0: {} + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + d3-scale@4.0.2: dependencies: d3-array: 3.2.4 @@ -21132,6 +22001,12 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -21146,6 +22021,61 @@ snapshots: d3-timer@3.0.1: {} + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -21180,6 +22110,8 @@ snapshots: date-fns@4.1.0: {} + dayjs@1.11.21: {} + debounce-fn@6.0.0: dependencies: mimic-function: 5.0.1 @@ -21244,6 +22176,10 @@ snapshots: defu@6.1.6: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} delegates@1.0.0: @@ -21306,6 +22242,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -21652,6 +22592,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm@3.2.25: {} + espree@10.4.0: dependencies: acorn: 8.16.0 @@ -22112,6 +23054,8 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 + hachure-fill@0.5.2: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -22310,6 +23254,8 @@ snapshots: cjs-module-lexer: 2.2.0 module-details-from-path: 1.0.4 + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} inflight@1.0.6: @@ -22335,6 +23281,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@1.0.1: {} + internmap@2.0.3: {} is-alphabetical@2.0.1: {} @@ -22352,8 +23300,6 @@ snapshots: is-arrayish@0.2.1: {} - is-arrayish@0.3.4: {} - is-async-function@2.1.1: dependencies: async-function: 1.0.0 @@ -22607,6 +23553,8 @@ snapshots: js-base64@3.7.8: {} + js-md5@0.8.3: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -22705,16 +23653,26 @@ 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 + khroma@2.1.0: {} + kind-of@6.0.3: {} kleur@3.0.3: {} kysely@0.28.15: {} + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + leac@0.6.0: {} levn@0.4.1: @@ -22792,6 +23750,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.18.1: {} + lodash.debounce@4.0.8: {} lodash.isequal@4.5.0: {} @@ -22861,8 +23821,23 @@ snapshots: marked@15.0.12: {} + marked@16.4.2: {} + math-intrinsics@1.1.0: {} + mathjax-full@3.2.2: + dependencies: + esm: 3.2.25 + mhchemparser: 4.2.1 + mj-context-menu: 0.6.1 + speech-rule-engine: 4.1.4 + + mathml-to-latex@1.8.0: + dependencies: + '@xmldom/xmldom': 0.9.10 + + mathml2omml@0.5.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -23034,6 +24009,32 @@ snapshots: merge-stream@2.0.0: {} + mermaid@11.16.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.3 + '@mermaid-js/parser': 1.2.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.4.11 + es-toolkit: 1.45.1 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.1 + + mhchemparser@4.2.1: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -23361,6 +24362,8 @@ snapshots: yallist: 4.0.0 optional: true + mj-context-menu@0.6.1: {} + mkdirp-classic@0.5.3: {} mkdirp@1.0.4: @@ -24002,6 +25005,8 @@ snapshots: package-json-from-dist@1.0.1: {} + package-manager-detector@1.7.0: {} + pako@0.2.9: {} pako@1.0.11: {} @@ -24044,6 +25049,8 @@ snapshots: path-browserify@1.0.1: {} + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-expression-matcher@1.2.0: {} @@ -24163,12 +25170,23 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + png-js@2.0.0: + dependencies: + fflate: 0.8.3 + pngjs@3.4.0: {} pngjs@6.0.0: {} pngjs@7.0.0: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + possible-typed-array-names@1.1.0: {} postcss-selector-parser@7.1.1: @@ -24277,7 +25295,7 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-view: 1.41.8 - prosemirror-highlight@0.15.1(@shikijs/types@4.0.2)(@types/hast@3.0.4)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8): + prosemirror-highlight@0.15.3(@shikijs/types@4.0.2)(@types/hast@3.0.4)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8): optionalDependencies: '@shikijs/types': 4.0.2 '@types/hast': 3.0.4 @@ -24805,6 +25823,8 @@ snapshots: dependencies: glob: 10.5.0 + robust-predicates@3.0.3: {} + rolldown@1.0.0-rc.15: dependencies: '@oxc-project/types': 0.124.0 @@ -24865,12 +25885,21 @@ snapshots: rou3@0.7.12: {} + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + rrweb-cssom@0.7.1: {} rrweb-cssom@0.8.0: {} run-applescript@7.1.0: {} + rw@1.3.3: {} + rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -25100,10 +26129,6 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 - simple-swizzle@0.2.4: - dependencies: - is-arrayish: 0.3.4 - sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -25188,6 +26213,12 @@ snapshots: space-separated-tokens@2.0.2: {} + speech-rule-engine@4.1.4: + dependencies: + '@xmldom/xmldom': 0.9.10 + commander: 13.1.0 + wicked-good-xpath: 1.3.0 + split2@4.2.0: {} sprintf-js@1.0.3: {} @@ -25321,6 +26352,8 @@ snapshots: stylis@4.2.0: {} + stylis@4.4.0: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -25486,6 +26519,8 @@ snapshots: trough@2.2.0: {} + ts-dedent@2.3.0: {} + ts-interface-checker@0.1.13: {} ts-morph@27.0.2: @@ -25702,6 +26737,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@14.0.1: {} + uuid@9.0.1: {} valibot@1.3.1(typescript@5.9.3): @@ -26078,7 +27115,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 +27208,6 @@ snapshots: jsdom: 29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0) transitivePeerDependencies: - msw - optional: true w3c-keyname@2.2.8: {} @@ -26315,6 +27350,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wicked-good-xpath@1.3.0: {} + wide-align@1.1.5: dependencies: string-width: 4.2.3 diff --git a/shared/testDocument.ts b/shared/testDocument.ts index 754665a59e..d14d6b1384 100644 --- a/shared/testDocument.ts +++ b/shared/testDocument.ts @@ -1,7 +1,7 @@ import { BlockNoteSchema, - defaultBlockSpecs, createPageBreakBlockSpec, + defaultBlockSpecs, } from "@blocknote/core"; import { partialBlocksToBlocksForTesting } from "./formatConversionTestUtil.js"; @@ -303,3 +303,53 @@ export const testDocument = partialBlocksToBlocksForTesting( }, ], ); + +// TODO: fix this +// Math, inline math & diagram blocks, covered by the exporters' default +// mappings. Hand-built (rather than via `partialBlocksToBlocksForTesting`) +// as their specs live in separate packages that `shared` doesn't depend on - +// the exporters only need the block JSON. +const sourceBlocksForTesting = [ + { + id: "math-block", + type: "math", + props: {}, + content: [ + { type: "text", text: "a^2 = \\sqrt{b^2 + c^2}", styles: {} }, + ], + children: [], + }, + { + id: "paragraph-with-inline-math", + type: "paragraph", + props: { + backgroundColor: "default", + textColor: "default", + textAlignment: "left", + }, + content: [ + { type: "text", text: "Inline math: ", styles: {} }, + { + type: "inlineMath", + props: {}, + content: [{ type: "text", text: "e^{i\\pi} + 1 = 0", styles: {} }], + }, + ], + children: [], + }, + { + id: "diagram-block", + type: "diagram", + props: {}, + content: [ + { + type: "text", + text: "graph TD\n A[Start] --> B[End]", + styles: {}, + }, + ], + children: [], + }, +] as unknown as typeof testDocument; + +testDocument.push(...sourceBlocksForTesting); diff --git a/tests/package.json b/tests/package.json index ffcbcad408..8271b28a92 100644 --- a/tests/package.json +++ b/tests/package.json @@ -12,6 +12,8 @@ "@blocknote/ariakit": "workspace:^", "@blocknote/core": "workspace:^", "@blocknote/mantine": "workspace:^", + "@blocknote/diagram-block": "workspace:^", + "@blocknote/math-block": "workspace:^", "@blocknote/react": "workspace:^", "@blocknote/shadcn": "workspace:^", "@playwright/test": "1.60.0", 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..1468bc565e 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -121,7 +121,7 @@ "content": "inline", "propSchema": { "language": { - "default": "text", + "default": "javascript", }, }, "type": "codeBlock", diff --git a/tests/src/unit/core/schema/__snapshots__/inlinecontent.json b/tests/src/unit/core/schema/__snapshots__/inlinecontent.json index c682709782..6013f4791c 100644 --- a/tests/src/unit/core/schema/__snapshots__/inlinecontent.json +++ b/tests/src/unit/core/schema/__snapshots__/inlinecontent.json @@ -13,6 +13,7 @@ }, "type": "mention", }, + "extensions": undefined, "implementation": { "node": null, "parse": [Function], @@ -26,6 +27,7 @@ "propSchema": {}, "type": "tag", }, + "extensions": undefined, "implementation": { "node": null, "parse": [Function], diff --git a/tests/src/unit/react/clipboard/copy/__snapshots__/text/html/inlineMath.html b/tests/src/unit/react/clipboard/copy/__snapshots__/text/html/inlineMath.html new file mode 100644 index 0000000000..11e5a718b2 --- /dev/null +++ b/tests/src/unit/react/clipboard/copy/__snapshots__/text/html/inlineMath.html @@ -0,0 +1,28 @@ +The identity + + + + + + e + + i + π + + + + + 1 + = + 0 + + e^{i\pi} + 1 = 0 + + + +is elegant. \ No newline at end of file diff --git a/tests/src/unit/react/clipboard/copy/__snapshots__/text/html/mathBlock.html b/tests/src/unit/react/clipboard/copy/__snapshots__/text/html/mathBlock.html new file mode 100644 index 0000000000..29b0b21c93 --- /dev/null +++ b/tests/src/unit/react/clipboard/copy/__snapshots__/text/html/mathBlock.html @@ -0,0 +1,21 @@ + + + + + a + 2 + + + + + b + 2 + + = + + c + 2 + + + a^2 + b^2 = c^2 + + \ No newline at end of file diff --git a/tests/src/unit/react/clipboard/copy/copyTestInstances.ts b/tests/src/unit/react/clipboard/copy/copyTestInstances.ts new file mode 100644 index 0000000000..2819049338 --- /dev/null +++ b/tests/src/unit/react/clipboard/copy/copyTestInstances.ts @@ -0,0 +1,71 @@ +import { NodeSelection, TextSelection } from "@tiptap/pm/state"; + +import { CopyTestCase } from "../../../shared/clipboard/copy/copyTestCase.js"; +import { testCopyHTML } from "../../../shared/clipboard/copy/copyTestExecutors.js"; +import { getPosOfTextNode } from "../../../shared/testUtil.js"; +import { TestInstance } from "../../../types.js"; +import { + TestBlockSchema, + TestInlineContentSchema, + TestStyleSchema, +} from "../../testSchema.js"; + +export const copyTestInstancesHTML: TestInstance< + CopyTestCase, + TestBlockSchema, + TestInlineContentSchema, + TestStyleSchema +>[] = [ + { + testCase: { + name: "mathBlock", + document: [ + { + type: "math", + content: "a^2 + b^2 = c^2", + }, + ], + getCopySelection: (doc) => { + let startPos: number | undefined = undefined; + + doc.descendants((node, pos) => { + if (node.type.name === "math") { + startPos = pos; + } + }); + + if (startPos === undefined) { + throw new Error("Math node not found."); + } + + return NodeSelection.create(doc, startPos); + }, + }, + executeTest: testCopyHTML, + }, + { + testCase: { + name: "inlineMath", + document: [ + { + type: "paragraph", + content: [ + "The identity ", + { + type: "inlineMath", + content: "e^{i\\pi} + 1 = 0", + } as const, + " is elegant.", + ], + }, + ], + getCopySelection: (doc) => { + const startPos = getPosOfTextNode(doc, "The identity "); + const endPos = getPosOfTextNode(doc, " is elegant.", true); + + return TextSelection.create(doc, startPos, endPos); + }, + }, + executeTest: testCopyHTML, + }, +]; diff --git a/tests/src/unit/react/clipboard/copy/runTests.test.ts b/tests/src/unit/react/clipboard/copy/runTests.test.ts new file mode 100644 index 0000000000..f26b0e61e3 --- /dev/null +++ b/tests/src/unit/react/clipboard/copy/runTests.test.ts @@ -0,0 +1,15 @@ +import { describe, it } from "vite-plus/test"; + +import { setupTestEditor } from "../../setupTestEditor.js"; +import { testSchema } from "../../testSchema.js"; +import { copyTestInstancesHTML } from "./copyTestInstances.js"; + +describe("React copy tests (HTML)", () => { + const getEditor = setupTestEditor(testSchema); + + for (const { testCase, executeTest } of copyTestInstancesHTML) { + it(`${testCase.name}`, async () => { + await executeTest(getEditor(), testCase); + }); + } +}); diff --git a/tests/src/unit/react/clipboard/copyPasteEquality/copyPasteEqualityTestInstances.ts b/tests/src/unit/react/clipboard/copyPasteEquality/copyPasteEqualityTestInstances.ts new file mode 100644 index 0000000000..8f9bc636ab --- /dev/null +++ b/tests/src/unit/react/clipboard/copyPasteEquality/copyPasteEqualityTestInstances.ts @@ -0,0 +1,34 @@ +import { CopyPasteEqualityTestCase } from "../../../shared/clipboard/copyPasteEquality/copyPasteEqualityTestCase.js"; +import { testCopyPasteEquality } from "../../../shared/clipboard/copyPasteEquality/copyPasteEqualityTestExecutors.js"; +import { TestInstance } from "../../../types.js"; +import { + TestBlockSchema, + TestInlineContentSchema, + TestStyleSchema, +} from "../../testSchema.js"; +import { copyTestInstancesHTML } from "../copy/copyTestInstances.js"; + +// NOTE: Only the block is covered in these tests. Inline `` is foreign (non-HTML) content, +// and ProseMirror's clipboard parser in the jsdom unit environment doesn't run the inline +// content's `parseContent` for it, so pasting the copied inline math doubles its source here. It +// round-trips correctly in a real browser and should be covered by the browser-mode e2e suite +// instead. +export const copyPasteEqualityTestInstances: TestInstance< + CopyPasteEqualityTestCase< + TestBlockSchema, + TestInlineContentSchema, + TestStyleSchema + >, + TestBlockSchema, + TestInlineContentSchema, + TestStyleSchema +>[] = copyTestInstancesHTML + .filter(({ testCase }) => testCase.name === "mathBlock") + .map(({ testCase }) => ({ + testCase: { + name: testCase.name, + document: testCase.document, + getCopyAndPasteSelection: testCase.getCopySelection, + }, + executeTest: testCopyPasteEquality, + })); diff --git a/tests/src/unit/react/clipboard/copyPasteEquality/runTests.test.ts b/tests/src/unit/react/clipboard/copyPasteEquality/runTests.test.ts new file mode 100644 index 0000000000..dfb3241a05 --- /dev/null +++ b/tests/src/unit/react/clipboard/copyPasteEquality/runTests.test.ts @@ -0,0 +1,15 @@ +import { describe, it } from "vite-plus/test"; + +import { setupTestEditor } from "../../setupTestEditor.js"; +import { testSchema } from "../../testSchema.js"; +import { copyPasteEqualityTestInstances } from "./copyPasteEqualityTestInstances.js"; + +describe("React copy/paste equality tests", () => { + const getEditor = setupTestEditor(testSchema); + + for (const { testCase, executeTest } of copyPasteEqualityTestInstances) { + it(`${testCase.name}`, async () => { + await executeTest(getEditor(), testCase); + }); + } +}); diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/diagram/basic.html b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/diagram/basic.html new file mode 100644 index 0000000000..608a912f53 --- /dev/null +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/diagram/basic.html @@ -0,0 +1,55 @@ +
+
+
+
+
+
+
+ +

Add a Mermaid diagram

+
+
+
+
+
+                graph TD
+  A[Start] --> B[End]
+              
+
+ +
+
+ +
+
+
+
+
+
\ No newline at end of file diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/inlineMath/basic.html b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/inlineMath/basic.html new file mode 100644 index 0000000000..cc283a6cb6 --- /dev/null +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/inlineMath/basic.html @@ -0,0 +1,104 @@ +
+
+
+
+

+ The identity + + + + + + + + + + + e + + i + π + + + + + 1 + = + 0 + + e^{i\pi} + 1 = 0 + + + + + + + +

+
+
+                    e^{i\pi} + 1 = 0
+                  
+
+ +
+
+ +
+ + + is elegant. +

+
+
+
+
\ No newline at end of file diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/math/basic.html b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/math/basic.html new file mode 100644 index 0000000000..33a64a28ca --- /dev/null +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/math/basic.html @@ -0,0 +1,132 @@ +
+
+
+
+
+
+ + + + + + + + + a + 2 + + + + + b + 2 + + = + + c + 2 + + + a^2 + b^2 = c^2 + + + + + + + +
+
+
+
+                a^2 + b^2 = c^2
+              
+
+ +
+
+ +
+
+
+
+
+
\ No newline at end of file diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/html/diagram/basic.html b/tests/src/unit/react/formatConversion/export/__snapshots__/html/diagram/basic.html new file mode 100644 index 0000000000..eb3b214fd7 --- /dev/null +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/html/diagram/basic.html @@ -0,0 +1,4 @@ +
+  graph TD
+  A[Start] --> B[End]
+
\ No newline at end of file diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/html/inlineMath/basic.html b/tests/src/unit/react/formatConversion/export/__snapshots__/html/inlineMath/basic.html new file mode 100644 index 0000000000..e216b237ff --- /dev/null +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/html/inlineMath/basic.html @@ -0,0 +1,30 @@ +

+ The identity + + + + + + e + + i + π + + + + + 1 + = + 0 + + e^{i\pi} + 1 = 0 + + + + is elegant. +

\ No newline at end of file diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/html/math/basic.html b/tests/src/unit/react/formatConversion/export/__snapshots__/html/math/basic.html new file mode 100644 index 0000000000..29b0b21c93 --- /dev/null +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/html/math/basic.html @@ -0,0 +1,21 @@ + + + + + a + 2 + + + + + b + 2 + + = + + c + 2 + + + a^2 + b^2 = c^2 + + \ No newline at end of file diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/markdown/diagram/basic.md b/tests/src/unit/react/formatConversion/export/__snapshots__/markdown/diagram/basic.md new file mode 100644 index 0000000000..0c7bf63597 --- /dev/null +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/markdown/diagram/basic.md @@ -0,0 +1,4 @@ +```mermaid +graph TD + A[Start] --> B[End] +``` diff --git a/tests/src/unit/react/formatConversion/export/exportTestInstances.ts b/tests/src/unit/react/formatConversion/export/exportTestInstances.ts index d822928fb6..30b339b8e3 100644 --- a/tests/src/unit/react/formatConversion/export/exportTestInstances.ts +++ b/tests/src/unit/react/formatConversion/export/exportTestInstances.ts @@ -2,6 +2,7 @@ import { ExportTestCase } from "../../../shared/formatConversion/export/exportTe import { testExportBlockNoteHTML, testExportHTML, + testExportMarkdown, } from "../../../shared/formatConversion/export/exportTestExecutors.js"; import { TestInstance } from "../../../types.js"; import { @@ -470,6 +471,49 @@ export const exportTestInstancesBlockNoteHTML: TestInstance< }, executeTest: testExportBlockNoteHTML, }, + { + testCase: { + name: "math/basic", + content: [ + { + type: "math", + content: "a^2 + b^2 = c^2", + }, + ], + }, + executeTest: testExportBlockNoteHTML, + }, + { + testCase: { + name: "inlineMath/basic", + content: [ + { + type: "paragraph", + content: [ + "The identity ", + { + type: "inlineMath", + content: "e^{i\\pi} + 1 = 0", + } as const, + " is elegant.", + ], + }, + ], + }, + executeTest: testExportBlockNoteHTML, + }, + { + testCase: { + name: "diagram/basic", + content: [ + { + type: "diagram", + content: "graph TD\n A[Start] --> B[End]", + }, + ], + }, + executeTest: testExportBlockNoteHTML, + }, ]; export const exportTestInstancesHTML: TestInstance< @@ -481,3 +525,25 @@ export const exportTestInstancesHTML: TestInstance< testCase, executeTest: testExportHTML, })); + +// Markdown export runs the external HTML through remark, so the diagram's +// fenced-code representation should come out as a ```mermaid fence. +export const exportTestInstancesMarkdown: TestInstance< + ExportTestCase, + TestBlockSchema, + TestInlineContentSchema, + TestStyleSchema +>[] = [ + { + testCase: { + name: "diagram/basic", + content: [ + { + type: "diagram", + content: "graph TD\n A[Start] --> B[End]", + }, + ], + }, + executeTest: testExportMarkdown, + }, +]; diff --git a/tests/src/unit/react/formatConversion/export/runTests.test.ts b/tests/src/unit/react/formatConversion/export/runTests.test.ts index 514171d525..4a63a80858 100644 --- a/tests/src/unit/react/formatConversion/export/runTests.test.ts +++ b/tests/src/unit/react/formatConversion/export/runTests.test.ts @@ -5,6 +5,7 @@ import { testSchema } from "../../testSchema.js"; import { exportTestInstancesBlockNoteHTML, exportTestInstancesHTML, + exportTestInstancesMarkdown, } from "./exportTestInstances.js"; describe("React export tests (BlockNote HTML)", () => { @@ -26,3 +27,13 @@ describe("React export tests (HTML)", () => { }); } }); + +describe("React export tests (Markdown)", () => { + const getEditor = setupTestEditor(testSchema); + + for (const { testCase, executeTest } of exportTestInstancesMarkdown) { + it(`${testCase.name}`, async () => { + await executeTest(getEditor(), testCase); + }); + } +}); diff --git a/tests/src/unit/react/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts b/tests/src/unit/react/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts index 2deca013d9..4c59e55659 100644 --- a/tests/src/unit/react/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts +++ b/tests/src/unit/react/formatConversion/exportParseEquality/exportParseEqualityTestInstances.ts @@ -20,10 +20,12 @@ export const exportParseEqualityTestInstancesBlockNoteHTML: TestInstance< TestBlockSchema, TestInlineContentSchema, TestStyleSchema ->[] = exportTestInstancesBlockNoteHTML.map(({ testCase }) => ({ - testCase, - executeTest: testExportParseEqualityBlockNoteHTML, -})); +>[] = exportTestInstancesBlockNoteHTML + .filter(({ testCase }) => testCase.name !== "inlineMath/basic") + .map(({ testCase }) => ({ + testCase, + executeTest: testExportParseEqualityBlockNoteHTML, + })); export const exportParseEqualityTestInstancesNodes: TestInstance< ExportParseEqualityTestCase< @@ -34,7 +36,7 @@ export const exportParseEqualityTestInstancesNodes: TestInstance< TestBlockSchema, TestInlineContentSchema, TestStyleSchema ->[] = exportParseEqualityTestInstancesBlockNoteHTML.map(({ testCase }) => ({ +>[] = exportTestInstancesBlockNoteHTML.map(({ testCase }) => ({ testCase, executeTest: testExportParseEqualityNodes, })); diff --git a/tests/src/unit/react/formatConversion/parse/__snapshots__/html/diagramBlock.json b/tests/src/unit/react/formatConversion/parse/__snapshots__/html/diagramBlock.json new file mode 100644 index 0000000000..4c8f430455 --- /dev/null +++ b/tests/src/unit/react/formatConversion/parse/__snapshots__/html/diagramBlock.json @@ -0,0 +1,16 @@ +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "graph TD + A[Start] --> B[End]", + "type": "text", + }, + ], + "id": "1", + "props": {}, + "type": "diagram", + }, +] \ No newline at end of file diff --git a/tests/src/unit/react/formatConversion/parse/__snapshots__/html/mathBlock.json b/tests/src/unit/react/formatConversion/parse/__snapshots__/html/mathBlock.json new file mode 100644 index 0000000000..41c476feac --- /dev/null +++ b/tests/src/unit/react/formatConversion/parse/__snapshots__/html/mathBlock.json @@ -0,0 +1,15 @@ +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "a^2 + b^2 = c^2", + "type": "text", + }, + ], + "id": "1", + "props": {}, + "type": "math", + }, +] \ No newline at end of file diff --git a/tests/src/unit/react/formatConversion/parse/__snapshots__/markdown/codeBlockNotDiagram.json b/tests/src/unit/react/formatConversion/parse/__snapshots__/markdown/codeBlockNotDiagram.json new file mode 100644 index 0000000000..d48d7ebb30 --- /dev/null +++ b/tests/src/unit/react/formatConversion/parse/__snapshots__/markdown/codeBlockNotDiagram.json @@ -0,0 +1,17 @@ +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "console.log('hi');", + "type": "text", + }, + ], + "id": "1", + "props": { + "language": "javascript", + }, + "type": "codeBlock", + }, +] \ No newline at end of file diff --git a/tests/src/unit/react/formatConversion/parse/__snapshots__/markdown/diagramBlock.json b/tests/src/unit/react/formatConversion/parse/__snapshots__/markdown/diagramBlock.json new file mode 100644 index 0000000000..4c8f430455 --- /dev/null +++ b/tests/src/unit/react/formatConversion/parse/__snapshots__/markdown/diagramBlock.json @@ -0,0 +1,16 @@ +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "graph TD + A[Start] --> B[End]", + "type": "text", + }, + ], + "id": "1", + "props": {}, + "type": "diagram", + }, +] \ No newline at end of file diff --git a/tests/src/unit/react/formatConversion/parse/parseTestInstances.ts b/tests/src/unit/react/formatConversion/parse/parseTestInstances.ts new file mode 100644 index 0000000000..4cfd6479d0 --- /dev/null +++ b/tests/src/unit/react/formatConversion/parse/parseTestInstances.ts @@ -0,0 +1,64 @@ +import { ParseTestCase } from "../../../shared/formatConversion/parse/parseTestCase.js"; +import { + testParseHTML, + testParseMarkdown, +} from "../../../shared/formatConversion/parse/parseTestExecutors.js"; +import { TestInstance } from "../../../types.js"; +import { + TestBlockSchema, + TestInlineContentSchema, + TestStyleSchema, +} from "../../testSchema.js"; + +// NOTE: Only the block is covered in these tests. Inline `` is foreign (non-HTML) content, +// and ProseMirror's clipboard parser in the jsdom unit environment doesn't run the inline +// content's `parseContent` for it, so pasting the copied inline math doubles its source here. It +// round-trips correctly in a real browser and should be covered by the browser-mode e2e suite +// instead. +export const parseTestInstancesHTML: TestInstance< + ParseTestCase, + TestBlockSchema, + TestInlineContentSchema, + TestStyleSchema +>[] = [ + { + testCase: { + name: "mathBlock", + content: `a2+b2=c2a^2 + b^2 = c^2`, + }, + executeTest: testParseHTML, + }, + { + testCase: { + name: "diagramBlock", + content: `
graph TD\n  A[Start] --> B[End]
`, + }, + executeTest: testParseHTML, + }, +]; + +// The diagram block's fenced-code representation should also round-trip from +// Markdown. +export const parseTestInstancesMarkdown: TestInstance< + ParseTestCase, + TestBlockSchema, + TestInlineContentSchema, + TestStyleSchema +>[] = [ + { + testCase: { + name: "diagramBlock", + content: "```mermaid\ngraph TD\n A[Start] --> B[End]\n```", + }, + executeTest: testParseMarkdown, + }, + // The reverse precedence check: with the diagram block registered, fences + // in other languages must still fall through to the code block. + { + testCase: { + name: "codeBlockNotDiagram", + content: "```javascript\nconsole.log('hi');\n```", + }, + executeTest: testParseMarkdown, + }, +]; diff --git a/tests/src/unit/react/formatConversion/parse/runTests.test.ts b/tests/src/unit/react/formatConversion/parse/runTests.test.ts new file mode 100644 index 0000000000..7f13214bea --- /dev/null +++ b/tests/src/unit/react/formatConversion/parse/runTests.test.ts @@ -0,0 +1,30 @@ +import { describe, it } from "vite-plus/test"; + +import { setupTestEditor } from "../../setupTestEditor.js"; +import { testSchema } from "../../testSchema.js"; +import { + parseTestInstancesHTML, + parseTestInstancesMarkdown, +} from "./parseTestInstances.js"; + +// Tests for verifying that the React math block and inline math implementations +// are correctly parsed from external HTML (MathML). +describe("React parse tests (HTML)", () => { + const getEditor = setupTestEditor(testSchema); + + for (const { testCase, executeTest } of parseTestInstancesHTML) { + it(`${testCase.name}`, async () => { + await executeTest(getEditor(), testCase); + }); + } +}); + +describe("React parse tests (Markdown)", () => { + const getEditor = setupTestEditor(testSchema); + + for (const { testCase, executeTest } of parseTestInstancesMarkdown) { + it(`${testCase.name}`, async () => { + await executeTest(getEditor(), testCase); + }); + } +}); diff --git a/tests/src/unit/react/testSchema.tsx b/tests/src/unit/react/testSchema.tsx index aa6ebff0f8..8f696d1fd3 100644 --- a/tests/src/unit/react/testSchema.tsx +++ b/tests/src/unit/react/testSchema.tsx @@ -3,6 +3,11 @@ import { createPageBreakBlockSpec, defaultProps, } from "@blocknote/core"; +import { createReactDiagramBlockSpec } from "@blocknote/diagram-block"; +import { + createReactInlineMathSpec, + createReactMathBlockSpec, +} from "@blocknote/math-block"; import { createReactBlockSpec, createReactInlineContentSpec, @@ -168,10 +173,13 @@ export const testSchema = BlockNoteSchema.create().extend({ customParagraph: createCustomParagraph(), simpleCustomParagraph: createSimpleCustomParagraph(), contextParagraph: createContextParagraph(), + math: createReactMathBlockSpec(), + diagram: createReactDiagramBlockSpec(), }, inlineContentSpecs: { mention: Mention, tag: Tag, + inlineMath: createReactInlineMathSpec(), }, styleSpecs: { small: Small, diff --git a/tests/vite.config.ts b/tests/vite.config.ts index 650c20d409..b5f74c0135 100644 --- a/tests/vite.config.ts +++ b/tests/vite.config.ts @@ -53,6 +53,14 @@ export default defineConfig( __dirname, "../packages/mantine/src/", ), + "@blocknote/math-block": path.resolve( + __dirname, + "../packages/math-block/src/", + ), + "@blocknote/diagram-block": path.resolve( + __dirname, + "../packages/diagram-block/src/", + ), "@blocknote/server-util": path.resolve( __dirname, "../packages/server-util/src/",