(frontend) add math and diagram blocks to the editor

Last version of Blocknote added support for math
and diagram blocks. This commit updates the
dependencies to include the necessary toolbar
items to create math and diagram blocks in
the editor.
This commit is contained in:
Anthony LC
2026-09-03 13:30:47 +02:00
parent ac580bc81b
commit ee624e875e
21 changed files with 977 additions and 21 deletions
+1
View File
@@ -11,6 +11,7 @@ and this project adheres to
- ✨(frontend) Add "Copy link to block" feature #2547
- ✨(frontend) add word count to doc header toolbox #2549
- ✨(frontend) add find and replace feature to the editor #2570
- ✨(frontend) add math and diagram blocks to the editor #2617
### Changed
File diff suppressed because one or more lines are too long
@@ -28,7 +28,7 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl ege
**Code block:**
```js
```javascript
const hello_world = () => {
console.log("Hello, world!");
}
@@ -709,7 +709,7 @@ test.describe('Doc Editor', () => {
page,
browserName,
}) => {
await createDoc(page, 'doc-scroll', browserName, 1);
await createDoc(page, 'doc-copy-link-to-block', browserName, 1);
const editor = await writeInEditor({ page, text: 'First Block' });
@@ -745,4 +745,45 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('First Block')).not.toBeInViewport();
await expect(editor.getByText('My Block')).toBeInViewport();
});
test('it checks "Equation block" feature', async ({ page, browserName }) => {
await createDoc(page, 'doc-equation', browserName, 1);
const { editor } = await openSuggestionMenu({
page,
suggestion: 'Block Equation',
});
await editor.getByLabel('E = mc^2').fill('E = mc^2');
await editor.locator('.bn-code-block-source-popup-ok-button').click();
await expect(
editor.locator('.katex-html').filter({ hasText: 'E=mc2' }),
).toBeVisible();
});
test('it checks "Diagram block" feature', async ({ page, browserName }) => {
await createDoc(page, 'doc-diagram', browserName, 1);
const { editor } = await openSuggestionMenu({
page,
suggestion: 'Diagram',
});
await editor.getByRole('img', { name: 'Mermaid diagram' }).click();
const diagramCode = editor.getByLabel('Enter diagram code');
await diagramCode.click();
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.press('Backspace');
await page.keyboard.type('graph TD');
await page.keyboard.press('Enter');
await page.keyboard.type(' A[Hello] --> B[World]');
await editor.locator('.bn-code-block-source-popup-ok-button').click();
await expect(
editor.getByLabel('Mermaid diagram').filter({ hasText: 'HelloWorld' }),
).toBeVisible();
});
});
@@ -360,7 +360,7 @@ test.describe('Doc Export', () => {
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
await page.waitForTimeout(1000);
await page.waitForTimeout(3000);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
@@ -357,6 +357,9 @@ test.describe('Doc Tree', () => {
const treeRow1 = await getTreeRow(page, docChild1);
const treeRow2 = await getTreeRow(page, docChild2);
// Wait for the tree to be stable
await page.waitForTimeout(500);
await docTree.click();
await page.keyboard.press('Tab');
await expect(rootItem).toBeFocused();
@@ -11,9 +11,14 @@ export const getEditor = async ({ page }: { page: Page }) => {
export const tryFocusEditorContent = async ({ page }: { page: Page }) => {
const editor = await getEditor({ page });
if (
(await editor.locator('.bn-trailing-block.ProseMirror-widget').count()) > 0
(await editor
.locator('.bn-block-outer div[data-content-type="paragraph"]')
.count()) > 0
) {
await editor.locator('.bn-trailing-block.ProseMirror-widget').click();
await editor
.locator('.bn-block-outer div[data-content-type="paragraph"]')
.last()
.click();
} else {
await editor.click();
}
@@ -45,7 +45,7 @@ export const overrideDocContent = async ({
1,
);
await page.waitForTimeout(1000);
await expect(page.getByText('copy/pasting out of doc')).toBeVisible();
// Add Image SVG
await openSuggestionMenu({
@@ -178,7 +178,7 @@ export const comparePDFWithAssetFolder = async ({
const maxDiffRatio = 0.0005;
try {
expect(numDiffPixels).toBeLessThan(0.0005);
expect(diffRatio).toBeLessThan(maxDiffRatio);
} catch {
if (testInfo) {
const pageNo = String(i + 1).padStart(2, '0');
+4
View File
@@ -11,6 +11,10 @@ const nextConfig = {
allowedDevOrigins: ['docs.127.0.0.1.nip.io'],
output: 'export',
trailingSlash: true,
// `@blocknote/math-block` imports `katex/dist/katex.min.css` from its entry
// point; transpiling it lets Next.js accept that global CSS import from
// within node_modules.
transpilePackages: ['@blocknote/math-block'],
images: {
unoptimized: true,
},
+5
View File
@@ -26,7 +26,9 @@
"@ai-sdk/openai": "3.0.71",
"@blocknote/code-block": "0.54.0",
"@blocknote/core": "0.54.0",
"@blocknote/diagram-block": "0.54.0",
"@blocknote/mantine": "0.54.0",
"@blocknote/math-block": "0.54.0",
"@blocknote/react": "0.54.0",
"@blocknote/xl-ai": "0.54.0",
"@blocknote/xl-docx-exporter": "0.54.0",
@@ -46,6 +48,7 @@
"@mantine/core": "9.5.2",
"@mantine/hooks": "9.5.2",
"@react-aria/live-announcer": "3.5.1",
"@react-pdf/math": "2.0.1",
"@react-pdf/renderer": "4.9.0",
"@sentry/nextjs": "10.70.0",
"@tanstack/react-query": "5.102.2",
@@ -63,6 +66,8 @@
"idb": "8.0.3",
"lodash": "4.18.1",
"luxon": "3.7.2",
"mathjax-full": "3.2.2",
"mathml2omml": "0.5.0",
"next": "16.3.2",
"posthog-js": "1.418.10",
"react": "*",
@@ -1,4 +1,4 @@
import { codeBlockOptions } from '@blocknote/code-block';
import { codeBlockOptions, syntaxHighlighter } from '@blocknote/code-block';
import {
BlockNoteSchema,
createCodeBlockSpec,
@@ -10,8 +10,17 @@ import { CommentsExtension } from '@blocknote/core/comments';
import '@blocknote/core/fonts/inter.css';
import * as localesBN from '@blocknote/core/locales';
import { withCollaboration } from '@blocknote/core/yjs';
import {
createReactDiagramBlockSpec,
locales as diagramLocales,
} from '@blocknote/diagram-block';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import {
createReactInlineMathSpec,
createReactMathBlockSpec,
locales as mathLocales,
} from '@blocknote/math-block';
import {
FloatingComposerController,
FloatingThreadController,
@@ -75,12 +84,15 @@ const baseBlockNoteSchema = withPageBreak(
...defaultBlockSpecs,
callout: CalloutBlock(),
codeBlock: createCodeBlockSpec(codeBlockOptions),
diagram: createReactDiagramBlockSpec(),
mathBlock: createReactMathBlockSpec(),
pdf: PdfBlock(),
uploadLoader: UploadLoaderBlock(),
},
inlineContentSpecs: {
...defaultInlineContentSpecs,
interlinkingLinkInline: InterlinkingLinkInlineContent,
math: createReactInlineMathSpec(),
},
}),
);
@@ -115,6 +127,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
!i18n.resolvedLanguage || !(i18n.resolvedLanguage in localesBNAI)
? DEFAULT_LOCALE
: i18n.resolvedLanguage;
// The math and diagram blocks ship the same set of locales.
const langLocalesBNMathDiagram =
!i18n.resolvedLanguage || !(i18n.resolvedLanguage in mathLocales)
? DEFAULT_LOCALE
: i18n.resolvedLanguage;
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const conf = useConfig().data;
@@ -205,6 +222,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
},
dictionary: {
...localesBN[langLocalesBN as keyof typeof localesBN],
math: mathLocales[langLocalesBNMathDiagram as keyof typeof mathLocales],
diagram:
diagramLocales[
langLocalesBNMathDiagram as keyof typeof diagramLocales
],
...(localesBNMultiColumn && {
multi_column:
localesBNMultiColumn[
@@ -234,6 +256,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
return defaultPasteHandler();
},
extensions: [
// Highlights the source of code blocks and of the math / diagram
// blocks' editable LaTeX / Mermaid popups.
syntaxHighlighter,
CommentsExtension({ threadStore, resolveUsers }),
...(aiExtension ? [aiExtension] : []),
],
@@ -265,6 +290,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
langLocalesBN,
langLocalesBNMultiColumn,
langLocalesBNAI,
langLocalesBNMathDiagram,
provider,
uploadFile,
threadStore,
@@ -1,5 +1,7 @@
import { combineByGroup } from '@blocknote/core';
import { filterSuggestionItems } from '@blocknote/core/extensions';
import { getDiagramSlashMenuItems } from '@blocknote/diagram-block';
import { getMathSlashMenuItems } from '@blocknote/math-block';
import {
DefaultReactSuggestionItem,
SuggestionMenuController,
@@ -57,6 +59,8 @@ export const BlockNoteSuggestionMenu = ({
getMultiColumnSlashMenuItems?.(editor) || [],
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
getMathSlashMenuItems(editor),
getDiagramSlashMenuItems(editor),
aiAllowed && getAISlashMenuItems ? getAISlashMenuItems(editor) : [],
);
@@ -1,8 +1,11 @@
import { getDiagramBlockTypeSelectItems } from '@blocknote/diagram-block';
import { getMathBlockTypeSelectItems } from '@blocknote/math-block';
import {
FormattingToolbar,
FormattingToolbarController,
blockTypeSelectItems,
getFormattingToolbarItems,
useBlockNoteEditor,
useDictionary,
} from '@blocknote/react';
import dynamic from 'next/dynamic';
@@ -30,6 +33,7 @@ const ModalConfirmDownloadUnsafe = dynamic(
const AIToolbarButton = BlockNoteAI?.AIToolbarButton;
export const BlockNoteToolbar = ({ aiAllowed }: { aiAllowed: boolean }) => {
const editor = useBlockNoteEditor();
const dict = useDictionary();
const [confirmOpen, setIsConfirmOpen] = useState(false);
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
@@ -40,6 +44,8 @@ export const BlockNoteToolbar = ({ aiAllowed }: { aiAllowed: boolean }) => {
let toolbarItems = getFormattingToolbarItems([
...blockTypeSelectItems(dict),
getCalloutFormattingToolbarItems(t),
...getMathBlockTypeSelectItems(editor),
...getDiagramBlockTypeSelectItems(editor),
]);
// Find the index of the file download button
@@ -75,7 +81,7 @@ export const BlockNoteToolbar = ({ aiAllowed }: { aiAllowed: boolean }) => {
});
return toolbarItems;
}, [dict, t]);
}, [dict, editor, t]);
const formattingToolbar = useCallback(() => {
return (
@@ -1,3 +1,8 @@
import { diagramBlockMapping } from '@blocknote/diagram-block/docx-exporter';
import {
inlineMathMapping,
mathBlockMapping,
} from '@blocknote/math-block/docx-exporter';
import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter';
import {
@@ -18,6 +23,10 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
// implementation signature, so we can reuse the handler directly.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: docxDefaultSchemaMappings.blockMapping.file as any,
// Renders the LaTeX as a native (editable) Word equation.
mathBlock: mathBlockMapping,
// Renders the Mermaid source to a PNG in the browser (async mapping).
diagram: diagramBlockMapping,
quote: blockMappingQuoteDocx,
image: blockMappingImageDocx,
uploadLoader: blockMappingUploadLoaderDocx,
@@ -48,6 +57,8 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
inlineContentMapping: {
...docxDefaultSchemaMappings.inlineContentMapping,
interlinkingLinkInline: inlineContentMappingInterlinkingLinkDocx,
// Renders inline math as a native (editable) Word equation.
math: inlineMathMapping,
},
styleMapping: {
...docxDefaultSchemaMappings.styleMapping,
@@ -1,3 +1,8 @@
import { diagramBlockMapping } from '@blocknote/diagram-block/odt-exporter';
import {
inlineMathMapping,
mathBlockMapping,
} from '@blocknote/math-block/odt-exporter';
import { odtDefaultSchemaMappings } from '@blocknote/xl-odt-exporter';
import {
@@ -23,10 +28,16 @@ export const odtDocsSchemaMappings: DocsExporterODT['mappings'] = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: odtDefaultSchemaMappings.blockMapping.file as any,
uploadLoader: blockMappingUploadLoaderODT,
// Renders the LaTeX as a native (editable) ODF formula object.
mathBlock: mathBlockMapping,
// Renders the Mermaid source to a PNG in the browser (async mapping).
diagram: diagramBlockMapping,
},
inlineContentMapping: {
...baseInlineMappings,
interlinkingLinkInline: inlineContentMappingInterlinkingLinkODT,
// Renders inline math as a native (editable) ODF formula object.
math: inlineMathMapping,
},
};
@@ -1,3 +1,8 @@
import { diagramBlockMapping } from '@blocknote/diagram-block/pdf-exporter';
import {
inlineMathMapping,
mathBlockMapping,
} from '@blocknote/math-block/pdf-exporter';
import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter';
import {
@@ -27,10 +32,16 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: pdfDefaultSchemaMappings.blockMapping.file as any,
uploadLoader: blockMappingUploadLoaderPDF,
// Renders the LaTeX as a vector formula (via @react-pdf/math).
mathBlock: mathBlockMapping,
// Renders the Mermaid source to a PNG in the browser (async mapping).
diagram: diagramBlockMapping,
},
inlineContentMapping: {
...pdfDefaultSchemaMappings.inlineContentMapping,
interlinkingLinkInline: inlineContentMappingInterlinkingLinkPDF,
// Inline math is rasterized to an image that flows with the text.
math: inlineMathMapping,
},
styleMapping: {
...pdfDefaultSchemaMappings.styleMapping,
@@ -30,8 +30,8 @@ const PRINT_ONLY_CONTENT_CSS = `
div[data-is-empty-and-focused="true"],
div[data-floating-ui-focusable],
.collaboration-cursor-custom__base,
.c__toast__container
{
.bn-source-block-popup,
.c__toast__container {
display: none !important;
}
@@ -26,7 +26,11 @@ const extractInlineText = (content: PresenterBlock['content']): string => {
return node.text;
}
if ('content' in node && node.content !== undefined) {
return extractInlineText(node.content);
// Plain inline content (e.g. inline math) exposes its content as a
// raw string rather than a list of inline nodes.
return typeof node.content === 'string'
? node.content
: extractInlineText(node.content);
}
}
return '';
+832 -8
View File
File diff suppressed because it is too large Load Diff