@fnix/lexxy-mathjax 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -0
- package/package.json +47 -0
- package/src/elements/math_editor_dialog.js +117 -0
- package/src/extensions/mathjax_extension.js +154 -0
- package/src/helpers/mathjax_helper.js +62 -0
- package/src/index.js +3 -0
- package/src/nodes/math_node.js +115 -0
- package/styles/lexxy-mathjax.css +111 -0
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# @fnix/lexxy-mathjax
|
|
2
|
+
|
|
3
|
+
LaTeX equations for [Lexxy](https://lexxy.dev) (the rich text editor for Rails), rendered with [MathJax](https://www.mathjax.org).
|
|
4
|
+
|
|
5
|
+
- Toolbar button (and `Cmd/Ctrl+Shift+E`) opens a dialog with a LaTeX input and live preview
|
|
6
|
+
- Click any equation to edit it
|
|
7
|
+
- Inline (`e^{i\pi} + 1 = 0` in a sentence) and display/block modes
|
|
8
|
+
- Saved HTML stores the raw LaTeX in a `data-latex` attribute, so content round-trips cleanly and is typeset again on display pages
|
|
9
|
+
|
|
10
|
+
## How it works
|
|
11
|
+
|
|
12
|
+
Equations are stored in the document (and in what ActionText persists) as:
|
|
13
|
+
|
|
14
|
+
```html
|
|
15
|
+
<span class="lexxy-math" data-latex="\frac{a}{b}">\frac{a}{b}</span> <!-- inline -->
|
|
16
|
+
<div class="lexxy-math" data-latex="\int_0^1 x\,dx">...</div> <!-- display/block -->
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Inside the editor the extension typesets these with the page's MathJax. **MathJax is host-provided, not bundled**: load MathJax v3 (`tex-chtml`) on any page that uses the editor or displays saved content.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
### With importmap-rails (no build)
|
|
24
|
+
|
|
25
|
+
Vendor this package (or pin it) and pin lexxy's bare specifier so the extension resolves it:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# config/importmap.rb
|
|
29
|
+
pin "lexxy" # from the lexxy gem, per lexxy's install docs
|
|
30
|
+
pin "@37signals/lexxy", to: "lexxy.js" # alias used by @fnix/lexxy-mathjax
|
|
31
|
+
pin "@fnix/lexxy-mathjax", to: "lexxy-mathjax/index.js" # vendored src/ of this package
|
|
32
|
+
# ...pin the files under src/ as well if vendoring, e.g. via pin_all_from
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### With a JS bundler (esbuild / vite / webpack)
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install @fnix/lexxy-mathjax @37signals/lexxy
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Register the extension
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
import { configure } from "@37signals/lexxy"
|
|
45
|
+
import MathjaxExtension from "@fnix/lexxy-mathjax"
|
|
46
|
+
|
|
47
|
+
configure({
|
|
48
|
+
global: { extensions: [ MathjaxExtension ] }
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Include the stylesheet (`@fnix/lexxy-mathjax/styles`, or copy `styles/lexxy-mathjax.css` into your assets).
|
|
53
|
+
|
|
54
|
+
### Load MathJax
|
|
55
|
+
|
|
56
|
+
```html
|
|
57
|
+
<script>
|
|
58
|
+
window.MathJax = { chtml: { displayAlign: "center" } }
|
|
59
|
+
</script>
|
|
60
|
+
<script async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"></script>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
If MathJax is missing, the editor still works and equations show their raw LaTeX.
|
|
64
|
+
|
|
65
|
+
## Rails / ActionText integration
|
|
66
|
+
|
|
67
|
+
**1. Allow `data-latex` through the server-side sanitizer** (ActionText strips unknown attributes when rendering):
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
# config/initializers/lexxy_mathjax.rb
|
|
71
|
+
ActiveSupport.on_load(:action_text_content) do
|
|
72
|
+
ActionText::ContentHelper.allowed_attributes += [ "data-latex" ]
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**2. Typeset saved content on display pages.** Load MathJax (as above) plus a small script that renders `[data-latex]` elements, Turbo-compatible:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
function typesetEquations() {
|
|
80
|
+
const elements = document.querySelectorAll(".trix-content [data-latex], .lexxy-content [data-latex]")
|
|
81
|
+
if (elements.length === 0 || !window.MathJax?.typesetPromise) return
|
|
82
|
+
|
|
83
|
+
elements.forEach((el) => { el.textContent = el.getAttribute("data-latex") })
|
|
84
|
+
window.MathJax.typesetPromise([ ...elements ])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
document.addEventListener("turbo:load", typesetEquations)
|
|
88
|
+
document.addEventListener("DOMContentLoaded", typesetEquations)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Note: `typesetPromise` scans for `\(...\)` delimiters by default; the snippet above instead sets each element's content from `data-latex` and typesets just those elements. If you prefer delimiter scanning, configure `tex.inlineMath` accordingly.
|
|
92
|
+
|
|
93
|
+
## Demo
|
|
94
|
+
|
|
95
|
+
Try it online: https://fnix.github.io/lexxy-mathjax/
|
|
96
|
+
|
|
97
|
+
Or serve the repo statically and open the demo locally:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npx serve .
|
|
101
|
+
# then visit http://localhost:3000/demo/
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Tests
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm install
|
|
108
|
+
npm test
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Development notes
|
|
112
|
+
|
|
113
|
+
The code follows lexxy's own conventions: plain ES modules, vanilla JS, no build step. Structure:
|
|
114
|
+
|
|
115
|
+
- `src/nodes/math_node.js` — `DecoratorNode` storing `{ latex, display }`; `createDOM` typesets with MathJax, `exportDOM`/`importDOM` handle the `data-latex` HTML format
|
|
116
|
+
- `src/extensions/mathjax_extension.js` — the `Lexxy.Extension`: registers the node, the `insertMath` command, the toolbar button, and click-to-edit
|
|
117
|
+
- `src/elements/math_editor_dialog.js` — `<lexxy-math-editor>` dialog with live preview
|
|
118
|
+
- `src/helpers/mathjax_helper.js` — serialized MathJax typesetting with graceful degradation
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fnix/lexxy-mathjax",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MathJax LaTeX equation extension for Lexxy, the rich text editor for Rails.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./styles": "./styles/lexxy-mathjax.css"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"styles"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"lexxy",
|
|
17
|
+
"lexical",
|
|
18
|
+
"mathjax",
|
|
19
|
+
"latex",
|
|
20
|
+
"rails",
|
|
21
|
+
"actiontext"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/fnix/lexxy-mathjax.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/fnix/lexxy-mathjax#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/fnix/lexxy-mathjax/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@37signals/lexxy": ">= 0.9.0"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"demo": "npx serve ."
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@37signals/lexxy": "^0.9.24",
|
|
44
|
+
"jsdom": "^27.0.0",
|
|
45
|
+
"vitest": "^4.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { typesetInto } from "../helpers/mathjax_helper.js"
|
|
2
|
+
|
|
3
|
+
const PREVIEW_DEBOUNCE_INTERVAL = 150
|
|
4
|
+
|
|
5
|
+
export class MathEditorDialogElement extends HTMLElement {
|
|
6
|
+
#resolve = null
|
|
7
|
+
#previewTimer = null
|
|
8
|
+
|
|
9
|
+
connectedCallback() {
|
|
10
|
+
this.innerHTML = this.constructor.template
|
|
11
|
+
this.dialog = this.querySelector("dialog")
|
|
12
|
+
this.textarea = this.querySelector("textarea")
|
|
13
|
+
this.displayCheckbox = this.querySelector("input[name='display']")
|
|
14
|
+
this.preview = this.querySelector(".lexxy-math-editor__preview")
|
|
15
|
+
|
|
16
|
+
this.querySelector("form").addEventListener("submit", this.#handleSubmit)
|
|
17
|
+
this.dialog.addEventListener("close", this.#handleClose)
|
|
18
|
+
this.textarea.addEventListener("input", this.#schedulePreview)
|
|
19
|
+
this.textarea.addEventListener("keydown", this.#handleTextareaKeydown)
|
|
20
|
+
this.displayCheckbox.addEventListener("change", this.#refreshPreview)
|
|
21
|
+
this.querySelector("[data-behavior='cancel']").addEventListener("click", () => this.dialog.close())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
disconnectedCallback() {
|
|
25
|
+
clearTimeout(this.#previewTimer)
|
|
26
|
+
this.#settle(null)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Shows the dialog and resolves with { latex, display } on confirm, null on cancel.
|
|
30
|
+
open({ latex = "", display = false } = {}) {
|
|
31
|
+
this.textarea.value = latex
|
|
32
|
+
this.displayCheckbox.checked = display
|
|
33
|
+
this.#refreshPreview()
|
|
34
|
+
|
|
35
|
+
this.dialog.showModal()
|
|
36
|
+
this.textarea.focus()
|
|
37
|
+
this.textarea.select()
|
|
38
|
+
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
this.#resolve = resolve
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#handleSubmit = (event) => {
|
|
45
|
+
event.preventDefault()
|
|
46
|
+
|
|
47
|
+
const latex = this.textarea.value.trim()
|
|
48
|
+
this.#settle(latex ? { latex, display: this.displayCheckbox.checked } : null)
|
|
49
|
+
this.dialog.close()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#handleClose = () => {
|
|
53
|
+
this.#settle(null)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#handleTextareaKeydown = (event) => {
|
|
57
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
58
|
+
event.preventDefault()
|
|
59
|
+
this.querySelector("form").requestSubmit()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#schedulePreview = () => {
|
|
64
|
+
clearTimeout(this.#previewTimer)
|
|
65
|
+
this.#previewTimer = setTimeout(this.#refreshPreview, PREVIEW_DEBOUNCE_INTERVAL)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#refreshPreview = () => {
|
|
69
|
+
const latex = this.textarea.value.trim()
|
|
70
|
+
|
|
71
|
+
if (latex) {
|
|
72
|
+
typesetInto(this.preview, latex, { display: this.displayCheckbox.checked })
|
|
73
|
+
} else {
|
|
74
|
+
this.preview.textContent = ""
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#settle(result) {
|
|
79
|
+
if (this.#resolve) {
|
|
80
|
+
const resolve = this.#resolve
|
|
81
|
+
this.#resolve = null
|
|
82
|
+
resolve(result)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static get template() {
|
|
87
|
+
return `
|
|
88
|
+
<dialog class="lexxy-math-editor">
|
|
89
|
+
<form method="dialog" novalidate>
|
|
90
|
+
<label class="lexxy-math-editor__label">
|
|
91
|
+
LaTeX
|
|
92
|
+
<textarea name="latex" rows="3" spellcheck="false" autocomplete="off" placeholder="\\frac{a}{b}"></textarea>
|
|
93
|
+
</label>
|
|
94
|
+
|
|
95
|
+
<div class="lexxy-math-editor__preview" aria-live="polite"></div>
|
|
96
|
+
|
|
97
|
+
<div class="lexxy-math-editor__footer">
|
|
98
|
+
<label class="lexxy-math-editor__display-toggle">
|
|
99
|
+
<input type="checkbox" name="display"> Display as block
|
|
100
|
+
</label>
|
|
101
|
+
|
|
102
|
+
<div class="lexxy-math-editor__actions">
|
|
103
|
+
<button type="button" data-behavior="cancel">Cancel</button>
|
|
104
|
+
<button type="submit" data-behavior="confirm">Save</button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</form>
|
|
108
|
+
</dialog>
|
|
109
|
+
`
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function defineMathEditorDialogElement() {
|
|
114
|
+
if (!customElements.get("lexxy-math-editor")) {
|
|
115
|
+
customElements.define("lexxy-math-editor", MathEditorDialogElement)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Extension, Lexical } from "@37signals/lexxy"
|
|
2
|
+
import { MathNode, $createMathNode, $isMathNode } from "../nodes/math_node.js"
|
|
3
|
+
import { defineMathEditorDialogElement } from "../elements/math_editor_dialog.js"
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
$getNearestNodeFromDOMNode,
|
|
7
|
+
$getNodeByKey,
|
|
8
|
+
$insertNodes,
|
|
9
|
+
COMMAND_PRIORITY_NORMAL,
|
|
10
|
+
createCommand
|
|
11
|
+
} = Lexical
|
|
12
|
+
|
|
13
|
+
export const INSERT_MATH_COMMAND = createCommand("INSERT_MATH_COMMAND")
|
|
14
|
+
|
|
15
|
+
// Square root of x, drawn to match lexxy's 18x18 filled toolbar icons
|
|
16
|
+
const MATH_ICON = `
|
|
17
|
+
<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
18
|
+
<path d="M17 3.5V5.3H10.1L6.7 15.5H5.2L3.3 10.9H1.5V9.1H4.5L5.9 12.5L8.8 3.5Z"/>
|
|
19
|
+
<path d="M10.4 7.9L14.6 13.1M14.6 7.9L10.4 13.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
|
20
|
+
</svg>
|
|
21
|
+
`
|
|
22
|
+
|
|
23
|
+
export class MathjaxExtension extends Extension {
|
|
24
|
+
#dialog = null
|
|
25
|
+
#editor = null
|
|
26
|
+
|
|
27
|
+
get enabled() {
|
|
28
|
+
return this.editorElement.supportsRichText
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get allowedElements() {
|
|
32
|
+
return [
|
|
33
|
+
{ tag: "span", attributes: [ "data-latex" ] },
|
|
34
|
+
{ tag: "div", attributes: [ "data-latex" ] }
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get lexicalExtension() {
|
|
39
|
+
const extension = this
|
|
40
|
+
|
|
41
|
+
return this.defineExtension({
|
|
42
|
+
name: "lexxy/mathjax",
|
|
43
|
+
nodes: [ MathNode ],
|
|
44
|
+
register(editor) {
|
|
45
|
+
extension.#editor = editor
|
|
46
|
+
|
|
47
|
+
const unregisterInsert = editor.registerCommand(INSERT_MATH_COMMAND, () => {
|
|
48
|
+
extension.#promptNewEquation()
|
|
49
|
+
return true
|
|
50
|
+
}, COMMAND_PRIORITY_NORMAL)
|
|
51
|
+
|
|
52
|
+
// String command so toolbar buttons can use data-command="insertMath"
|
|
53
|
+
const unregisterToolbarInsert = editor.registerCommand("insertMath", () => {
|
|
54
|
+
extension.#promptNewEquation()
|
|
55
|
+
return true
|
|
56
|
+
}, COMMAND_PRIORITY_NORMAL)
|
|
57
|
+
|
|
58
|
+
const unregisterClicks = editor.registerRootListener((rootElement, previousRootElement) => {
|
|
59
|
+
previousRootElement?.removeEventListener("click", extension.#handleEquationClicked)
|
|
60
|
+
rootElement?.addEventListener("click", extension.#handleEquationClicked)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
unregisterInsert()
|
|
65
|
+
unregisterToolbarInsert()
|
|
66
|
+
unregisterClicks()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
initializeToolbar(toolbar) {
|
|
73
|
+
const button = document.createElement("button")
|
|
74
|
+
button.type = "button"
|
|
75
|
+
button.name = "math"
|
|
76
|
+
button.className = "lexxy-editor__toolbar-button"
|
|
77
|
+
button.title = "Insert equation"
|
|
78
|
+
button.setAttribute("data-command", "insertMath")
|
|
79
|
+
button.setAttribute("data-hotkey", "cmd+shift+e ctrl+shift+e")
|
|
80
|
+
button.innerHTML = MATH_ICON
|
|
81
|
+
|
|
82
|
+
toolbar.appendChild(button)
|
|
83
|
+
toolbar.requestOverflowRefresh?.()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
dispose() {
|
|
87
|
+
this.#dialog?.remove()
|
|
88
|
+
this.#dialog = null
|
|
89
|
+
this.#editor = null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async #promptNewEquation() {
|
|
93
|
+
const result = await this.#openDialog()
|
|
94
|
+
if (!result) return
|
|
95
|
+
|
|
96
|
+
this.#editor.update(() => {
|
|
97
|
+
$insertNodes([ $createMathNode(result.latex, result.display) ])
|
|
98
|
+
})
|
|
99
|
+
this.#editor.focus()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#handleEquationClicked = async (event) => {
|
|
103
|
+
const element = event.target.closest(".lexxy-math")
|
|
104
|
+
if (!element) return
|
|
105
|
+
|
|
106
|
+
let nodeKey = null
|
|
107
|
+
this.#editor.read(() => {
|
|
108
|
+
const node = $getNearestNodeFromDOMNode(element)
|
|
109
|
+
if ($isMathNode(node)) nodeKey = node.getKey()
|
|
110
|
+
})
|
|
111
|
+
if (nodeKey === null) return
|
|
112
|
+
|
|
113
|
+
event.preventDefault()
|
|
114
|
+
|
|
115
|
+
let currentValues = null
|
|
116
|
+
this.#editor.read(() => {
|
|
117
|
+
const node = $getNodeByKey(nodeKey)
|
|
118
|
+
if ($isMathNode(node)) {
|
|
119
|
+
currentValues = { latex: node.getLatex(), display: node.isDisplay() }
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
if (!currentValues) return
|
|
123
|
+
|
|
124
|
+
const result = await this.#openDialog(currentValues)
|
|
125
|
+
if (!result) return
|
|
126
|
+
|
|
127
|
+
this.#editor.update(() => {
|
|
128
|
+
const node = $getNodeByKey(nodeKey)
|
|
129
|
+
if (!$isMathNode(node)) return
|
|
130
|
+
|
|
131
|
+
if (node.isDisplay() === result.display) {
|
|
132
|
+
node.setLatex(result.latex)
|
|
133
|
+
} else {
|
|
134
|
+
// Inline vs block affects where the node may live in the tree, so
|
|
135
|
+
// swap in a fresh node instead of mutating in place.
|
|
136
|
+
node.replace($createMathNode(result.latex, result.display))
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
this.#editor.focus()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#openDialog(values = {}) {
|
|
143
|
+
return this.#findOrCreateDialog().open(values)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#findOrCreateDialog() {
|
|
147
|
+
if (!this.#dialog || !this.#dialog.isConnected) {
|
|
148
|
+
defineMathEditorDialogElement()
|
|
149
|
+
this.#dialog = document.createElement("lexxy-math-editor")
|
|
150
|
+
document.body.appendChild(this.#dialog)
|
|
151
|
+
}
|
|
152
|
+
return this.#dialog
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const MATHJAX_WAIT_ATTEMPTS = 40
|
|
2
|
+
const MATHJAX_WAIT_INTERVAL = 250
|
|
3
|
+
|
|
4
|
+
let warnedAboutMissingMathJax = false
|
|
5
|
+
let renderQueue = Promise.resolve()
|
|
6
|
+
|
|
7
|
+
// Resolves with window.MathJax once its startup has finished, or null if
|
|
8
|
+
// MathJax never shows up (the host page is responsible for loading it).
|
|
9
|
+
export async function findMathJax() {
|
|
10
|
+
for (let attempt = 0; attempt < MATHJAX_WAIT_ATTEMPTS; attempt++) {
|
|
11
|
+
if (window.MathJax?.startup?.promise) {
|
|
12
|
+
await window.MathJax.startup.promise
|
|
13
|
+
return window.MathJax
|
|
14
|
+
}
|
|
15
|
+
await sleep(MATHJAX_WAIT_INTERVAL)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!warnedAboutMissingMathJax) {
|
|
19
|
+
warnedAboutMissingMathJax = true
|
|
20
|
+
console.warn("@fnix/lexxy-mathjax: window.MathJax not found. Equations will show raw LaTeX. " +
|
|
21
|
+
"Load MathJax v3 (tex-chtml) on pages that use the editor.")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Typesets LaTeX into the given element, replacing its content. Falls back to
|
|
28
|
+
// showing the raw LaTeX when MathJax is unavailable or the LaTeX is invalid.
|
|
29
|
+
// Calls are serialized because MathJax's conversion pipeline is not reentrant.
|
|
30
|
+
export function typesetInto(element, latex, { display = false } = {}) {
|
|
31
|
+
element.textContent = latex
|
|
32
|
+
|
|
33
|
+
renderQueue = renderQueue.then(async () => {
|
|
34
|
+
const mathjax = await findMathJax()
|
|
35
|
+
if (!mathjax) return
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const rendered = await mathjax.tex2chtmlPromise(latex, {
|
|
39
|
+
display,
|
|
40
|
+
em: 16,
|
|
41
|
+
ex: 8,
|
|
42
|
+
containerWidth: element.clientWidth || 780
|
|
43
|
+
})
|
|
44
|
+
element.replaceChildren(rendered)
|
|
45
|
+
element.classList.remove("lexxy-math--error")
|
|
46
|
+
|
|
47
|
+
// Refresh MathJax's global stylesheet so glyphs used by this equation render.
|
|
48
|
+
mathjax.startup.document.clear()
|
|
49
|
+
mathjax.startup.document.updateDocument()
|
|
50
|
+
} catch (error) {
|
|
51
|
+
element.textContent = latex
|
|
52
|
+
element.classList.add("lexxy-math--error")
|
|
53
|
+
element.title = `${error}`
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
return renderQueue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sleep(milliseconds) {
|
|
61
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
|
62
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Lexical } from "@37signals/lexxy"
|
|
2
|
+
import { typesetInto } from "../helpers/mathjax_helper.js"
|
|
3
|
+
|
|
4
|
+
const { DecoratorNode } = Lexical
|
|
5
|
+
|
|
6
|
+
export class MathNode extends DecoratorNode {
|
|
7
|
+
static getType() {
|
|
8
|
+
return "lexxy-math"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
static clone(node) {
|
|
12
|
+
return new MathNode(node.__latex, node.__display, node.__key)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static importJSON(serializedNode) {
|
|
16
|
+
return $createMathNode(serializedNode.latex, serializedNode.display)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static importDOM() {
|
|
20
|
+
return {
|
|
21
|
+
span: (element) => $mathConversionFor(element, false),
|
|
22
|
+
div: (element) => $mathConversionFor(element, true)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
constructor(latex = "", display = false, key) {
|
|
27
|
+
super(key)
|
|
28
|
+
this.__latex = latex
|
|
29
|
+
this.__display = display
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getLatex() {
|
|
33
|
+
return this.__latex
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setLatex(latex) {
|
|
37
|
+
this.getWritable().__latex = latex
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
isDisplay() {
|
|
41
|
+
return this.__display
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setDisplay(display) {
|
|
45
|
+
this.getWritable().__display = display
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
isInline() {
|
|
49
|
+
return !this.__display
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
isKeyboardSelectable() {
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getTextContent() {
|
|
57
|
+
return this.__latex
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
createDOM() {
|
|
61
|
+
const element = document.createElement(this.__display ? "div" : "span")
|
|
62
|
+
element.className = this.__display ? "lexxy-math lexxy-math--display" : "lexxy-math"
|
|
63
|
+
element.setAttribute("role", "math")
|
|
64
|
+
element.setAttribute("aria-label", `Equation: ${this.__latex}`)
|
|
65
|
+
element.setAttribute("data-latex", this.__latex)
|
|
66
|
+
|
|
67
|
+
typesetInto(element, this.__latex, { display: this.__display })
|
|
68
|
+
|
|
69
|
+
return element
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
updateDOM() {
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
exportDOM() {
|
|
77
|
+
const element = document.createElement(this.__display ? "div" : "span")
|
|
78
|
+
element.className = "lexxy-math"
|
|
79
|
+
element.setAttribute("data-latex", this.__latex)
|
|
80
|
+
element.textContent = this.__latex
|
|
81
|
+
return { element }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
exportJSON() {
|
|
85
|
+
return {
|
|
86
|
+
type: "lexxy-math",
|
|
87
|
+
version: 1,
|
|
88
|
+
latex: this.__latex,
|
|
89
|
+
display: this.__display
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
decorate() {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function $createMathNode(latex = "", display = false) {
|
|
99
|
+
return new MathNode(latex, display)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function $isMathNode(node) {
|
|
103
|
+
return node instanceof MathNode
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function $mathConversionFor(element, display) {
|
|
107
|
+
if (!element.hasAttribute("data-latex")) return null
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
conversion: (domNode) => ({
|
|
111
|
+
node: $createMathNode(domNode.getAttribute("data-latex"), display)
|
|
112
|
+
}),
|
|
113
|
+
priority: 2
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/* Equations inside the editor and in rendered content */
|
|
2
|
+
|
|
3
|
+
.lexxy-math {
|
|
4
|
+
display: inline-block;
|
|
5
|
+
padding: 0 0.1em;
|
|
6
|
+
border-radius: 0.2em;
|
|
7
|
+
cursor: pointer;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.lexxy-math--display {
|
|
11
|
+
display: block;
|
|
12
|
+
text-align: center;
|
|
13
|
+
margin: 0.5em 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
lexxy-editor .lexxy-math:hover {
|
|
17
|
+
background-color: color-mix(in srgb, currentColor 8%, transparent);
|
|
18
|
+
outline: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
lexxy-editor .lexxy-math.selected,
|
|
22
|
+
lexxy-editor [data-lexical-decorator="true"]:focus .lexxy-math {
|
|
23
|
+
outline: 2px solid var(--lexxy-focus-color, #2563eb);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.lexxy-math--error {
|
|
27
|
+
color: #b91c1c;
|
|
28
|
+
font-family: monospace;
|
|
29
|
+
outline: 1px dashed #b91c1c;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Equation editor dialog */
|
|
33
|
+
|
|
34
|
+
.lexxy-math-editor::backdrop {
|
|
35
|
+
background: rgba(0, 0, 0, 0.35);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.lexxy-math-editor {
|
|
39
|
+
min-width: min(28rem, 90vw);
|
|
40
|
+
border: 1px solid #d4d4d8;
|
|
41
|
+
border-radius: 0.5rem;
|
|
42
|
+
padding: 1rem;
|
|
43
|
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.lexxy-math-editor form {
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
gap: 0.75rem;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.lexxy-math-editor__label {
|
|
53
|
+
display: flex;
|
|
54
|
+
flex-direction: column;
|
|
55
|
+
gap: 0.25rem;
|
|
56
|
+
font-size: 0.85rem;
|
|
57
|
+
color: #52525b;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.lexxy-math-editor textarea {
|
|
61
|
+
font-family: ui-monospace, monospace;
|
|
62
|
+
font-size: 0.95rem;
|
|
63
|
+
padding: 0.5rem;
|
|
64
|
+
border: 1px solid #d4d4d8;
|
|
65
|
+
border-radius: 0.375rem;
|
|
66
|
+
resize: vertical;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.lexxy-math-editor__preview {
|
|
70
|
+
min-height: 3rem;
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
justify-content: center;
|
|
74
|
+
padding: 0.5rem;
|
|
75
|
+
border: 1px dashed #d4d4d8;
|
|
76
|
+
border-radius: 0.375rem;
|
|
77
|
+
overflow-x: auto;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.lexxy-math-editor__footer {
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
justify-content: space-between;
|
|
84
|
+
gap: 1rem;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.lexxy-math-editor__display-toggle {
|
|
88
|
+
display: flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
gap: 0.35rem;
|
|
91
|
+
font-size: 0.85rem;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.lexxy-math-editor__actions {
|
|
95
|
+
display: flex;
|
|
96
|
+
gap: 0.5rem;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.lexxy-math-editor__actions button {
|
|
100
|
+
padding: 0.35rem 0.9rem;
|
|
101
|
+
border-radius: 0.375rem;
|
|
102
|
+
border: 1px solid #d4d4d8;
|
|
103
|
+
background: white;
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.lexxy-math-editor__actions button[data-behavior="confirm"] {
|
|
108
|
+
background: var(--lexxy-focus-color, #2563eb);
|
|
109
|
+
border-color: var(--lexxy-focus-color, #2563eb);
|
|
110
|
+
color: white;
|
|
111
|
+
}
|