├── CHANGELOG.md
├── LICENSE
├── README.md
├── example
├── .eslintrc.cjs
├── .gitignore
├── index.html
├── package-lock.json
├── package.json
├── src
│ ├── App.css
│ ├── App.tsx
│ ├── TipTap.tsx
│ ├── index.css
│ ├── main.tsx
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
└── packages
└── tiptap-math-extension
├── .gitignore
├── README.md
├── jest.config.js
├── package-lock.json
├── package.json
├── rollup.config.js
├── src
├── index.ts
├── inline-math-node.ts
├── latex-evaluation
│ ├── evaluate-expression.ts
│ └── update-evaluation.ts
├── tests
│ └── regex.test.ts
└── util
│ ├── generate-id.ts
│ └── options.ts
└── tsconfig.json
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 1.2.2 (2024-04-09)
4 | ### Breaking
5 | - Disable evaluation mode by default
6 |
7 | ## 1.2.1 (2024-03-27)
8 | ### New
9 | - Updated README to include instructions on includidng KaTeX CSS
10 |
11 | ## 1.2.0 (2024-03-10)
12 | ### New
13 | - Added extension options
14 | - Allow disabling evaluation mode
15 | - Update README with options
16 |
17 | ## 1.1.0 (2023-06-03)
18 | ### New
19 | - Display math using the `$$\sum_{i=1}^n x_i$$` notation
20 | - Added Display attribute
21 | - Allow copy/paste of latex elements directly in the editor
22 | - Indicate with a black border/box if a math element is currently selected
23 | ### Fixed
24 | - Updated HTML render function to allow in-editor copy/paste
25 | - Fixed paste handling function to allow pasting LaTeX expressions and automatically converting them to rendered LaTeX elements
26 | - Allow selection of latex nodes
27 |
28 | ## 1.0.0 (2023-06-02)
29 | Initial version with inline math and basic evaluation support
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 aarkue
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
Math/LaTeX Extension for the TipTap Editor
3 |
Use inline math expression / LaTeX directly in your editor!
12 |
13 |
14 | ## Usage
15 |
16 | See the demo at `/example` for a quick introduction on how to use this package.
17 | The **package is now available on NPM** as [**@aarkue/tiptap-math-extension**](https://www.npmjs.com/package/@aarkue/tiptap-math-extension).
18 | It can be installed using `npm install @aarkue/tiptap-math-extension`.
19 |
20 | To correctly render the LaTeX expressions, you will also need to include the **KaTeX CSS**.
21 | If you are using [vite](https://vitejs.dev/) you can use `import "katex/dist/katex.min.css";` in the component which renders the tiptap editor.
22 | This requires that you also install the `katex` npm package using `npm i katex` (https://www.npmjs.com/package/katex).
23 | There are also different ways to include the CSS, for instance by using a CDN like `jsdelivr.net`. See https://katex.org/docs/browser for more information. Note, however, that only the CSS needs to be included manually, as the JS is already bundled with this plugin.
24 |
25 | ## Features
26 |
27 | ### Display Inline LaTeX
28 |
29 | Writing a math expression surrounded by `$`-signs automatically creates a rendered LaTeX expression.
30 | The delimiters are also configurable via the corresponding option.
31 |
32 | To edit or delete the LaTeX, simply press backspace with the cursor placed before the expression.
33 | The rendered LaTeX will disappear and the LaTeX source will become normal editable text again.
34 |
35 | ### Evaluate LaTeX Expression
36 |
37 | **Note: Since version 1.2.0 this feature needs to be explicitly enabled**.
38 | This can be done using the `evaluate` configuration option:
39 |
40 | ```typescript
41 | const editor = useEditor({
42 | extensions: [StarterKit, MathExtension.configure({ evaluation: true })],
43 | content: "
Hello World!
",
44 | });
45 | ```
46 |
47 | Calculation results can be shown inline, using the [Evaluatex.js]([https://arthanzel.github.io/evaluatex/) library.
48 |
49 | Define variables using the `:=` notation (e.g., `x := 120`).
50 | Then, expressions can include this variable (e.g., `x \cdot 4=`).
51 | End the calculating expressions with `=` to automatically show the computed result.
52 |
53 | ## Screenshots + Demo
54 |
55 | Try out the demo directly online at [https://aarkue.github.io/tiptap-math-extension/](https://aarkue.github.io/tiptap-math-extension/)!
56 |
57 | 
58 |
59 | 
60 |
61 | https://github.com/aarkue/tiptap-math-extension/assets/20766652/96f31846-d4a8-4cb2-b963-ff6da57daeb1
62 |
63 | ## Options
64 |
65 | There are a few options available to configure the extension. See below for typescript definitions of all available options and their default value.
66 |
67 | ```typescript
68 | export interface MathExtensionOption {
69 | /** Evaluate LaTeX expressions */
70 | evaluation: boolean;
71 | /** Add InlineMath node type (currently required as inline is the only supported mode) */
72 | addInlineMath: boolean;
73 | /** KaTeX options to use for evaluation, see also https://katex.org/docs/options.html */
74 | katexOptions?: KatexOptions;
75 | /** Delimiters to auto-convert. Per default dollar-style (`dollar`) ($x_1$ and $$\sum_i i$$) are used.
76 | *
77 | * The `bracket` option corresponds to `\(x_1\)` and `\[\sum_i i \]`.
78 | *
79 | * Alternatively, custom inline/block regexes can be used.
80 | * The inner math content is expected to be the match at index 1 (`props.match[1]`).
81 | */
82 | delimiters?:
83 | | "dollar"
84 | | "bracket"
85 | | {
86 | inlineRegex?: string;
87 | blockRegex?: string;
88 | inlineStart?: string;
89 | inlineEnd?: string;
90 | blockStart?: string;
91 | blockEnd?: string;
92 | };
93 |
94 | /** If and how to represent math nodes in the raw text output (e.g., `editor.getText()`)
95 | *
96 | * - `"none"`: do not include in text at all
97 | * - `"raw-latex"`: include the latex source, without delimiters (e.g., `\frac{1}{n}`)
98 | * - `{placeholder: "[...]`"}: represent all math nodes as a fixed placeholder string (e.g., `[...]`)
99 | *
100 | * The option delimited-latex is currently disabled because of issues with it re-triggering input rules (see also https://github.com/ueberdosis/tiptap/issues/2946).
101 | *
102 | * - ~`"delimited-latex"`: include the latex source with delimiters (e.g., `$\frac{1}{n}$`)~
103 | */
104 | renderTextMode?: "none"|"raw-latex"|{placeholder: string}, // |"delimited-latex"
105 | }
106 | export const DEFAULT_OPTIONS: MathExtensionOption = { addInlineMath: true, evaluation: false, delimiters: "dollar", renderTextMode: "raw-latex" };
107 | ```
108 |
109 | See https://katex.org/docs/options.html for a complete list of the available KaTeX options.
110 |
111 | ## Related or Used Projects
112 |
113 | - [Tiptap Editor](https://github.com/ueberdosis/tiptap): The extensible editor for which this is an extension.
114 | - [KaTeX](https://github.com/KaTeX/KaTeX): A LaTeX rendering engine for the web, used to render LaTeX expressions.
115 | - [Evaluatex.js](https://github.com/arthanzel/evaluatex): Used to evaluate LaTeX expressions to a numeric value (e.g., `1 + (2 \cdot 3) = 7`).
116 | - [Vite](https://github.com/vitejs/vite): Used to serve the example demo project.
117 |
--------------------------------------------------------------------------------
/example/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: { browser: true, es2020: true },
3 | extends: [
4 | 'eslint:recommended',
5 | 'plugin:@typescript-eslint/recommended',
6 | 'plugin:react-hooks/recommended',
7 | ],
8 | parser: '@typescript-eslint/parser',
9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
10 | plugins: ['react-refresh'],
11 | rules: {
12 | 'react-refresh/only-export-components': 'warn',
13 | },
14 | }
15 |
--------------------------------------------------------------------------------
/example/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | TipTap Math Extension
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "example",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@tiptap/pm": "^2.11.3",
14 | "@tiptap/react": "^2.11.3",
15 | "@tiptap/starter-kit": "^2.11.3",
16 | "katex": "^0.16.7",
17 | "react": "^18.2.0",
18 | "react-dom": "^18.2.0"
19 | },
20 | "devDependencies": {
21 | "@types/react": "^18.0.37",
22 | "@types/react-dom": "^18.0.11",
23 | "@typescript-eslint/eslint-plugin": "^5.59.0",
24 | "@typescript-eslint/parser": "^5.59.0",
25 | "@vitejs/plugin-react": "^4.0.0",
26 | "eslint": "^8.38.0",
27 | "eslint-plugin-react-hooks": "^4.6.0",
28 | "eslint-plugin-react-refresh": "^0.3.4",
29 | "typescript": "^5.0.2",
30 | "vite": "^4.3.9"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/example/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | margin: 0 auto;
3 | padding: 2rem;
4 | }
5 |
--------------------------------------------------------------------------------
/example/src/App.tsx:
--------------------------------------------------------------------------------
1 | import "./App.css";
2 | import Tiptap from "./TipTap";
3 |
4 | function App() {
5 | return (
6 | <>
7 |
12 |
13 |
14 | ## Usage
15 |
16 | See the demo at `/example` for a quick introduction on how to use this package.
17 | The **package is now available on NPM** as [**@aarkue/tiptap-math-extension**](https://www.npmjs.com/package/@aarkue/tiptap-math-extension).
18 | It can be installed using `npm install @aarkue/tiptap-math-extension`.
19 |
20 | To correctly render the LaTeX expressions, you will also need to include the **KaTeX CSS**.
21 | If you are using [vite](https://vitejs.dev/) you can use `import "katex/dist/katex.min.css";` in the component which renders the tiptap editor.
22 | This requires that you also install the `katex` npm package using `npm i katex` (https://www.npmjs.com/package/katex).
23 | There are also different ways to include the CSS, for instance by using a CDN like `jsdelivr.net`. See https://katex.org/docs/browser for more information. Note, however, that only the CSS needs to be included manually, as the JS is already bundled with this plugin.
24 |
25 | ## Features
26 |
27 | ### Display Inline LaTeX
28 |
29 | Writing a math expression surrounded by `$`-signs automatically creates a rendered LaTeX expression.
30 | The delimiters are also configurable via the corresponding option.
31 |
32 | To edit or delete the LaTeX, simply press backspace with the cursor placed before the expression.
33 | The rendered LaTeX will disappear and the LaTeX source will become normal editable text again.
34 |
35 | ### Evaluate LaTeX Expression
36 |
37 | **Note: Since version 1.2.0 this feature needs to be explicitly enabled**.
38 | This can be done using the `evaluate` configuration option:
39 |
40 | ```typescript
41 | const editor = useEditor({
42 | extensions: [StarterKit, MathExtension.configure({ evaluation: true })],
43 | content: "
Hello World!
",
44 | });
45 | ```
46 |
47 | Calculation results can be shown inline, using the [Evaluatex.js]([https://arthanzel.github.io/evaluatex/) library.
48 |
49 | Define variables using the `:=` notation (e.g., `x := 120`).
50 | Then, expressions can include this variable (e.g., `x \cdot 4=`).
51 | End the calculating expressions with `=` to automatically show the computed result.
52 |
53 | ## Screenshots + Demo
54 |
55 | Try out the demo directly online at [https://aarkue.github.io/tiptap-math-extension/](https://aarkue.github.io/tiptap-math-extension/)!
56 |
57 | 
58 |
59 | 
60 |
61 | https://github.com/aarkue/tiptap-math-extension/assets/20766652/96f31846-d4a8-4cb2-b963-ff6da57daeb1
62 |
63 | ## Options
64 |
65 | There are a few options available to configure the extension. See below for typescript definitions of all available options and their default value.
66 |
67 | ```typescript
68 | export interface MathExtensionOption {
69 | /** Evaluate LaTeX expressions */
70 | evaluation: boolean;
71 | /** Add InlineMath node type (currently required as inline is the only supported mode) */
72 | addInlineMath: boolean;
73 | /** KaTeX options to use for evaluation, see also https://katex.org/docs/options.html */
74 | katexOptions?: KatexOptions;
75 | /** Delimiters to auto-convert. Per default dollar-style (`dollar`) ($x_1$ and $$\sum_i i$$) are used.
76 | *
77 | * The `bracket` option corresponds to `\(x_1\)` and `\[\sum_i i \]`.
78 | *
79 | * Alternatively, custom inline/block regexes can be used.
80 | * The inner math content is expected to be the match at index 1 (`props.match[1]`).
81 | */
82 | delimiters?:
83 | | "dollar"
84 | | "bracket"
85 | | {
86 | inlineRegex?: string;
87 | blockRegex?: string;
88 | inlineStart?: string;
89 | inlineEnd?: string;
90 | blockStart?: string;
91 | blockEnd?: string;
92 | };
93 |
94 | /** If and how to represent math nodes in the raw text output (e.g., `editor.getText()`)
95 | *
96 | * - `"none"`: do not include in text at all
97 | * - `"raw-latex"`: include the latex source, without delimiters (e.g., `\frac{1}{n}`)
98 | * - `{placeholder: "[...]`"}: represent all math nodes as a fixed placeholder string (e.g., `[...]`)
99 | *
100 | * The option delimited-latex is currently disabled because of issues with it re-triggering input rules (see also https://github.com/ueberdosis/tiptap/issues/2946).
101 | *
102 | * - ~`"delimited-latex"`: include the latex source with delimiters (e.g., `$\frac{1}{n}$`)~
103 | */
104 | renderTextMode?: "none"|"raw-latex"|{placeholder: string}, // |"delimited-latex"
105 | }
106 | export const DEFAULT_OPTIONS: MathExtensionOption = { addInlineMath: true, evaluation: false, delimiters: "dollar", renderTextMode: "raw-latex" };
107 | ```
108 |
109 | See https://katex.org/docs/options.html for a complete list of the available KaTeX options.
110 |
111 | ## Related or Used Projects
112 |
113 | - [Tiptap Editor](https://github.com/ueberdosis/tiptap): The extensible editor for which this is an extension.
114 | - [KaTeX](https://github.com/KaTeX/KaTeX): A LaTeX rendering engine for the web, used to render LaTeX expressions.
115 | - [Evaluatex.js](https://github.com/arthanzel/evaluatex): Used to evaluate LaTeX expressions to a numeric value (e.g., `1 + (2 \cdot 3) = 7`).
116 | - [Vite](https://github.com/vitejs/vite): Used to serve the example demo project.
117 |
--------------------------------------------------------------------------------
/packages/tiptap-math-extension/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/
2 | module.exports = {
3 | testEnvironment: "node",
4 | transform: {
5 | "^.+.tsx?$": ["ts-jest",{}],
6 | },
7 | };
--------------------------------------------------------------------------------
/packages/tiptap-math-extension/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@aarkue/tiptap-math-extension",
3 | "author": "aarkue",
4 | "homepage": "https://github.com/aarkue/tiptap-math-extension",
5 | "license": "MIT",
6 | "version": "1.3.5",
7 | "description": "Math/LaTeX Extension for the TipTap Editor ",
8 | "main": "dist/index.cjs.js",
9 | "module": "dist/index.js",
10 | "types": "dist/index.d.ts",
11 | "scripts": {
12 | "clean": "rm -rf dist",
13 | "build": "npm run clean && rollup -c",
14 | "dev": "npm run clean && rollup -c -w",
15 | "test": "jest"
16 | },
17 | "devDependencies": {
18 | "@rollup/plugin-babel": "^6.0.4",
19 | "@rollup/plugin-commonjs": "^26.0.3",
20 | "@tiptap/core": "^2.0.0",
21 | "@tiptap/pm": "^2.0.0",
22 | "@types/evaluatex": "^2.2.4",
23 | "@types/jest": "^29.5.12",
24 | "@types/katex": "^0.16.7",
25 | "jest": "^29.7.0",
26 | "rollup": "^4.27.4",
27 | "rollup-plugin-auto-external": "^2.0.0",
28 | "rollup-plugin-sourcemaps": "^0.6.3",
29 | "rollup-plugin-typescript2": "^0.36.0",
30 | "ts-jest": "^29.2.2",
31 | "typescript": "^4.9.5"
32 | },
33 | "overrides": {
34 | "@rollup/pluginutils": "^5.1.3"
35 | },
36 | "peerDependencies": {
37 | "@tiptap/core": "^2.0.0",
38 | "@tiptap/pm": "^2.0.0"
39 | },
40 | "dependencies": {
41 | "evaluatex": "^2.2.0",
42 | "katex": "^0.16.7"
43 | }
44 | }
--------------------------------------------------------------------------------
/packages/tiptap-math-extension/rollup.config.js:
--------------------------------------------------------------------------------
1 | // rollup.config.js
2 |
3 | const autoExternal = require("rollup-plugin-auto-external");
4 | const sourcemaps = require("rollup-plugin-sourcemaps");
5 | const commonjs = require("@rollup/plugin-commonjs");
6 | const babel = require("@rollup/plugin-babel");
7 | const typescript = require("rollup-plugin-typescript2");
8 |
9 | const config = {
10 | input: "src/index.ts",
11 | output: [
12 | {
13 | file: "dist/index.cjs.js",
14 | format: "cjs",
15 | exports: "named",
16 | sourcemap: true,
17 | },
18 | {
19 | file: "dist/index.js",
20 | format: "esm",
21 | exports: "named",
22 | sourcemap: true,
23 | },
24 | ],
25 | external: ['evaluatex/dist/evaluatex'],
26 | plugins: [
27 | autoExternal({ packagePath: "./package.json" }),
28 | sourcemaps(),
29 | babel({babelHelpers: "bundled"}),
30 | commonjs(),
31 | typescript(),
32 | ],
33 | };
34 |
35 | module.exports = config;
36 |
--------------------------------------------------------------------------------
/packages/tiptap-math-extension/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Extension } from "@tiptap/core";
2 |
3 | import { InlineMathNode } from "./inline-math-node";
4 | import { DEFAULT_OPTIONS, MathExtensionOption } from "./util/options";
5 |
6 | export const MATH_EXTENSION_NAME = "mathExtension";
7 | export const MathExtension = Extension.create({
8 | name: MATH_EXTENSION_NAME,
9 |
10 | addOptions() {
11 | return DEFAULT_OPTIONS;
12 | },
13 |
14 | addExtensions() {
15 | const extensions = [];
16 | if (this.options.addInlineMath !== false) {
17 | extensions.push(InlineMathNode.configure(this.options));
18 | }
19 |
20 | return extensions;
21 | },
22 | });
23 |
24 | export { InlineMathNode, DEFAULT_OPTIONS };
25 | export type { MathExtensionOption };
26 | export default MathExtension;
27 |
--------------------------------------------------------------------------------
/packages/tiptap-math-extension/src/inline-math-node.ts:
--------------------------------------------------------------------------------
1 | import { Content, InputRule, mergeAttributes, Node, PasteRule } from "@tiptap/core";
2 | import katex from "katex";
3 | import {
4 | AllVariableUpdateListeners,
5 | MathVariables,
6 | VariableUpdateListeners,
7 | } from "./latex-evaluation/evaluate-expression";
8 | import { generateID } from "./util/generate-id";
9 | import { updateEvaluation } from "./latex-evaluation/update-evaluation";
10 | import { DEFAULT_OPTIONS, MathExtensionOption, MathExtensionOption as MathExtensionOptions } from "./util/options";
11 |
12 | export const InlineMathNode = Node.create({
13 | name: "inlineMath",
14 | group: "inline",
15 | inline: true,
16 | selectable: true,
17 | atom: true,
18 |
19 | addOptions() {
20 | return DEFAULT_OPTIONS;
21 | },
22 |
23 | addAttributes() {
24 | return {
25 | latex: {
26 | default: "x_1",
27 | parseHTML: (element) => element.getAttribute("data-latex"),
28 | renderHTML: (attributes) => {
29 | return {
30 | "data-latex": attributes.latex,
31 | };
32 | },
33 | },
34 | evaluate: {
35 | default: "no",
36 | parseHTML: (element) => element.getAttribute("data-evaluate"),
37 | renderHTML: (attributes) => {
38 | return {
39 | "data-evaluate": attributes.evaluate,
40 | };
41 | },
42 | },
43 | display: {
44 | default: "no",
45 | parseHTML: (element) => element.getAttribute("data-display"),
46 | renderHTML: (attributes) => {
47 | return {
48 | "data-display": attributes.display,
49 | };
50 | },
51 | },
52 | };
53 | },
54 |
55 | addInputRules() {
56 | const inputRules = [];
57 | const blockRegex = getRegexFromOptions("block", this.options);
58 | if (blockRegex !== undefined) {
59 | inputRules.push(
60 | new InputRule({
61 | find: new RegExp(blockRegex, ""),
62 | handler: (props) => {
63 | let latex = props.match[1];
64 | if (props.match[1].length === 0) {
65 | return;
66 | }
67 | const showRes = latex.endsWith("=");
68 | if (showRes) {
69 | latex = latex.substring(0, latex.length - 1);
70 | }
71 | let content: Content = [
72 | {
73 | type: "inlineMath",
74 | attrs: { latex: latex, evaluate: showRes ? "yes" : "no", display: "yes" },
75 | },
76 | ];
77 | props
78 | .chain()
79 | .insertContentAt(
80 | {
81 | from: props.range.from,
82 | to: props.range.to,
83 | },
84 | content,
85 | { updateSelection: true }
86 | )
87 | .run();
88 | },
89 | })
90 | );
91 | }
92 | const inlineRegex = getRegexFromOptions("inline", this.options);
93 | if (inlineRegex !== undefined) {
94 | inputRules.push(
95 | new InputRule({
96 | find: new RegExp(inlineRegex, ""),
97 | handler: (props) => {
98 | if (props.match[1].length === 0) {
99 | return;
100 | }
101 | // TODO: Better handling, also for custom regexes
102 | // This prevents that $$x_1$ (a block expression in progress) is already captured by inline input rules
103 | if (
104 | (this.options.delimiters === undefined || this.options.delimiters === "dollar") &&
105 | (props.match[1].startsWith("$") || props.match[0].startsWith("$$"))
106 | ) {
107 | return;
108 | }
109 | let latex = props.match[1];
110 | latex = latex.trim();
111 | const showRes = latex.endsWith("=");
112 | if (showRes) {
113 | latex = latex.substring(0, latex.length - 1);
114 | }
115 | let content: Content = [
116 | {
117 | type: "inlineMath",
118 | attrs: { latex: latex, evaluate: showRes ? "yes" : "no", display: "no" },
119 | },
120 | ];
121 | props
122 | .chain()
123 | .insertContentAt(
124 | {
125 | from: props.range.from,
126 | to: props.range.to,
127 | },
128 | content,
129 | { updateSelection: true }
130 | )
131 | .run();
132 | },
133 | })
134 | );
135 | }
136 | return inputRules;
137 | },
138 |
139 | addPasteRules() {
140 | const pasteRules = [];
141 | const blockRegex = getRegexFromOptions("block", this.options);
142 | if (blockRegex !== undefined) {
143 | pasteRules.push(
144 | new PasteRule({
145 | find: new RegExp(blockRegex, "g"),
146 | handler: (props) => {
147 | const latex = props.match[1];
148 | props
149 | .chain()
150 | .insertContentAt(
151 | { from: props.range.from, to: props.range.to },
152 | [
153 | {
154 | type: "inlineMath",
155 | attrs: { latex: latex, evaluate: "no", display: "yes" },
156 | },
157 | ],
158 | { updateSelection: true }
159 | )
160 | .run();
161 | },
162 | })
163 | );
164 | }
165 | const inlineRegex = getRegexFromOptions("inline", this.options);
166 | if (inlineRegex !== undefined) {
167 | pasteRules.push(
168 | new PasteRule({
169 | find: new RegExp(inlineRegex, "g"),
170 | handler: (props) => {
171 | const latex = props.match[1];
172 | props
173 | .chain()
174 | .insertContentAt(
175 | { from: props.range.from, to: props.range.to },
176 | [
177 | {
178 | type: "inlineMath",
179 | attrs: { latex: latex, evaluate: "no", display: "no" },
180 | },
181 | ],
182 | { updateSelection: true }
183 | )
184 | .run();
185 | },
186 | })
187 | );
188 | }
189 | return pasteRules;
190 | },
191 |
192 | parseHTML() {
193 | return [
194 | {
195 | tag: `span[data-type="${this.name}"]`,
196 | },
197 | ];
198 | },
199 |
200 | renderHTML({ node, HTMLAttributes }) {
201 | let latex = "x";
202 | if (node.attrs.latex && typeof node.attrs.latex == "string") {
203 | latex = node.attrs.latex;
204 | }
205 | return [
206 | "span",
207 | mergeAttributes(HTMLAttributes, {
208 | "data-type": this.name,
209 | }),
210 | getDelimiter(node.attrs.display === "yes" ? "block" : "inline", "start", this.options) +
211 | latex +
212 | getDelimiter(node.attrs.display === "yes" ? "block" : "inline", "end", this.options),
213 | ];
214 | },
215 |
216 | renderText({ node }) {
217 | if (this.options.renderTextMode === "none") {
218 | return "";
219 | }
220 | if (typeof this.options.renderTextMode === 'object' && "placeholder" in this.options.renderTextMode) {
221 | return this.options.renderTextMode.placeholder;
222 | }
223 | let latex = "x";
224 | if (node.attrs.latex && typeof node.attrs.latex == "string") {
225 | latex = node.attrs.latex;
226 | }
227 | // if ( this.options.renderTextMode === "raw-latex") {
228 | return latex;
229 | // }
230 | // TODO: Maybe re-enable the delimited-latex mode once there is a way to not re-trigger the input rule :(
231 | // if (this.options.renderTextMode === undefined || this.options.renderTextMode === "delimited-latex") {
232 | // const displayMode = node.attrs.display === "yes";
233 | // const firstDelimiter = getDelimiter(displayMode ? "block" : "inline", "start", this.options);
234 | // let secondDelimiter = getDelimiter(displayMode ? "block" : "inline", "end", this.options);
235 | // return firstDelimiter + latex + secondDelimiter;
236 | // }
237 |
238 | },
239 |
240 | addKeyboardShortcuts() {
241 | return {
242 | Backspace: () =>
243 | this.editor.commands.command(({ tr, state }) => {
244 | let isMention = false;
245 | const { selection } = state;
246 | const { empty, anchor } = selection;
247 | if (!empty) {
248 | return false;
249 | }
250 | state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
251 | if (node.type.name === this.name) {
252 | isMention = true;
253 | const displayMode = node.attrs.display === "yes";
254 | const firstDelimiter = getDelimiter(displayMode ? "block" : "inline", "start", this.options);
255 | let secondDelimiter = getDelimiter(displayMode ? "block" : "inline", "end", this.options);
256 | secondDelimiter = secondDelimiter.substring(0, secondDelimiter.length - 1);
257 | tr.insertText(firstDelimiter + (node.attrs.latex || "") + secondDelimiter, pos, anchor);
258 | }
259 | });
260 | return isMention;
261 | }),
262 | };
263 | },
264 |
265 | addNodeView() {
266 | return ({ HTMLAttributes, node, getPos, editor }) => {
267 | const outerSpan = document.createElement("span");
268 | const span = document.createElement("span");
269 | outerSpan.appendChild(span);
270 | let latex = "x_1";
271 | if ("data-latex" in HTMLAttributes && typeof HTMLAttributes["data-latex"] === "string") {
272 | latex = HTMLAttributes["data-latex"];
273 | }
274 | let displayMode = node.attrs.display === "yes";
275 | katex.render(latex, span, {
276 | displayMode: displayMode,
277 | throwOnError: false,
278 | ...(this.options.katexOptions ?? {}),
279 | });
280 |
281 | outerSpan.classList.add("tiptap-math", "latex");
282 |
283 | let showEvalResult = node.attrs.evaluate === "yes";
284 | const id = generateID();
285 |
286 | const shouldEvaluate = this.options.evaluation;
287 | // Should evaluate (i.e., also register new variables etc.)
288 | if (shouldEvaluate) {
289 | outerSpan.title = "Click to toggle result";
290 | outerSpan.style.cursor = "pointer";
291 | const resultSpan = document.createElement("span");
292 | outerSpan.append(resultSpan);
293 | resultSpan.classList.add("tiptap-math", "result");
294 | resultSpan.classList.add("katex");
295 | const evalRes = updateEvaluation(latex, id, resultSpan, showEvalResult, this.editor.storage.inlineMath);
296 | // On click, update the evaluate attribute (effectively triggering whether the result is shown)
297 | outerSpan.addEventListener("click", (ev) => {
298 | if (editor.isEditable && typeof getPos === "function") {
299 | editor
300 | .chain()
301 | .command(({ tr }) => {
302 | const position = getPos();
303 | tr.setNodeAttribute(position, "evaluate", !showEvalResult ? "yes" : "no");
304 | return true;
305 | })
306 | .run();
307 | }
308 | ev.preventDefault();
309 | ev.stopPropagation();
310 | ev.stopImmediatePropagation();
311 | });
312 |
313 | return {
314 | dom: outerSpan,
315 | destroy: () => {
316 | if (evalRes?.variablesUsed) {
317 | // De-register listeners
318 | for (const v of evalRes.variablesUsed) {
319 | let listenersForV: VariableUpdateListeners = this.editor.storage.inlineMath.variableListeners[v];
320 | if (listenersForV == undefined) {
321 | listenersForV = [];
322 | }
323 | this.editor.storage.inlineMath.variableListeners[v] = listenersForV.filter((l) => l.id !== id);
324 | }
325 | }
326 | },
327 | };
328 | } else {
329 | // Should not evaluate math expression (just display them)
330 | return {
331 | dom: outerSpan,
332 | };
333 | }
334 | };
335 | },
336 |
337 | addStorage(): {
338 | variables: MathVariables;
339 | variableListeners: AllVariableUpdateListeners;
340 | } {
341 | return {
342 | variables: {},
343 | variableListeners: {},
344 | };
345 | },
346 | });
347 |
348 | export function getRegexFromOptions(mode: "inline" | "block", options: MathExtensionOption): string | undefined {
349 | if (options.delimiters === undefined || options.delimiters === "dollar") {
350 | if (mode === "inline") {
351 | return String.raw`(? any }[] | undefined;
5 | export type AllVariableUpdateListeners = Record;
6 | export type MathVariable = { aliases: string[]; value: number };
7 | export type MathVariables = Record;
8 |
9 | export function evaluateExpression(
10 | latex: string,
11 | variables: MathVariables,
12 | variableListeners: AllVariableUpdateListeners
13 | ):
14 | | {
15 | result: number | undefined;
16 | definedVariableID: string | undefined;
17 | variablesUsed: Set;
18 | }
19 | | undefined {
20 | try {
21 | const regex = /\\pi({})?/g;
22 | let changedLatex = latex.replace(regex, "{PI}").trim();
23 | let definesVariable = undefined;
24 | const assignmentRegex = /^(.*)\s*:=\s*/;
25 | const assRegexRes = assignmentRegex.exec(changedLatex);
26 | if (assRegexRes && assRegexRes[0]) {
27 | changedLatex = changedLatex.substring(assRegexRes[0].length);
28 | definesVariable = assRegexRes[1].trim();
29 | }
30 | const splitAtEq = changedLatex.split("=");
31 | if(splitAtEq[splitAtEq.length - 1].length > 0){
32 | changedLatex = splitAtEq[splitAtEq.length - 1];
33 | }else if (splitAtEq.length >= 2){
34 | changedLatex = splitAtEq[splitAtEq.length - 2];
35 | }
36 | const variableObj: Record = {};
37 | let definedVariableID = undefined;
38 | let aliases: string[] = [];
39 | if (definesVariable) {
40 | aliases = getVariableAliases(definesVariable);
41 | }
42 | changedLatex = getVariableName(changedLatex.replace("}", "}"));
43 | console.log({aliases,changedLatex,variables})
44 | for (const id in variables) {
45 | const variable: MathVariable = variables[id];
46 | variableObj[id] = variable.value;
47 | for (const alias of variable.aliases) {
48 | // Replace all occurences of alias with
49 | const regexSafeAlias = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
50 | const r = new RegExp("(^|(?<=[^a-zA-Z]))" + regexSafeAlias + "($|(?=[^a-zA-Z]))", "g");
51 | console.log("changedLatex before",changedLatex)
52 | changedLatex = changedLatex.replace(r, id);
53 | console.log("changedLatex after",changedLatex)
54 | for (const a of aliases) {
55 | if (alias === a) {
56 | definedVariableID = id;
57 | }
58 | }
59 | }
60 | }
61 | const res = evaluatex(changedLatex, {}, { latex: true });
62 | const usedVars: Set = new Set(res.tokens.filter((t) => t.type === "SYMBOL").map((t) => t.value as string));
63 | console.log({usedVars,res});
64 | const resNum = res(variableObj);
65 |
66 | if (definesVariable !== undefined) {
67 | if (definedVariableID === undefined) {
68 | definedVariableID = generateID();
69 | }
70 | // Cyclic dependency! Fail early
71 | if (usedVars.has(definedVariableID)) {
72 | return undefined;
73 | }
74 | variables[definedVariableID] = {
75 | value: resNum,
76 | aliases: aliases,
77 | };
78 | const listeners: VariableUpdateListeners = variableListeners[definedVariableID];
79 | if (listeners != undefined) {
80 | for (const l of listeners) {
81 | l.onUpdate();
82 | }
83 | }
84 | }
85 |
86 | return {
87 | definedVariableID: definedVariableID,
88 | variablesUsed: usedVars,
89 | result: resNum,
90 | };
91 | } catch (e) {
92 | console.log(e);
93 | return undefined;
94 | }
95 | }
96 |
97 | function getVariableAliases(variable: string) {
98 | return [getVariableName(variable),getVariableName(variable,true)];
99 | }
100 |
101 | function parseInnerVariablePart(variablePart: string, skipOptionalBrackets = false): string {
102 | variablePart = variablePart.trim();
103 | let mode: "main" | "sub" | "sup" | "after" = "main";
104 | let depth = 0;
105 | let prevBackslash = false;
106 | let main = "";
107 | let sup = "";
108 | let sub = "";
109 | let after = "";
110 | let inCommand = false;
111 | for (const c of variablePart) {
112 | let writeC = true;
113 | if (c === "\\") {
114 | if (!prevBackslash && depth === 0) {
115 | inCommand = true;
116 | }
117 | prevBackslash = !prevBackslash;
118 | } else {
119 | prevBackslash = false;
120 | }
121 | if (c === " " && depth === 0) {
122 | inCommand = false;
123 | }
124 | if (!prevBackslash) {
125 | if (c === "_" && depth === 0 && mode === "main") {
126 | mode = "sub";
127 | writeC = false;
128 | }
129 | if (c === "^" && depth === 0 && mode === "main") {
130 | mode = "sup";
131 | writeC = false;
132 | }
133 | if (c === "{") {
134 | depth++;
135 | }
136 | if (c === "}") {
137 | depth--;
138 | if (depth === 0) {
139 | inCommand = false;
140 | }
141 | }
142 | }
143 | if (mode === "main" && c === " " && depth === 0) {
144 | mode = "after";
145 | writeC = false;
146 | }
147 | if (mode === "main" && c === "\\" && depth === 0 && main != "") {
148 | mode = "after";
149 | }
150 | if (writeC) {
151 | if (mode === "main") {
152 | main += c;
153 | } else if (mode === "sub") {
154 | sub += c;
155 | } else if (mode === "sup") {
156 | sup += c;
157 | } else if (mode === "after") {
158 | after += c;
159 | }
160 | // Unless in a "group" {...}, go back to main mode
161 | // or command
162 | if ((mode === "sub" || mode == "sup") && depth === 0 && !inCommand) {
163 | mode = "main";
164 | }
165 | }
166 | }
167 | if (sup.startsWith("{") && sup.endsWith("}")) {
168 | sup = sup.substring(1, sup.length - 1);
169 | }
170 | if (sub.startsWith("{") && sub.endsWith("}")) {
171 | sub = sub.substring(1, sub.length - 1);
172 | }
173 | let subpart = sub.trim()
174 | let suppart = sup.trim()
175 | if(skipOptionalBrackets && subpart.indexOf(" ") === -1){
176 | subpart = sub !== "" ? `_${subpart}` : "";
177 | }else{
178 | subpart = sub !== "" ? `_{${subpart}}` : "";
179 | }
180 | if(skipOptionalBrackets && suppart.indexOf(" ") === -1){
181 | suppart = sup !== "" ? `^${sup.trim()}` : "";
182 | }else{
183 | suppart = sup !== "" ? `^{${sup.trim()}}` : "";
184 | }
185 | const processedAfter = after !== "" ? " " + parseInnerVariablePart(after) : "";
186 | return `${main}${subpart}${suppart}${processedAfter}`;
187 | }
188 |
189 | function getVariableName(variablePart: string, skipOptionalBrackets = false): string {
190 | variablePart = variablePart.trim();
191 | if (variablePart.startsWith("{") && variablePart.endsWith("}")) {
192 | return getVariableName(variablePart.substring(1, variablePart.length - 1));
193 | }
194 | const colorRegex = /(?![^\\])\\color{\w*}/g;
195 | if (colorRegex.test(variablePart)) {
196 | return getVariableName(variablePart.replace(colorRegex, " "));
197 | }
198 |
199 | const textColorRegex = /\\textcolor{\w*}/g;
200 | if (textColorRegex.test(variablePart)) {
201 | return getVariableName(variablePart.replace(textColorRegex, " "));
202 | }
203 | return parseInnerVariablePart(variablePart, skipOptionalBrackets);
204 | }
205 |
--------------------------------------------------------------------------------
/packages/tiptap-math-extension/src/latex-evaluation/update-evaluation.ts:
--------------------------------------------------------------------------------
1 | import { VariableUpdateListeners, evaluateExpression } from "./evaluate-expression";
2 |
3 | export function updateEvaluation(latex: string, id: string, resultSpan: HTMLSpanElement, showEvalResult: boolean, editorStorage: any) {
4 | let evalRes = evaluateExpression(
5 | latex,
6 | editorStorage.variables,
7 | editorStorage.variableListeners
8 | ); // Do not show if error occurs (in general, we probably want to make showing the result optional)
9 | const updateResultSpan = () => {
10 | if (evalRes?.result) {
11 | if (evalRes.result.toString().split(".")[1]?.length > 5) {
12 | resultSpan.innerText = "=" + evalRes.result.toFixed(4);
13 | } else {
14 | resultSpan.innerText = "=" + evalRes.result.toString();
15 | }
16 | } else {
17 | resultSpan.innerText = "=Error";
18 | }
19 |
20 | if (!showEvalResult) {
21 | resultSpan.style.display = "none";
22 | } else {
23 | resultSpan.style.display = "inline-block";
24 | }
25 | };
26 | updateResultSpan();
27 | if (evalRes?.variablesUsed) {
28 | for (const v of evalRes.variablesUsed) {
29 | // Register Listeners
30 | let listenersForV: VariableUpdateListeners = editorStorage.variableListeners[v];
31 | if (listenersForV == undefined) {
32 | listenersForV = [];
33 | }
34 | listenersForV.push({
35 | id: id,
36 | onUpdate: () => {
37 | {
38 | evalRes = evaluateExpression(
39 | latex,
40 | editorStorage.variables,
41 | editorStorage.variableListeners
42 | );
43 | updateResultSpan();
44 | }
45 | },
46 | });
47 | editorStorage.variableListeners[v] = listenersForV;
48 | }
49 | }
50 | return evalRes;
51 | }
52 |
--------------------------------------------------------------------------------
/packages/tiptap-math-extension/src/tests/regex.test.ts:
--------------------------------------------------------------------------------
1 | import { getRegexFromOptions } from "../inline-math-node";
2 | import { DEFAULT_OPTIONS } from "../util/options";
3 |
4 | const raw = String.raw;
5 | test("Inline Dollar Math Regex", () => {
6 | const options = { ...DEFAULT_OPTIONS };
7 | options.delimiters = "dollar";
8 | const r = new RegExp(getRegexFromOptions("inline", options), "");
9 | expect(r.exec(raw`$x_1$`)[1]).toStrictEqual("x_1");
10 | expect(r.exec(raw`$x_2$`)[1]).toStrictEqual("x_2");
11 | expect(r.exec(raw`$\sum_{i=1}^n i$`)[1]).toStrictEqual(raw`\sum_{i=1}^n i`);
12 | expect(r.exec(raw`$x \cdot y$`)[1]).toStrictEqual(raw`x \cdot y`);
13 | expect(r.exec(raw`$x \cdot 4$`)[1]).toStrictEqual(raw`x \cdot 4`);
14 | expect(r.exec(raw`$4 \cdot x$`)[1]).toStrictEqual(raw`4 \cdot x`);
15 | expect(r.exec(raw`$4 \cdot 5$`)[1]).toStrictEqual(raw`4 \cdot 5`);
16 | expect(r.exec(raw`$\left( \frac{1}{2} \right)$`)[1]).toStrictEqual(raw`\left( \frac{1}{2} \right)`);
17 | expect(r.exec(raw`$\$$`)[1]).toStrictEqual(raw`\$`);
18 | expect(r.exec(raw`$1$`)[1]).toStrictEqual(raw`1`);
19 | // The below test case is, of course, not a wanted match, but we definetely want to have the second $ as part of the input!
20 | // This allows filtering based on the match (i.e., if it starts with $: return)
21 | expect(r.exec(raw`$$x_1$$`)).toStrictEqual(null);
22 | expect(r.exec(raw`$1.5$`)[1]).toStrictEqual(raw`1.5`);
23 | expect(r.exec(raw`$1.23456789$`)[1]).toStrictEqual(raw`1.23456789`);
24 | expect(r.exec(raw`One scoop is $2 and two are $3`)).toStrictEqual(null);
25 | expect(r.exec(raw`One scoop is 2$, and two are 3$`)).toStrictEqual(null);
26 | expect(r.exec(raw`$10 ($5)`)).toStrictEqual(null);
27 | expect(r.exec(raw`I have $120 ($40 from my allowance and $80 from the card Grandma sent me on my birthday).`)).toStrictEqual(null);
28 | // Here the danger is matching starting from the $120; We only want to match $40$!
29 | expect(r.exec(raw`I have $120 ($40$ from my allowance and $80 from the card Grandma sent me on my birthday).`)[1]).toStrictEqual(raw`40`);
30 | // ...or $80$
31 | expect(r.exec(raw`I have $120 ($40 from my allowance and $80$ from the card Grandma sent me on my birthday).`)[1]).toStrictEqual(raw`80`);
32 | expect(r.exec(raw`I have $\$120$ ($40 from my allowance and $80$ from the card Grandma sent me on my birthday).`)[1]).toStrictEqual(raw`\$120`);
33 | expect(r.exec(raw`I have $120 ($\$40$ from my allowance and $\$80$ from the card Grandma sent me on my birthday).`)[1]).toStrictEqual(raw`\$40`);
34 | expect(r.exec(raw`I have $120 ($40 from my allowance and $\$80$ from the card Grandma sent me on my birthday).`)[1]).toStrictEqual(raw`\$80`);
35 |
36 | expect(r.exec(raw`I gave Cynthia $5.00 and she said, "$5.00? That's really all you're good for?"`)).toStrictEqual(null);
37 | expect(r.exec('${"x"}$')[1]).toStrictEqual('{"x"}');
38 |
39 | // Tests with global regex (e.g., relevant for pasting)
40 | const rg = new RegExp(getRegexFromOptions("inline", options), "g");
41 | expect([...raw`I have $\$120$ ($40$ from my allowance and $80$ from the card Grandma sent me on my birthday).`.match(rg)]).toStrictEqual([raw`$\$120$`,raw`$40$`,raw`$80$`]);
42 | });
43 |
44 | test("Display Dollar Math Regex", () => {
45 | const options = { ...DEFAULT_OPTIONS };
46 | options.delimiters = "dollar";
47 | const r = new RegExp(getRegexFromOptions("block", options), "");
48 | expect(r.exec(raw`$$x_1$$`)[1]).toStrictEqual("x_1");
49 | expect(r.exec(raw`$$x_2$$`)[1]).toStrictEqual("x_2");
50 | expect(r.exec(raw`$$\sum_{i=1}^n i$$`)[1]).toStrictEqual(raw`\sum_{i=1}^n i`);
51 | expect(r.exec(raw`$$x \cdot y$$`)[1]).toStrictEqual(raw`x \cdot y`);
52 | expect(r.exec(raw`$$x \cdot 4$$`)[1]).toStrictEqual(raw`x \cdot 4`);
53 | expect(r.exec(raw`$$4 \cdot x$$`)[1]).toStrictEqual(raw`4 \cdot x`);
54 | expect(r.exec(raw`$$4 \cdot 5$$`)[1]).toStrictEqual(raw`4 \cdot 5`);
55 | expect(r.exec(raw`$$\left( \frac{1}{2} \right)$$`)[1]).toStrictEqual(raw`\left( \frac{1}{2} \right)`);
56 | expect(r.exec(raw`$$\$$$`)[1]).toStrictEqual(raw`\$`);
57 | expect(r.exec(raw`$$1$$`)[1]).toStrictEqual(raw`1`);
58 | expect(r.exec(raw`$$1.5$$`)[1]).toStrictEqual(raw`1.5`);
59 | expect(r.exec(raw`$$1.23456789$$`)[1]).toStrictEqual(raw`1.23456789`);
60 | expect(r.exec(raw`One scoop is $2 and two are $3`)).toStrictEqual(null);
61 | });
62 |
63 | test("Inline Bracket Math Regex", () => {
64 | const options = { ...DEFAULT_OPTIONS };
65 | options.delimiters = "bracket";
66 | const r = new RegExp(getRegexFromOptions("inline", options), "");
67 | expect(r.exec(raw`\(x_1\)`)[1]).toStrictEqual("x_1");
68 | expect(r.exec(raw`\(x_2\)`)[1]).toStrictEqual("x_2");
69 | expect(r.exec(raw`\(\sum_{i=1}^n i\)`)[1]).toStrictEqual(raw`\sum_{i=1}^n i`);
70 | expect(r.exec(raw`\(x \cdot y\)`)[1]).toStrictEqual(raw`x \cdot y`);
71 | expect(r.exec(raw`\(x \cdot 4\)`)[1]).toStrictEqual(raw`x \cdot 4`);
72 | expect(r.exec(raw`\(4 \cdot x\)`)[1]).toStrictEqual(raw`4 \cdot x`);
73 | expect(r.exec(raw`\(4 \cdot 5\)`)[1]).toStrictEqual(raw`4 \cdot 5`);
74 | expect(r.exec(raw`\(\left( \frac{1}{2} \right)\)`)[1]).toStrictEqual(raw`\left( \frac{1}{2} \right)`);
75 | expect(r.exec(raw`\(\$\)`)[1]).toStrictEqual(raw`\$`);
76 | expect(r.exec(raw`\(1\)`)[1]).toStrictEqual(raw`1`);
77 | expect(r.exec(raw`\(1.5\)`)[1]).toStrictEqual(raw`1.5`);
78 | expect(r.exec(raw`\(1.23456789\)`)[1]).toStrictEqual(raw`1.23456789`);
79 | expect(r.exec(raw`Solve task a) and b)`)).toStrictEqual(null);
80 | });
81 |
82 | test("Display Bracket Math Regex", () => {
83 | const options = { ...DEFAULT_OPTIONS };
84 | options.delimiters = "bracket";
85 | const r = new RegExp(getRegexFromOptions("block", options), "");
86 | expect(r.exec(raw`\[x_1\]`)[1]).toStrictEqual("x_1");
87 | expect(r.exec(raw`\[x_2\]`)[1]).toStrictEqual("x_2");
88 | expect(r.exec(raw`\[\sum_{i=1}^n i\]`)[1]).toStrictEqual(raw`\sum_{i=1}^n i`);
89 | expect(r.exec(raw`\[x \cdot y\]`)[1]).toStrictEqual(raw`x \cdot y`);
90 | expect(r.exec(raw`\[x \cdot 4\]`)[1]).toStrictEqual(raw`x \cdot 4`);
91 | expect(r.exec(raw`\[4 \cdot x\]`)[1]).toStrictEqual(raw`4 \cdot x`);
92 | expect(r.exec(raw`\[4 \cdot 5\]`)[1]).toStrictEqual(raw`4 \cdot 5`);
93 | expect(r.exec(raw`\[\left( \frac{1}{2} \right)\]`)[1]).toStrictEqual(raw`\left( \frac{1}{2} \right)`);
94 | expect(r.exec(raw`\[\$\]`)[1]).toStrictEqual(raw`\$`);
95 | expect(r.exec(raw`\[1\]`)[1]).toStrictEqual(raw`1`);
96 | expect(r.exec(raw`\[1.5\]`)[1]).toStrictEqual(raw`1.5`);
97 | expect(r.exec(raw`\[1.23456789\]`)[1]).toStrictEqual(raw`1.23456789`);
98 | expect(r.exec(raw`Solve task a) and b)`)).toStrictEqual(null);
99 | });
100 |
--------------------------------------------------------------------------------
/packages/tiptap-math-extension/src/util/generate-id.ts:
--------------------------------------------------------------------------------
1 | // import { v4 } from "uuid";
2 |
3 | // This is not a secure/unpredictable ID, but this is simple and good enough for our case
4 | export function generateID() {
5 | // Note, that E is not included on purpose (to prevent any confusion with eulers number)
6 | const ALL_ALLOWED_CHARS_UPPER = [
7 | "A",
8 | "B",
9 | "C",
10 | "D",
11 | "F",
12 | "G",
13 | "H",
14 | "I",
15 | "J",
16 | "K",
17 | "L",
18 | "M",
19 | "N",
20 | "O",
21 | "P",
22 | "Q",
23 | "R",
24 | "S",
25 | "T",
26 | "U",
27 | "V",
28 | "W",
29 | "X",
30 | "Y",
31 | "Z",
32 | ];
33 | const RAND_ID_LEN = 36;
34 | let id = "";
35 | for (let i = 1; i <= RAND_ID_LEN; i++) {
36 | const c = ALL_ALLOWED_CHARS_UPPER[Math.floor(Math.random() * ALL_ALLOWED_CHARS_UPPER.length)];
37 | if (Math.random() > 0.5) {
38 | id += c.toLowerCase();
39 | } else {
40 | id += c;
41 | }
42 | }
43 | return id;
44 | // Alternative: use uuidv4
45 | // return v4()
46 | }
47 |
--------------------------------------------------------------------------------
/packages/tiptap-math-extension/src/util/options.ts:
--------------------------------------------------------------------------------
1 | import type { KatexOptions } from "katex";
2 | export interface MathExtensionOption {
3 | /** Evaluate LaTeX expressions */
4 | evaluation: boolean;
5 | /** Add InlineMath node type (currently required as inline is the only supported mode) */
6 | addInlineMath: boolean;
7 | /** KaTeX options to use for evaluation, see also https://katex.org/docs/options.html */
8 | katexOptions?: KatexOptions;
9 | /** Delimiters to auto-convert. Per default dollar-style (`dollar`) ($x_1$ and $$\sum_i i$$) are used.
10 | *
11 | * The `bracket` option corresponds to `\(x_1\)` and `\[\sum_i i \]`.
12 | *
13 | * Alternatively, custom inline/block regexes can be used.
14 | * The inner math content is expected to be the match at index 1 (`props.match[1]`).
15 | */
16 | delimiters?:
17 | | "dollar"
18 | | "bracket"
19 | | {
20 | inlineRegex?: string;
21 | blockRegex?: string;
22 | inlineStart?: string;
23 | inlineEnd?: string;
24 | blockStart?: string;
25 | blockEnd?: string;
26 | };
27 |
28 | /** If and how to represent math nodes in the raw text output (e.g., `editor.getText()`)
29 | *
30 | * - `"none"`: do not include in text at all
31 | * - `"raw-latex"`: include the latex source, without delimiters (e.g., `\frac{1}{n}`)
32 | * - `{placeholder: "[...]`"}: represent all math nodes as a fixed placeholder string (e.g., `[...]`)
33 | *
34 | * The option delimited-latex is currently disabled because of issues with it re-triggering input rules (see also https://github.com/ueberdosis/tiptap/issues/2946).
35 | *
36 | * - ~`"delimited-latex"`: include the latex source with delimiters (e.g., `$\frac{1}{n}$`)~
37 | */
38 | renderTextMode?: "none"|"raw-latex"|{placeholder: string}, // |"delimited-latex"
39 | }
40 | export const DEFAULT_OPTIONS: MathExtensionOption = { addInlineMath: true, evaluation: false, delimiters: "dollar", renderTextMode: "raw-latex" };
41 |
--------------------------------------------------------------------------------
/packages/tiptap-math-extension/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2015",
4 | "module": "ES2015",
5 | "lib": ["es2015", "dom"],
6 | "moduleResolution": "node",
7 | "declaration": true,
8 | "noEmit": true,
9 | "rootDir": "./src",
10 | "esModuleInterop": true
11 | },
12 | }
--------------------------------------------------------------------------------