├── 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!

4 |

5 | 6 | [![npm license](https://img.shields.io/npm/l/%40aarkue%2Ftiptap-math-extension)](https://www.npmjs.com/package/@aarkue/tiptap-math-extension) 7 | [![npm version](https://img.shields.io/npm/v/%40aarkue%2Ftiptap-math-extension)](https://www.npmjs.com/package/@aarkue/tiptap-math-extension) 8 | [![npm downloads](https://img.shields.io/npm/dy/%40aarkue%2Ftiptap-math-extension)](https://www.npmjs.com/package/@aarkue/tiptap-math-extension) 9 | 10 |

11 |
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 | ![2023-06-03_16-05](https://github.com/aarkue/tiptap-math-extension/assets/20766652/3f5cc6d5-f0eb-4c2a-9ba7-87367cfdf119) 58 | 59 | ![2023-06-03_16-05_1](https://github.com/aarkue/tiptap-math-extension/assets/20766652/a722b978-06ef-48c0-8aa0-ba9bedff58a1) 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 |

TipTap + Math

8 |

9 | Visit{" "} 10 | https://github.com/aarkue/tiptap-math-extension/{" "} 11 | for more information. 12 |

13 | 14 | 15 | ); 16 | } 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /example/src/TipTap.tsx: -------------------------------------------------------------------------------- 1 | import { useEditor, EditorContent } from "@tiptap/react"; 2 | import StarterKit from "@tiptap/starter-kit"; 3 | // import { MathExtension } from "@aarkue/tiptap-math-extension"; 4 | import { MathExtension } from "../../packages/tiptap-math-extension/src/index"; 5 | import "katex/dist/katex.min.css"; 6 | import { useEffect } from "react"; 7 | 8 | const Tiptap = () => { 9 | const editor = useEditor({ 10 | extensions: [ 11 | StarterKit, 12 | MathExtension.configure({ evaluation: true, katexOptions: { macros: { "\\B": "\\mathbb{B}" } }, delimiters: "dollar" }), 13 | ], 14 | content: `

Hello World! 15 |
16 |
17 | This is a sum: 18 |
19 |
20 | This is a block math expression: 21 |
22 | 23 |
24 |
25 | Cool, right?

`, 26 | }); 27 | 28 | useEffect(() => { 29 | if (editor) { 30 | console.log({ editor }); 31 | (window as any).tiptapEditor = editor; 32 | } 33 | }, [editor]); 34 | return ( 35 |
36 | 43 | 44 |
45 | ); 46 | }; 47 | 48 | export default Tiptap; 49 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | 4 | color-scheme: light dark; 5 | color: rgba(255, 255, 255, 0.87); 6 | background-color: #242424; 7 | 8 | font-synthesis: none; 9 | text-rendering: optimizeLegibility; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | -webkit-text-size-adjust: 100%; 13 | 14 | --alt-bg-color: #151414; 15 | --alt-fg-color: #fafafa; 16 | } 17 | 18 | body { 19 | margin: 0; 20 | } 21 | 22 | a { 23 | font-weight: 500; 24 | color: #646cff; 25 | text-decoration: inherit; 26 | } 27 | 28 | a:hover { 29 | color: #535bf2; 30 | } 31 | 32 | @media (prefers-color-scheme: light) { 33 | :root { 34 | color: #213547; 35 | background-color: #ffffff; 36 | 37 | --alt-bg-color: #fafafa; 38 | } 39 | a:hover { 40 | color: #747bff; 41 | } 42 | } 43 | 44 | .tiptap-editor { 45 | padding: 0.5rem; 46 | } 47 | 48 | .ProseMirror { 49 | background-color: var(--alt-bg-color); 50 | padding: 8px; 51 | border-radius: 8px; 52 | font-size: 1.2rem; 53 | } 54 | 55 | .tiptap-math.latex { 56 | display: inline-flex; 57 | align-items: center; 58 | width: fit-content; 59 | } 60 | 61 | .tiptap-math.latex.ProseMirror-selectednode { 62 | outline: 1px solid black; 63 | } 64 | 65 | .tiptap-math.result { 66 | background-color: #78e65618; 67 | border-bottom: rgb(68, 194, 68) 2px solid; 68 | padding-left: 4px; 69 | padding-right: 2px; 70 | height: fit-content; 71 | } 72 | -------------------------------------------------------------------------------- /example/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /example/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /example/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /packages/tiptap-math-extension/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /packages/tiptap-math-extension/README.md: -------------------------------------------------------------------------------- 1 |
2 |

Math/LaTeX Extension for the TipTap Editor

3 |

Use inline math expression / LaTeX directly in your editor!

4 |

5 | 6 | [![npm license](https://img.shields.io/npm/l/%40aarkue%2Ftiptap-math-extension)](https://www.npmjs.com/package/@aarkue/tiptap-math-extension) 7 | [![npm version](https://img.shields.io/npm/v/%40aarkue%2Ftiptap-math-extension)](https://www.npmjs.com/package/@aarkue/tiptap-math-extension) 8 | [![npm downloads](https://img.shields.io/npm/dy/%40aarkue%2Ftiptap-math-extension)](https://www.npmjs.com/package/@aarkue/tiptap-math-extension) 9 | 10 |

11 |
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 | ![2023-06-03_16-05](https://github.com/aarkue/tiptap-math-extension/assets/20766652/3f5cc6d5-f0eb-4c2a-9ba7-87367cfdf119) 58 | 59 | ![2023-06-03_16-05_1](https://github.com/aarkue/tiptap-math-extension/assets/20766652/a722b978-06ef-48c0-8aa0-ba9bedff58a1) 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 | } --------------------------------------------------------------------------------