Add native KaTeX-based math rendering#772
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces first-class math support to Lexxy by adding KaTeX-based rendering for both inline ($...$) and block ($$ + Enter / toolbar) formulas, with editor-side nodes and a live-preview math editor element, plus display-page rendering and sanitization updates.
Changes:
- Add
InlineMathNode/BlockMathNodeLexical nodes and aMathExtensionfor detection + insertion commands. - Add
<lexxy-math-editor>element for editing formulas with live preview. - Add
renderContentMath()for rendering saved ActionText content on display pages; update sanitizers and styles for KaTeX.
Reviewed changes
Copilot reviewed 27 out of 28 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Adds KaTeX (and commander dep) lock entries. |
| package.json | Adds katex dependency. |
| src/helpers/math_helper.js | Introduces KaTeX renderMath() helper. |
| src/helpers/math_content_helper.js | Adds DOM helper to render [data-math] elements in saved content. |
| src/nodes/inline_math_node.js | New inline math DecoratorNode with export/import + edit event. |
| src/nodes/block_math_node.js | New block math DecoratorNode with export/import + edit event. |
| src/extensions/math_extension.js | Adds inline detection transform, block trigger on Enter, insert commands, and math editor wiring. |
| src/elements/math_editor.js | Adds custom element UI for editing LaTeX with preview and confirm interactions. |
| src/elements/index.js | Registers <lexxy-math-editor>. |
| src/elements/toolbar_icons.js | Adds math toolbar icon. |
| src/elements/toolbar.js | Adds toolbar button to insert block math. |
| src/editor/command_dispatcher.js | Adds dispatcher methods and command names for math insertion. |
| src/elements/editor.js | Adds MathExtension to base extensions. |
| src/index.js | Imports KaTeX config and exports renderContentMath. |
| src/config/katex.js | Adds KaTeX “config” module (currently just re-export). |
| src/config/lexxy.js | Enables math: true in default preset config. |
| src/config/dom_purify.js | Allows div/span + data-math; strips classes on generic div/span to reduce import interference. |
| lib/lexxy/engine.rb | Extends ActionText allowlists for div/span and data-math. |
| app/assets/stylesheets/lexxy.css | Imports KaTeX CSS. |
| app/assets/stylesheets/lexxy-editor.css | Adds editor-side styling for math nodes and math editor UI. |
| app/assets/stylesheets/lexxy-content.css | Adds display-side styling for exported .math-inline / .math-block. |
| app/assets/stylesheets/katex.min.css | Vendors KaTeX CSS. |
| test/javascript/helpers/math_helper.test.js | Adds unit tests for renderMath(). |
| test/javascript/helpers/math_detection.test.js | Adds unit tests for inline-math detection regex. |
| test/dummy/app/javascript/controllers/math_content_controller.js | Stimulus controller to invoke renderContentMath() on dummy views. |
| test/dummy/app/views/posts/index.html.erb | Applies display-page controllers for code + math rendering. |
| test/dummy/app/views/posts/show.html.erb | Applies display-page controllers for code + math rendering. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/extensions/math_extension.js
Outdated
| function findDOMNodeForKey(rootElement, nodeKey) { | ||
| // Lexical decorates DOM elements with data attributes we can search | ||
| return rootElement?.querySelector(`[data-lexical-decorator="true"]`) || null |
There was a problem hiding this comment.
findDOMNodeForKey(rootElement, nodeKey) ignores nodeKey and just returns the first element matching [data-lexical-decorator="true"]. That will position the math editor relative to an arbitrary decorator node (and may not match the attribute you set on InlineMathNode, which is data-lexxy-decorator). Please make the lookup key-specific (or avoid lookup entirely by passing the clicked element) so the editor positions next to the correct math node.
There was a problem hiding this comment.
There was a problem hiding this comment.
already updated, findDOMNodeForKey was removed and now use editor.getElementByKey(nodeKey) in all call sites. Thanks for the pointer!
src/config/katex.js
Outdated
| @@ -0,0 +1,3 @@ | |||
| import katex from "katex" | |||
|
|
|||
There was a problem hiding this comment.
src/config/katex.js has no side effects beyond importing and re-exporting KaTeX, but src/index.js imports it as a side-effect module. This extra layer is effectively redundant (KaTeX is already imported where needed) and can confuse readers into expecting global configuration. Consider removing this module or moving any real KaTeX setup (if needed) into it.
| // Expose KaTeX on the global object so it is available when this module is | |
| // imported for its side effects (for example from src/index.js). | |
| if (typeof globalThis !== "undefined") { | |
| // Do not overwrite an existing global if one is already defined. | |
| if (globalThis.katex === undefined) { | |
| globalThis.katex = katex | |
| } | |
| } |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 27 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import katex from "katex" | ||
|
|
||
| export function renderMath(latex, { displayMode = false } = {}) { |
There was a problem hiding this comment.
katex is imported here, but the npm Rollup config (rollup.config.npm.mjs) currently has no resolver plugins and does not list katex as an external. That will cause yarn build:npm to fail with an unresolved bare import unless the build config is updated (e.g., mark katex as external or add node-resolve/commonjs).
| import katex from "katex" | |
| export function renderMath(latex, { displayMode = false } = {}) { | |
| const katex = | |
| typeof globalThis !== "undefined" && globalThis.katex | |
| ? globalThis.katex | |
| : null | |
| export function renderMath(latex, { displayMode = false } = {}) { | |
| if (!katex) { | |
| return `<span class="lexxy-math-error">${escapeHtml(latex)}</span>` | |
| } |
| requestAnimationFrame(() => { | ||
| openMathEditor(editor, editorElement, blockMathNode.getKey(), "", true) | ||
| }) |
There was a problem hiding this comment.
When triggered via $$ + Enter, openMathEditor(...) is called without a targetElement, so the <lexxy-math-editor> can’t position itself near the newly inserted node and may appear at an unexpected location. Consider passing the DOM element for blockMathNode (e.g., via editor.getElementByKey(...)) after the node is mounted.
Introduce inline ($...$) and block ($$+Enter) math with auto-detection, live preview during editing, and click-to-edit rendered math in-place. - InlineMathNode and BlockMathNode as Lexical DecoratorNodes - MathExtension with TextNode transform for inline auto-detection - <lexxy-math-editor> custom element with live KaTeX preview - renderContentMath() helper for display-page rendering - Toolbar button and command dispatcher integration - DOMPurify and ActionText sanitizer allowlisting for data-math - 13 new tests (math_helper + math_detection regex)
- Remove unused $isTextNode import - Fix math editor positioning: pass clicked DOM element through event detail instead of broken findDOMNodeForKey lookup - Fix listener leak in math_editor.js: remove existing handler before adding new one in show() - Restore default KaTeX output (htmlAndMathml) for accessibility; .katex-mathml is already hidden via CSS - Replace DOM-dependent escapeHtml with pure string replacement - Import INLINE_MATH_REGEX from source in test instead of hardcoding - Remove redundant src/config/katex.js re-export module
- Fix Lexical error basecamp#19 (selection lost) in $$ + Enter block math trigger by moving selection before replacing the paragraph - Use COMMAND_PRIORITY_HIGH for block math Enter handler to avoid conflict with editor's #handleEnter at NORMAL priority - Remove nested editor.update() anti-pattern in block math trigger - Replace regex lookbehind with compatible alternative - Mark katex as external in npm rollup config - Add disconnectedCallback and guard #buildUI in math editor - Pass targetElement for math editor positioning
0f04d5d to
360b50c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 28 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Narrow renderContentMath selector to .math-inline/.math-block only - Update BlockMathNode.updateDOM() to update DOM in-place when possible - Add @font-face declarations to katex.min.css via jsdelivr CDN (woff2)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 28 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Strip class from div/span unless they are math elements (have data-math attribute). | ||
| // This prevents pasted div/span elements from interfering with Lexical's importDOM. | ||
| if ((data.tagName === "div" || data.tagName === "span") && !node.hasAttribute("data-math")) { | ||
| node.removeAttribute("class") | ||
| } |
There was a problem hiding this comment.
The DOMPurify hook removes the class attribute from every <div>/<span> that doesn’t have data-math. Since sanitize() is used when generating the editor’s HTML value, this will strip required classes from Lexxy-generated markup (e.g. attachment galleries use a <div> with attachment-gallery... classes), breaking styling and potentially import behavior. Consider removing this rule or scoping it so it only affects incoming/pasted HTML, or otherwise whitelisting Lexxy-owned class patterns instead of blanket-stripping.
There was a problem hiding this comment.
Hey @CharlieLeee - this is a cool use of Lexxy Extensions! This is the way we'd hoped the editor could be extended for application-specific needs. I've noted we need a clear hook for allowing additional DOM.
Since it's quite application specific, I don't think it need to be part of core Lexxy, especially with how you can reach deep into the editor to provide new functionalities like this. It would be useful to publish as an extension so others can import into their projects.
I've left my thoughts on a few potential improvements.
src/extensions/math_extension.js
Outdated
| editor.registerNodeTransform(TextNode, (textNode) => { | ||
| $detectInlineMath(textNode) | ||
| }), |
There was a problem hiding this comment.
I haven't used it yet, but this seems to be the use case for registerLexicalTextEntity
There was a problem hiding this comment.
seems like registerLexicalTextEntityrequires Klass<T extends TextNode>, but InlineMathNode extends DecoratorNode, so it probably doesn't apply here
There was a problem hiding this comment.
Ah, it seemed the TextNode => DecoratorNode transform would fit the use case
src/extensions/math_extension.js
Outdated
|
|
||
| // Ensure paragraph below | ||
| if (!node.getNextSibling()) { | ||
| const paragraph = $createParagraphNode() |
There was a problem hiding this comment.
In theory ProvisionalParagraphs (#726) handle selection-after-decorator issues.
There was a problem hiding this comment.
good call, I did look into it.
The manual selection handling in $handleBlockMathTrigger is addressing a slightly different issue though: when the user types $$ and presses Enter, the paragraph holding the cursor gets replaced with a BlockMathNode. Without moving selection first, Lexical throws error #19 (selection references removed nodes) and the editor freezes. Moving selection to the next paragraph before replacing avoids that. I think ProvisionalParagraphs handles decorator navigation rather than this specific case, but happy to revisit if I'm missing something!
|
Thanks for the thoughtful review, @samuelpecher! Really appreciate you taking the time. I understand the reasoning for keeping core Lexxy lean. I'll look into publishing this as a standalone extension and will iterate on the improvements you suggested ( That said, I wanted to share some context: our team uses Basecamp daily for academic/technical collaboration, and the lack of math support in the editor is a real pain point for us. We often need to discuss equations and formulas, and currently have to resort to screenshots or external tools. Native LaTeX support would be a huge quality-of-life improvement for teams like ours. If there's ever an appetite to include math as an opt-in extension in core (similar to how code highlighting is built in), we'd love to see that happen. Either way, happy to keep improving this implementation. |
- Use $config() API replacing static getType/clone/importJSON/importDOM - Add afterCloneFrom to preserve __latex during node cloning - Add setLatex() with getWritable() for proper node mutation - Replace internal lexxy:edit-math events with CLICK_COMMAND handler - Simplify TextNode transform and command handlers - Flatten code with early returns, use topElement.isEmpty() - Use $isBlockMathNode/$isInlineMathNode helpers throughout
|
We are using Lexxy with separate handling for LaTeX (via Mathjax in our case), it would be great to have this built-in (or as an extension) to Lexxy for us too. |
Summary
$...$auto-detection) and block ($$+ Enter) math support powered by KaTeXInlineMathNodeandBlockMathNodeas Lexical DecoratorNodes<lexxy-math-editor>custom element with live preview for editingrenderContentMath()helper for display-page rendering (same pattern ashighlightCode())math: true/falsein Lexxy configTest plan
$E=mc^2$in editor — auto-converts to rendered inline math$$at start of empty line + Enter — opens block math editor with live previewnpm test— 21 tests passnpm run build— builds successfully