Skip to content

Add native KaTeX-based math rendering#772

Open
CharlieLeee wants to merge 5 commits intobasecamp:mainfrom
CharlieLeee:dev/math-support
Open

Add native KaTeX-based math rendering#772
CharlieLeee wants to merge 5 commits intobasecamp:mainfrom
CharlieLeee:dev/math-support

Conversation

@CharlieLeee
Copy link

@CharlieLeee CharlieLeee commented Mar 2, 2026

Summary

  • Add inline ($...$ auto-detection) and block ($$ + Enter) math support powered by KaTeX
  • InlineMathNode and BlockMathNode as Lexical DecoratorNodes
  • <lexxy-math-editor> custom element with live preview for editing
  • renderContentMath() helper for display-page rendering (same pattern as highlightCode())
  • Toolbar button, command dispatcher, DOMPurify and ActionText sanitizer integration
  • 13 new tests covering math rendering and detection regex
  • Togglable via math: true/false in Lexxy config

Test plan

  • Type $E=mc^2$ in editor — auto-converts to rendered inline math
  • Type $$ at start of empty line + Enter — opens block math editor with live preview
  • Click rendered math — opens editor for modification
  • Escape or Cmd+Enter — confirms edit
  • Save post and view — math renders correctly on display page
  • npm test — 21 tests pass
  • npm run build — builds successfully

Copilot AI review requested due to automatic review settings March 2, 2026 19:45
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / BlockMathNode Lexical nodes and a MathExtension for 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.

Comment on lines +214 to +216
function findDOMNodeForKey(rootElement, nodeKey) {
// Lexical decorates DOM elements with data attributes we can search
return rootElement?.querySelector(`[data-lexical-decorator="true"]`) || null
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

already updated, findDOMNodeForKey was removed and now use editor.getElementByKey(nodeKey) in all call sites. Thanks for the pointer!

@@ -0,0 +1,3 @@
import katex from "katex"

Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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
}
}

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1 to +3
import katex from "katex"

export function renderMath(latex, { displayMode = false } = {}) {
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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>`
}

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +114
requestAnimationFrame(() => {
openMathEditor(editor, editorElement, blockMathNode.getKey(), "", true)
})
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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
Copilot AI review requested due to automatic review settings March 3, 2026 23:30
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +40 to +44
// 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")
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

@samuelpecher samuelpecher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +26 to +28
editor.registerNodeTransform(TextNode, (textNode) => {
$detectInlineMath(textNode)
}),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't used it yet, but this seems to be the use case for registerLexicalTextEntity

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like registerLexicalTextEntityrequires Klass<T extends TextNode>, but InlineMathNode extends DecoratorNode, so it probably doesn't apply here

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, it seemed the TextNode => DecoratorNode transform would fit the use case


// Ensure paragraph below
if (!node.getNextSibling()) {
const paragraph = $createParagraphNode()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory ProvisionalParagraphs (#726) handle selection-after-decorator issues.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

@CharlieLeee
Copy link
Author

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 ($config(), CLICK_COMMAND, setLatex, etc.).

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
@adam-h
Copy link

adam-h commented Mar 5, 2026

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants