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/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..00eeb765b2d6
--- /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 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");
+ 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..bcb9c870fafd
--- /dev/null
+++ b/extensions/op-blocknote-hocuspocus/test/extensions/math.test.ts
@@ -0,0 +1,79 @@
+//-- 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 {
+ 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();
+ 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");
+ });
+});
+
+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"] } },
},
});
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) {