I’m building a custom Editor.js plugin to render LaTeX formulas using MathJax. My inline tool inserts LaTeX formulas inside paragraph blocks. While editing, the formulas display correctly. However, when I load saved content and render it later, the formulas fail to render with MathJax and appear as raw LaTeX text inside the paragraph block. It seems MathJax does not process formulas properly when embedded inline in paragraph blocks or the markup is not preserved correctly. For block-level formulas, MathJax renders them fine, but I lose the ability to continue writing text on the same line or after the formula — basically, the formula becomes a standalone block, breaking the text flow. So how can I make MathJax render inline LaTeX formulas inside paragraph blocks properly on load? Is there a recommended approach to allow block-level formulas without losing the ability to write continuous text after the formula? your text I tried creating a block-level Editor.js tool that outputs formulas as standalone blocks (e.g., inside a or block-level element). MathJax renders these correctly on load, but I lose the ability to continue typing on the same line or add text after the formula — the formula breaks the text flow. I expect a solution where inline formulas render properly on load, or a block-level approach that allows continuous text entry after the formula block. plugin export default class MathJaxInlineTool { private button: HTMLButtonElement; private api: any; private state: boolean = false; static get isInline() { return true; } static get sanitize() { return { span: { class: 'latex-formula', 'data-latex': true, 'contenteditable': false, }, }; } static get title() { return 'Formula'; } static get icon() { return '<svg style="padding: 0 10px">∑</svg>'; } constructor({ api }: { api: any }) { this.api = api; this.button = document.createElement('button'); this.button.type = 'button'; this.button.innerHTML = '∑'; this.button.classList.add(this.api.styles.inlineToolButton); this.button.addEventListener('click', () => { this.state = !this.state; }); document.addEventListener('keydown', this.handleBackspace.bind(this)); } render() { return this.button; } surround(range: Range) { if (!range || range.collapsed) return; const selectedText = range.toString().trim(); if (!selectedText) return; const selectedNode = range.startContainer.parentElement; if (selectedNode?.classList.contains('latex-formula')) { this.unwrap(selectedNode); return; } const formulaSpan = this.createFormulaElement(selectedText); range.deleteContents(); range.insertNode(formulaSpan); const spacer = document.createTextNode(' '); formulaSpan.parentNode?.insertBefore(spacer, formulaSpan.nextSibling); const newRange = document.createRange(); newRange.setStartAfter(spacer); newRange.collapse(true); const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(newRange); this.checkState(sel!); this.api.inlineToolbar?.open?.(sel?.anchorNode, sel); setTimeout(() => this.updateMathJax([formulaSpan]), 50); } checkState(selection: Selection) { const anchorNode = selection.anchorNode; const element = anchorNode instanceof Element ? anchorNode : anchorNode?.parentElement; const isActive = element?.closest('.latex-formula') !== null; this.button.classList.toggle( this.api.styles.inlineToolButtonActive, isActive ); this.state = isActive; } clear() { // Optional cleanup } private createFormulaElement(content: string): HTMLSpanElement { const span = document.createElement('span'); span.classList.add('latex-formula'); span.dataset['latex'] = content; span.textContent = `\`${content}\``; span.setAttribute('contenteditable', 'false'); span.tabIndex = 0; span.addEventListener('dblclick', (event) => { event.preventDefault(); this.unwrap(span); }); return span; } private unwrap(formulaNode: Element) { let latex = formulaNode.getAttribute('data-latex') || ''; latex = latex.replace(/^`|`$/g, ''); const textNode = document.createTextNode(latex); formulaNode.parentNode?.replaceChild(textNode, formulaNode); const range = document.createRange(); range.selectNodeContents(textNode); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); } private updateMathJax(nodes: Node[]) { if (typeof window.MathJax !== 'undefined') { window.MathJax.typesetPromise(nodes).catch((err: Error) => { console.error('MathJax typeset error:', err); }); } } private handleBackspace(event: KeyboardEvent) { if (event.key !== 'Backspace') return; const sel = window.getSelection(); if (!sel || !sel.isCollapsed) return; const range = sel.getRangeAt(0); const { startContainer, startOffset } = range; if (startContainer.nodeType === Node.TEXT_NODE && startOffset === 0) { const prev = startContainer.previousSibling; if (prev instanceof HTMLElement && prev.classList.contains('latex-formula')) { event.preventDefault(); prev.remove(); const newRange = document.createRange(); newRange.setStart(startContainer, 0); newRange.collapse(true); sel.removeAllRanges(); sel.addRange(newRange); this.checkState(sel); this.api.inlineToolbar?.open?.(sel.anchorNode, sel); } } else if (startContainer.nodeType === Node.ELEMENT_NODE) { const el = startContainer.childNodes[startOffset - 1]; if (el instanceof HTMLElement && el.classList.contains('latex-formula')) { event.preventDefault(); el.remove(); const newRange = document.createRange(); newRange.setStart(startContainer, startOffset - 1); newRange.collapse(true); sel.removeAllRanges(); sel.addRange(newRange); this.checkState(sel); this.api.inlineToolbar?.open?.(sel.anchorNode, sel); } } } // This is what Editor.js will use to save the data static prepareData(data: string): string { return data.replace(/^`|`$/g, ''); } } editor component import { AfterViewInit, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild, ViewEncapsulation } from '@angular/core'; import Table from '@editorjs/table'; import EditorJS, {BlockToolConstructable, OutputData} from '@editorjs/editorjs'; import Header from '@editorjs/header'; import Paragraph from '@editorjs/paragraph'; import ImageTool from '@editorjs/image'; import CodeTool from '@editorjs/code'; import List from '@editorjs/list'; import {Subject} from 'rxjs'; import hljs from 'highlight.js'; import {MathJaxService} from '../../services/math-jax.service'; import MathJaxInlineTool from '../mathjax-plugin/mathjax-plugin.component'; @Component({ selector: 'app-editor', templateUrl: './editor.component.html', imports: [], encapsulation: ViewEncapsulation.None, styleUrls: ['./editor.component.scss'] }) export class EditorComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy { @Input() isEditing: boolean = false; @Input() placholder: string = 'Add description...'; @Input() set initialContent(content: string | undefined) { if (content !== this._initialContent) { this._initialContent = content; this.parsedContent = content ? this.parseContent(content) : this.getEmptyData(); this.initializeEditorWithContent(); } } @Input() htmlContent: string = ''; @Input() isReadOnly: boolean = false @Output() opCancelled = new EventEmitter<boolean>(); @ViewChild('editorJs', {static: false}) editorContainer!: ElementRef; private editor: EditorJS | undefined; private destroy$ = new Subject<void>(); private _initialContent: string | undefined; private parsedContent: OutputData | undefined; private originalContentJSON: string = ''; isUpdated: boolean = true; constructor( private mathjaxService: MathJaxService, ) { } ngOnInit(): void { } ngAfterViewInit(): void { if (this.isEditing) { setTimeout(() => { if (this.editorContainer) { requestAnimationFrame(() => { this.initializeEditor(); setTimeout(() => this.focusEditor(), 50) }); } }, 0); } else { this.convertToMathematicalFormat(); } } private focusEditor(): void { if (!this.editor) return; this.editor.isReady.then(() => { const firstBlock = document.querySelector('.ce-block'); if (firstBlock) { (firstBlock as HTMLElement).focus(); } }); } ngOnChanges(changes: SimpleChanges): void { } convertToMathematicalFormat() { setTimeout(() => this.mathjaxService.renderMath(), 0); } get initialContent(): string | undefined { return this._initialContent; } private initializeEditorWithContent(): void { if (this.isEditing && !this.editor) { this.initializeEditor(); } else if (this.editor && this.parsedContent) { this.editor.isReady.then(() => { this.editor?.render(this.parsedContent || this.getEmptyData()).then(() => { this.originalContentJSON = JSON.stringify(this.parsedContent); this.isUpdated = true; }); }); } } private initializeEditor(): void { if (!this.editorContainer) { console.error('Editor container not found.'); return; } this.editor = new EditorJS({ holder: this.editorContainer.nativeElement, readOnly: this.isReadOnly, tools: { table: Table, list: { class: List as unknown as BlockToolConstructable, inlineToolbar: true }, header: { class: Header as unknown as BlockToolConstructable, config: { levels: [1, 2, 3, 4], default: 2 }, inlineToolbar: true }, paragraph: { class: Paragraph as unknown as BlockToolConstructable, inlineToolbar: true }, image: { class: ImageTool, config: { uploader: { uploadByFile: this.getUploadByFileHandler(), uploadByUrl: (url: string) => { return Promise.resolve({ success: 1, file: {url} }); } } }, }, code: { class: CodeTool, config: { placeholder: 'Enter your code here...', preserveBlank: true }, inlineToolbar: true, }, mathjaxInline: { class: MathJaxInlineTool, inlineToolbar: true }, }, data: this.parsedContent || this.getEmptyData(), inlineToolbar: ['bold', 'italic', 'link', 'mathjaxInline'], placeholder: 'Start writing your content here...', onChange: () => { this.applySyntaxHighlighting(); this.trackChanges(); } }); } private trackChanges(): void { this.editor?.save().then(currentData => { const currentJSON = JSON.stringify(currentData); this.isUpdated = currentJSON === this.originalContentJSON; }).catch(error => { console.error('Error tracking changes', error); }); } public emitSave(): Promise<OutputData> | undefined { this.editor?.save().then((res) => { console.log('Saving changes...', JSON.stringify(res)); }) return this.editor?.save(); } private applySyntaxHighlighting(): void { setTimeout(() => { document.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block as HTMLElement); }); // this.mathjaxService.renderMath(); }, 100); } private parseContent(rawContent: string): OutputData { try { const parsed = JSON.parse(rawContent); if (parsed.blocks && parsed.time) { return parsed; } if (Array.isArray(parsed)) { return { time: new Date().getTime(), blocks: parsed }; } return this.getEmptyData(); } catch (e) { console.error('Error parsing content', e); return this.getEmptyData(); } } private getEmptyData(): OutputData { return { time: new Date().getTime(), blocks: [] }; } getUploadByFileHandler() { return (file: File) => ({ then: (resolve: (value: any) => void) => { } }); } isEmptyContent(content: string | undefined): boolean { if (!content) return true; const cleanContent = content.replace(/^"(.*)"$/, '$1'); const emptyPatterns = [ '', '<p><br></p>', '<p></p>', ' ', ' ', ]; const hasImage = /<img\s+[^>]*src=["'][^"']+["'][^>]*>/i.test(cleanContent); return (!hasImage && (emptyPatterns.includes(cleanContent) || cleanContent.trim() === '' || cleanContent.replace(/<[^>]*>/g, '').trim() === '')); } cleanContent(content: string): string { if (content === '') return this.placholder; const tempDiv = document.createElement('div'); tempDiv.innerHTML = content; const codeBlocks = tempDiv.querySelectorAll('.ql-code-block-container'); if (codeBlocks.length > 0 && codeBlocks[0].childNodes.length > 0) { codeBlocks[0].childNodes[0].remove(); } return tempDiv.innerHTML; } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); if (this.editor) { this.editor.destroy(); } } } mathjaxservice // mathjax.service.ts import { Injectable } from '@angular/core'; declare global { interface Window { MathJax: any; } } @Injectable({ providedIn: 'root', }) export class MathJaxService { renderMath() { if (window.MathJax?.typesetPromise) { window.MathJax.typesetPromise().catch((err: any) => console.error('MathJax typeset error:', err) ); } } } Continue reading...