From 96ac0b43c3515d7328e864367f46b25f8fc062f8 Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Thu, 15 Oct 2026 15:12:29 +0300 Subject: [PATCH 1/2] [COMMS-1057] Enable math formula support in BlockNote on documents https://community.openproject.org/wp/COMMS-1057 --- .../src/extensions/openProjectApi.ts | 3 + .../op-blocknote-hocuspocus/src/mathSpecs.ts | 63 ++++++++++++++ .../test/extensions/math.test.ts | 56 +++++++++++++ frontend/angular.json | 1 + frontend/esbuild/plugins.ts | 18 +++- frontend/package-lock.json | 84 +++++++++++++++++++ frontend/package.json | 5 ++ .../react/components/OpBlockNoteEditor.tsx | 7 +- .../react/hooks/useBlockNoteLocale.spec.ts | 7 ++ .../src/react/hooks/useBlockNoteLocale.ts | 9 +- .../spec/features/block_note_editor_spec.rb | 24 ++++++ .../primerized/block_note_editor_input.rb | 21 +++++ 12 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 extensions/op-blocknote-hocuspocus/src/mathSpecs.ts create mode 100644 extensions/op-blocknote-hocuspocus/test/extensions/math.test.ts diff --git a/extensions/op-blocknote-hocuspocus/src/extensions/openProjectApi.ts b/extensions/op-blocknote-hocuspocus/src/extensions/openProjectApi.ts index 925a53e9b518..6976b20a23a6 100644 --- a/extensions/op-blocknote-hocuspocus/src/extensions/openProjectApi.ts +++ b/extensions/op-blocknote-hocuspocus/src/extensions/openProjectApi.ts @@ -35,6 +35,7 @@ import { } from "@openproject/op-blocknote-extensions/server"; import * as Y from "yjs"; import { TokenExpired, TokenExpiryMissing, unauthorized } from "../closeEvents"; +import { mathBlockStaticSpec, mathInlineStaticSpec } from "../mathSpecs"; import { decryptAndValidateToken } from "../services/tokenValidationService"; import type { ApiResponseDocument } from "../types"; import { fetchResource } from "../services/resourceService"; @@ -42,9 +43,11 @@ import { fetchResource } from "../services/resourceService"; export const editorSchema = BlockNoteSchema.create().extend({ blockSpecs: { openProjectWorkPackageBlock: openProjectWorkPackageStaticBlockSpec(), + mathBlock: mathBlockStaticSpec(), }, inlineContentSpecs: { openProjectWorkPackageInline: openProjectWorkPackageStaticInlineSpec, + math: mathInlineStaticSpec, }, }); diff --git a/extensions/op-blocknote-hocuspocus/src/mathSpecs.ts b/extensions/op-blocknote-hocuspocus/src/mathSpecs.ts new file mode 100644 index 000000000000..b00a4d7fd46b --- /dev/null +++ b/extensions/op-blocknote-hocuspocus/src/mathSpecs.ts @@ -0,0 +1,63 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { createBlockSpec, createInlineContentSpec, plainContentToString } from "@blocknote/core"; + +// `@blocknote/math-block` cannot run in Node (it imports KaTeX's CSS, and its React rendering crashes +// the process after an export), so these mirror its specs. Type, content and `code` must stay in sync +// with the editor, or Yjs content does not map onto this schema. The markdown exporter reads the LaTeX +// from the `application/x-tex` annotation. +function mathElement(latex:string, display:"block" | "inline") { + const namespace = "http://www.w3.org/1998/Math/MathML"; + const math = document.createElementNS(namespace, "math"); + const semantics = document.createElementNS(namespace, "semantics"); + const annotation = document.createElementNS(namespace, "annotation"); + + math.setAttribute("display", display); + annotation.setAttribute("encoding", "application/x-tex"); + annotation.textContent = latex; + semantics.append(annotation); + math.append(semantics); + + return math as unknown as HTMLElement; +} + +export const mathBlockStaticSpec = createBlockSpec( + { type: "mathBlock", propSchema: {}, content: "plain" }, + { + meta: { code: true, defining: true, isolating: false }, + render: (block) => ({ dom: mathElement(plainContentToString(block.content), "block") }), + }, +); + +export const mathInlineStaticSpec = createInlineContentSpec( + { type: "math", propSchema: {}, content: "plain" }, + { + meta: { code: true }, + render: (inlineContent) => ({ dom: mathElement(plainContentToString(inlineContent.content), "inline") }), + }, +); diff --git a/extensions/op-blocknote-hocuspocus/test/extensions/math.test.ts b/extensions/op-blocknote-hocuspocus/test/extensions/math.test.ts new file mode 100644 index 000000000000..b4d1f2ed3845 --- /dev/null +++ b/extensions/op-blocknote-hocuspocus/test/extensions/math.test.ts @@ -0,0 +1,56 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { describe, expect, test } from "vitest"; +import * as Y from "yjs"; +import { createEditor } from "../../src/extensions/openProjectApi"; + +async function storedMarkdown(blocks:unknown[]) { + const editor = createEditor(); + const fragment = new Y.Doc().getXmlFragment("document-store"); + // @ts-expect-error BlockNote types are complicated + editor.blocksToYXmlFragment(blocks, fragment); + return editor.blocksToMarkdownLossy(editor.yXmlFragmentToBlocks(fragment)); +} + +describe("math", () => { + test("stores a math block as a $$ block", async () => { + const markdown = await storedMarkdown([ + { type: "mathBlock", content: "a^2 = \\sqrt{b^2 + c^2}" }, + ]); + + expect(markdown).toBe("$$\na^2 = \\sqrt{b^2 + c^2}\n$$\n"); + }); + + test("stores inline math as a $ span", async () => { + const markdown = await storedMarkdown([ + { type: "paragraph", content: ["Euler: ", { type: "math", content: "e^{i\\pi} + 1 = 0" }, "."] }, + ]); + + expect(markdown).toBe("Euler: $e^{i\\pi} + 1 = 0$.\n"); + }); +}); diff --git a/frontend/angular.json b/frontend/angular.json index 0aec38cc41a9..1dd0ab7a701f 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -54,6 +54,7 @@ "src/styles.scss", "node_modules/codemirror/lib/codemirror.css", "node_modules/flatpickr/dist/flatpickr.min.css", + "node_modules/katex/dist/katex.min.css", "src/vendor/ckeditor/ckeditor.css" ], "stylePreprocessorOptions": { diff --git a/frontend/esbuild/plugins.ts b/frontend/esbuild/plugins.ts index 44cc01dc4ebe..46c2e7cf1729 100644 --- a/frontend/esbuild/plugins.ts +++ b/frontend/esbuild/plugins.ts @@ -105,4 +105,20 @@ if (hadPreviousDollar) { }, }; -export default [customConfigPlugin, jqueryInjectionPlugin]; +// The production build cannot bundle KaTeX's fonts; angular.json loads the stylesheet instead. +const katexStylesheetFromJsPlugin:Plugin = { + name: 'katex-stylesheet-from-js', + setup(build) { + build.onResolve({ filter: /^katex\/dist\/katex\.min\.css$/ }, (args) => { + if (args.kind !== 'import-statement') { + return undefined; + } + + return { path: args.path, namespace: 'katex-stylesheet-from-js' }; + }); + + build.onLoad({ filter: /.*/, namespace: 'katex-stylesheet-from-js' }, () => ({ contents: '', loader: 'empty' })); + }, +}; + +export default [customConfigPlugin, jqueryInjectionPlugin, katexStylesheetFromJsPlugin]; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f479de66b893..3ef67deb4ed2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,6 +30,7 @@ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0", "@blocknote/core": "^0.55.0", "@blocknote/mantine": "^0.55.0", + "@blocknote/math-block": "^0.55.0", "@blocknote/react": "^0.55.0", "@braintree/sanitize-url": "^7.1.2", "@datorama/akita": "^8.0.1", @@ -97,6 +98,7 @@ "jquery": "^4.0.0", "jquery-migrate": "^4.0.2", "json5": "^2.2.2", + "katex": "^0.18.11", "lit-html": "^3.3.3", "lodash-es": "^4.17.21", "luxon": "^3.7.2", @@ -2427,6 +2429,62 @@ "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" } }, + "node_modules/@blocknote/math-block": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@blocknote/math-block/-/math-block-0.55.0.tgz", + "integrity": "sha512-rxmDdvpmKw61oMTPOBpVeEYeNJ6f2j7FYn0yQx2fN7goEEHWd5Y021c19x5Gy7qmLrbyEFOuvPkFsqdOWMOPbw==", + "license": "MPL-2.0", + "dependencies": { + "@blocknote/core": "^0.55.0", + "@blocknote/react": "^0.55.0", + "@handlewithcare/prosemirror-inputrules": "^0.1.4", + "katex": "^0.16.11", + "prosemirror-model": "^1.25.11", + "prosemirror-state": "^1.4.4" + }, + "peerDependencies": { + "@blocknote/xl-docx-exporter": "^0.55.0", + "@blocknote/xl-email-exporter": "^0.55.0", + "@blocknote/xl-odt-exporter": "^0.55.0", + "@blocknote/xl-typst-exporter": "^0.55.0", + "@react-email/components": "^1.0.12", + "docx": "^9.6.1", + "mathjax-full": "^3.2.2", + "mathml2omml": "^0.5.0", + "react": "^18.0 || ^19.0 || >= 19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc", + "tex2typst": "^0.6.2" + }, + "peerDependenciesMeta": { + "@blocknote/xl-docx-exporter": { + "optional": true + }, + "@blocknote/xl-email-exporter": { + "optional": true + }, + "@blocknote/xl-odt-exporter": { + "optional": true + }, + "@blocknote/xl-typst-exporter": { + "optional": true + }, + "@react-email/components": { + "optional": true + }, + "docx": { + "optional": true + }, + "mathjax-full": { + "optional": true + }, + "mathml2omml": { + "optional": true + }, + "tex2typst": { + "optional": true + } + } + }, "node_modules/@blocknote/react": { "version": "0.55.0", "resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.55.0.tgz", @@ -8533,6 +8591,15 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -11750,6 +11817,23 @@ "node": ">=4.0" } }, + "node_modules/katex": { + "version": "0.18.11", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz", + "integrity": "sha512-yvyz/2VMKqoho/35Hat5w37wIEdWJ5ElVMidTAlWrxr5uW4aBGsEpveTmKh18/zw08v/7lGswCPon4OhqmqrTA==", + "deprecated": "Accidentally published with breaking changes. Use 0.19.0 instead.", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^15.0.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6b8cc20f8d0c..5de3a762f74f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -82,6 +82,7 @@ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0", "@blocknote/core": "^0.55.0", "@blocknote/mantine": "^0.55.0", + "@blocknote/math-block": "^0.55.0", "@blocknote/react": "^0.55.0", "@braintree/sanitize-url": "^7.1.2", "@datorama/akita": "^8.0.1", @@ -149,6 +150,7 @@ "jquery": "^4.0.0", "jquery-migrate": "^4.0.2", "json5": "^2.2.2", + "katex": "^0.18.11", "lit-html": "^3.3.3", "lodash-es": "^4.17.21", "luxon": "^3.7.2", @@ -213,6 +215,9 @@ "@angular/build": { ".": "$@angular/build", "piscina": "5.3.2" + }, + "@blocknote/math-block": { + "katex": "$katex" } }, "allowScripts": { diff --git a/frontend/src/react/components/OpBlockNoteEditor.tsx b/frontend/src/react/components/OpBlockNoteEditor.tsx index 55e1f385173b..60fa6180ca4c 100644 --- a/frontend/src/react/components/OpBlockNoteEditor.tsx +++ b/frontend/src/react/components/OpBlockNoteEditor.tsx @@ -25,7 +25,7 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { BlockNoteEditorOptions, BlockNoteSchema, User } from '@blocknote/core'; +import { BlockNoteEditorOptions, BlockNoteSchema, combineByGroup, User } from '@blocknote/core'; import { ExternalLinkA11yExtension } from '../extensions/external-link-a11y'; import { ExternalLinkCaptureExtension } from '../extensions/external-link-capture'; import { CollaborationCursorLabelFitExtension } from '../extensions/collaboration-cursor-label-fit'; @@ -33,6 +33,7 @@ import { HeadingLinkExtension } from '../extensions/heading-link'; import { filterSuggestionItems } from '@blocknote/core/extensions'; import { withCollaboration } from '@blocknote/core/yjs'; import { BlockNoteView } from '@blocknote/mantine'; +import { createReactInlineMathSpec, createReactMathBlockSpec, getMathSlashMenuItems } from '@blocknote/math-block'; import { getDefaultReactSlashMenuItems, SuggestionMenuController, useCreateBlockNote } from '@blocknote/react'; import { HocuspocusProvider } from '@hocuspocus/provider'; import { @@ -64,9 +65,11 @@ export interface OpBlockNoteEditorProps { const schema = BlockNoteSchema.create().extend({ blockSpecs: { openProjectWorkPackageBlock: openProjectWorkPackageBlockSpec(), + mathBlock: createReactMathBlockSpec(), }, inlineContentSpecs: { openProjectWorkPackageInline: openProjectWorkPackageInlineSpec, + math: createReactInlineMathSpec(), }, }); @@ -149,7 +152,7 @@ export function OpBlockNoteEditor({ }, [editor, hocuspocusProvider]); const getCustomSlashMenuItems = useCallback((editorInstance:EditorType) => [ - ...getDefaultReactSlashMenuItems(editorInstance), + ...combineByGroup(getDefaultReactSlashMenuItems(editorInstance), getMathSlashMenuItems(editorInstance)), ...getOpenProjectSlashMenuItems(editorInstance), ], []); const { getHashItems, HashWpMenu } = useHashWpMenu(editor); diff --git a/frontend/src/react/hooks/useBlockNoteLocale.spec.ts b/frontend/src/react/hooks/useBlockNoteLocale.spec.ts index 077a9904a6d1..43bdb9d362cf 100644 --- a/frontend/src/react/hooks/useBlockNoteLocale.spec.ts +++ b/frontend/src/react/hooks/useBlockNoteLocale.spec.ts @@ -26,6 +26,7 @@ //++ import { renderHook } from '@testing-library/react'; +import { locales as mathLocales } from '@blocknote/math-block'; import { useBlockNoteLocale, } from './useBlockNoteLocale'; @@ -44,4 +45,10 @@ describe('useBlockNoteLocale', () => { expect(result.current.localeString).toBe('en'); expect(result.current.localeDictionary).toBeDefined(); }); + + it('includes the math strings of the same locale', () => { + const { result } = renderHook(() => useBlockNoteLocale('de')); + + expect(result.current.localeDictionary.math).toBe(mathLocales.de); + }); }); diff --git a/frontend/src/react/hooks/useBlockNoteLocale.ts b/frontend/src/react/hooks/useBlockNoteLocale.ts index b730f6eba1c5..74c82fa3eaca 100644 --- a/frontend/src/react/hooks/useBlockNoteLocale.ts +++ b/frontend/src/react/hooks/useBlockNoteLocale.ts @@ -26,11 +26,14 @@ //++ import * as blockNoteLocales from '@blocknote/core/locales'; +import { locales as mathLocales, MathDictionary } from '@blocknote/math-block'; import { useMemo } from 'react'; +type BlockNoteLocale = keyof typeof blockNoteLocales; + export interface BlockNoteLocaleResult { localeString:string; // e.g. 'en', 'de' - localeDictionary:typeof blockNoteLocales[keyof typeof blockNoteLocales]; + localeDictionary:typeof blockNoteLocales[BlockNoteLocale] & { math:MathDictionary }; } /* @@ -38,8 +41,8 @@ export interface BlockNoteLocaleResult { */ export function useBlockNoteLocale(userLocale:string):BlockNoteLocaleResult { return useMemo(() => { - const localeString = Object.keys(blockNoteLocales).includes(userLocale) ? userLocale : 'en'; - const localeDictionary = blockNoteLocales[localeString as keyof typeof blockNoteLocales]; + const localeString = (Object.keys(blockNoteLocales).includes(userLocale) ? userLocale : 'en') as BlockNoteLocale; + const localeDictionary = { ...blockNoteLocales[localeString], math: mathLocales[localeString] }; return { localeString, localeDictionary }; }, [userLocale]); } diff --git a/modules/documents/spec/features/block_note_editor_spec.rb b/modules/documents/spec/features/block_note_editor_spec.rb index 5539b0208cf1..232b6165931b 100644 --- a/modules/documents/spec/features/block_note_editor_spec.rb +++ b/modules/documents/spec/features/block_note_editor_spec.rb @@ -110,6 +110,30 @@ end end + context "when inserting math formulas" do + it "renders a block equation and stores its LaTeX as a $$ block" do + visit document_path(document) + expect(page).to have_test_selector("blocknote-document-description") + + editor.insert_block_equation("a^2 + b^2 = c^2") + + expect(editor.element).to have_css("[data-content-type='mathBlock'] .katex-html") + expect(editor).to be_katex_stylesheet_applied + editor.wait_for_autosave { document.reload.description&.include?("$$\na^2 + b^2 = c^2\n$$") } + end + + it "renders an inline equation and stores its LaTeX as a $ span" do + visit document_path(document) + expect(page).to have_test_selector("blocknote-document-description") + + editor.fill_in("Euler ") + editor.insert_inline_equation("e^{i\\pi} + 1 = 0") + + expect(editor.element).to have_css("[data-content-type='paragraph'] .katex-html") + editor.wait_for_autosave { document.reload.description&.include?("Euler $e^{i\\pi} + 1 = 0$") } + end + end + context "when running with a relative_url_root and non-proxied frontend assets", js: false, with_env: { "OPENPROJECT_DISABLE_DEV_ASSET_PROXY" => "1" } do diff --git a/spec/support/form_fields/primerized/block_note_editor_input.rb b/spec/support/form_fields/primerized/block_note_editor_input.rb index 15572d2e79bf..e7d0d2370922 100644 --- a/spec/support/form_fields/primerized/block_note_editor_input.rb +++ b/spec/support/form_fields/primerized/block_note_editor_input.rb @@ -56,6 +56,14 @@ def open_create_work_package_dialog send_keys(:enter) end + def insert_block_equation(latex) + insert_equation("/block equation", latex) + end + + def insert_inline_equation(latex) + insert_equation("/inline equation", latex) + end + def click_formatting_toolbar_button(label) shadow_root.find("button[aria-label='#{label}']").click end @@ -197,6 +205,13 @@ def undo JS end + # Without KaTeX's stylesheet the MathML copy of each formula shows next to the HTML one. + def katex_stylesheet_applied? + page.evaluate_script(<<~JS) + getComputedStyle(#{shadow_root_query}.querySelector('.katex-mathml')).position === 'absolute' + JS + end + def heading_to_paragraph_font_size_ratio heading_font_size, paragraph_font_size = page.evaluate_script(<<~JS) [ @@ -237,6 +252,12 @@ def send_keys_to_editor(keys) element.send_keys(keys) end + def insert_equation(slash_command, latex) + send_keys_to_editor(slash_command) + send_keys(:enter) + send_keys(latex) + end + def shadow_root_observe_js <<~JS function shadowRootWaitFor(shadowRoot, tryFunction, done) { From c9a5997b88dbb9d49d9412c782e47e4c3a8d736b Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Thu, 22 Oct 2026 14:44:39 +0300 Subject: [PATCH 2/2] [COMMS-1057] Guard the server math specs against drift --- .../op-blocknote-hocuspocus/package-lock.json | 102 ++++++++++++++++-- .../op-blocknote-hocuspocus/package.json | 6 ++ .../op-blocknote-hocuspocus/src/mathSpecs.ts | 8 +- .../test/extensions/math.test.ts | 23 ++++ .../op-blocknote-hocuspocus/vitest.config.mjs | 2 + 5 files changed, 129 insertions(+), 12 deletions(-) diff --git a/extensions/op-blocknote-hocuspocus/package-lock.json b/extensions/op-blocknote-hocuspocus/package-lock.json index 00fecbbb0088..4b773948ef6b 100644 --- a/extensions/op-blocknote-hocuspocus/package-lock.json +++ b/extensions/op-blocknote-hocuspocus/package-lock.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@blocknote/core": "^0.55.0", + "@blocknote/math-block": "^0.55.0", "@eslint/js": "^10.0.1", "@eslint/json": "^2.1.1", "@hocuspocus/provider": "^4.7.0", @@ -167,24 +168,64 @@ "url": "https://github.com/sponsors/dmonad" } }, - "node_modules/@blocknote/server-util": { + "node_modules/@blocknote/math-block": { "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@blocknote/server-util/-/server-util-0.55.0.tgz", - "integrity": "sha512-OLtUfFme33T6i0aqFbBo5XjjNZjLZjmyjFrA70lC4YMd/bhFcsVd2dOx06Da9M9oVMcT5PuV6AiKQVjA7sCGaQ==", + "resolved": "https://registry.npmjs.org/@blocknote/math-block/-/math-block-0.55.0.tgz", + "integrity": "sha512-rxmDdvpmKw61oMTPOBpVeEYeNJ6f2j7FYn0yQx2fN7goEEHWd5Y021c19x5Gy7qmLrbyEFOuvPkFsqdOWMOPbw==", + "dev": true, "license": "MPL-2.0", "dependencies": { "@blocknote/core": "^0.55.0", "@blocknote/react": "^0.55.0", - "@tiptap/pm": "^3.31.3", - "jsdom": "^29.0.2", - "yjs": "^13.6.27" + "@handlewithcare/prosemirror-inputrules": "^0.1.4", + "katex": "^0.16.11", + "prosemirror-model": "^1.25.11", + "prosemirror-state": "^1.4.4" }, "peerDependencies": { + "@blocknote/xl-docx-exporter": "^0.55.0", + "@blocknote/xl-email-exporter": "^0.55.0", + "@blocknote/xl-odt-exporter": "^0.55.0", + "@blocknote/xl-typst-exporter": "^0.55.0", + "@react-email/components": "^1.0.12", + "docx": "^9.6.1", + "mathjax-full": "^3.2.2", + "mathml2omml": "^0.5.0", "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", + "tex2typst": "^0.6.2" + }, + "peerDependenciesMeta": { + "@blocknote/xl-docx-exporter": { + "optional": true + }, + "@blocknote/xl-email-exporter": { + "optional": true + }, + "@blocknote/xl-odt-exporter": { + "optional": true + }, + "@blocknote/xl-typst-exporter": { + "optional": true + }, + "@react-email/components": { + "optional": true + }, + "docx": { + "optional": true + }, + "mathjax-full": { + "optional": true + }, + "mathml2omml": { + "optional": true + }, + "tex2typst": { + "optional": true + } } }, - "node_modules/@blocknote/server-util/node_modules/@blocknote/react": { + "node_modules/@blocknote/react": { "version": "0.55.0", "resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.55.0.tgz", "integrity": "sha512-t+4N8+3xHhlnesxpWhF3FZnoHcnwhO+VgHiz6WEx7kTOTgk2YH84EOD7FsPh/hCm3I9MH9cHuiMfo6W6PHj/9w==", @@ -208,6 +249,23 @@ "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" } }, + "node_modules/@blocknote/server-util": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@blocknote/server-util/-/server-util-0.55.0.tgz", + "integrity": "sha512-OLtUfFme33T6i0aqFbBo5XjjNZjLZjmyjFrA70lC4YMd/bhFcsVd2dOx06Da9M9oVMcT5PuV6AiKQVjA7sCGaQ==", + "license": "MPL-2.0", + "dependencies": { + "@blocknote/core": "^0.55.0", + "@blocknote/react": "^0.55.0", + "@tiptap/pm": "^3.31.3", + "jsdom": "^29.0.2", + "yjs": "^13.6.27" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || >= 19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", @@ -2634,6 +2692,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -3483,6 +3551,24 @@ "dev": true, "license": "MIT" }, + "node_modules/katex": { + "version": "0.18.11", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz", + "integrity": "sha512-yvyz/2VMKqoho/35Hat5w37wIEdWJ5ElVMidTAlWrxr5uW4aBGsEpveTmKh18/zw08v/7lGswCPon4OhqmqrTA==", + "deprecated": "Accidentally published with breaking changes. Use 0.19.0 instead.", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^15.0.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", diff --git a/extensions/op-blocknote-hocuspocus/package.json b/extensions/op-blocknote-hocuspocus/package.json index 1c7d4a599023..deb55ec560eb 100644 --- a/extensions/op-blocknote-hocuspocus/package.json +++ b/extensions/op-blocknote-hocuspocus/package.json @@ -32,6 +32,7 @@ }, "devDependencies": { "@blocknote/core": "^0.55.0", + "@blocknote/math-block": "^0.55.0", "@eslint/js": "^10.0.1", "@eslint/json": "^2.1.1", "@hocuspocus/provider": "^4.7.0", @@ -46,6 +47,11 @@ "typescript-eslint": "^8.71.2", "vitest": "^5.0.5" }, + "overrides": { + "@blocknote/math-block": { + "katex": "^0.18.11" + } + }, "allowScripts": { "esbuild@0.28.1": true, "fsevents@2.3.3": true, diff --git a/extensions/op-blocknote-hocuspocus/src/mathSpecs.ts b/extensions/op-blocknote-hocuspocus/src/mathSpecs.ts index b00a4d7fd46b..00eeb765b2d6 100644 --- a/extensions/op-blocknote-hocuspocus/src/mathSpecs.ts +++ b/extensions/op-blocknote-hocuspocus/src/mathSpecs.ts @@ -27,10 +27,10 @@ import { createBlockSpec, createInlineContentSpec, plainContentToString } from "@blocknote/core"; -// `@blocknote/math-block` cannot run in Node (it imports KaTeX's CSS, and its React rendering crashes -// the process after an export), so these mirror its specs. Type, content and `code` must stay in sync -// with the editor, or Yjs content does not map onto this schema. The markdown exporter reads the LaTeX -// from the `application/x-tex` annotation. +// `@blocknote/math-block` cannot be loaded in Node (it imports KaTeX's CSS), and with React's development +// build its rendering crashes the process after an export, so these mirror its specs. The math tests +// guard that they stay in sync with the package. The markdown exporter reads the LaTeX from the +// `application/x-tex` annotation. function mathElement(latex:string, display:"block" | "inline") { const namespace = "http://www.w3.org/1998/Math/MathML"; const math = document.createElementNS(namespace, "math"); diff --git a/extensions/op-blocknote-hocuspocus/test/extensions/math.test.ts b/extensions/op-blocknote-hocuspocus/test/extensions/math.test.ts index b4d1f2ed3845..bcb9c870fafd 100644 --- a/extensions/op-blocknote-hocuspocus/test/extensions/math.test.ts +++ b/extensions/op-blocknote-hocuspocus/test/extensions/math.test.ts @@ -25,9 +25,16 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import { + createMathBlockConfig, + createReactInlineMathSpec, + createReactMathBlockSpec, + mathInlineContentConfig, +} from "@blocknote/math-block"; import { describe, expect, test } from "vitest"; import * as Y from "yjs"; import { createEditor } from "../../src/extensions/openProjectApi"; +import { mathBlockStaticSpec, mathInlineStaticSpec } from "../../src/mathSpecs"; async function storedMarkdown(blocks:unknown[]) { const editor = createEditor(); @@ -54,3 +61,19 @@ describe("math", () => { expect(markdown).toBe("Euler: $e^{i\\pi} + 1 = 0$.\n"); }); }); + +describe("math specs", () => { + test("match the math block of @blocknote/math-block", () => { + const { code, defining, isolating } = createReactMathBlockSpec().implementation.meta ?? {}; + + expect(mathBlockStaticSpec().config).toEqual(createMathBlockConfig()); + expect(mathBlockStaticSpec().implementation.meta).toEqual({ code, defining, isolating }); + }); + + test("match the inline math of @blocknote/math-block", () => { + const { code } = createReactInlineMathSpec().implementation.meta ?? {}; + + expect(mathInlineStaticSpec.config).toEqual(mathInlineContentConfig); + expect(mathInlineStaticSpec.implementation.meta).toEqual({ code }); + }); +}); diff --git a/extensions/op-blocknote-hocuspocus/vitest.config.mjs b/extensions/op-blocknote-hocuspocus/vitest.config.mjs index 52ad54d89901..f8b356b5d428 100644 --- a/extensions/op-blocknote-hocuspocus/vitest.config.mjs +++ b/extensions/op-blocknote-hocuspocus/vitest.config.mjs @@ -31,5 +31,7 @@ export default defineConfig({ test: { include: ["test/**/*.test.ts"], setupFiles: ["test/setup.ts"], + // Lets Vite handle the KaTeX stylesheet `@blocknote/math-block` imports, which Node cannot load. + server: { deps: { inline: ["@blocknote/math-block"] } }, }, });