diff --git a/.changeset/bright-mathjax-editor.md b/.changeset/bright-mathjax-editor.md new file mode 100644 index 00000000..ed0ba6da --- /dev/null +++ b/.changeset/bright-mathjax-editor.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-editor": minor +--- + +Add MathJax parsing and rendering support to the rich-text editor. diff --git a/plugins/official/index.ts b/plugins/official/index.ts index 9366ee6e..dc489269 100644 --- a/plugins/official/index.ts +++ b/plugins/official/index.ts @@ -3,3 +3,10 @@ export type { StackSnippetOptions, SnippetMetadata, } from "./stack-snippets/src/common"; + +export { mathJaxPlugin } from "./mathjax/src/mathJaxPlugin"; +export type { + MathJaxDelimiter, + MathJaxOptions, + MathJaxRenderRequest, +} from "./mathjax/src/common"; diff --git a/plugins/official/mathjax/src/common.ts b/plugins/official/mathjax/src/common.ts new file mode 100644 index 00000000..0f8791c7 --- /dev/null +++ b/plugins/official/mathjax/src/common.ts @@ -0,0 +1,36 @@ +/** A pair of opening and closing MathJax delimiters. */ +export type MathJaxDelimiter = readonly [open: string, close: string]; + +/** Information passed to the host application when a formula should render. */ +export interface MathJaxRenderRequest { + /** Element owned by the editor that should contain the rendered formula. */ + container: HTMLElement; + /** TeX source without its surrounding delimiters. */ + source: string; + /** Whether the formula uses display rather than inline layout. */ + display: boolean; +} + +/** Options used by the official MathJax editor plugin. */ +export interface MathJaxOptions { + /** Delimiters recognized as inline formulas. */ + inlineDelimiters?: readonly MathJaxDelimiter[]; + /** Delimiters recognized as display formulas. */ + displayDelimiters?: readonly MathJaxDelimiter[]; + /** Host callback that renders a formula into the supplied container. */ + renderer?: (request: MathJaxRenderRequest) => void | Promise; + /** Host callback that clears renderer-owned state from a container. */ + clear?: (container: HTMLElement) => void | Promise; +} + +/** Default inline delimiters supported by Stack Exchange MathJax. */ +export const defaultInlineDelimiters: readonly MathJaxDelimiter[] = [ + ["$", "$"], + ["\\(", "\\)"], +]; + +/** Default display delimiters supported by Stack Exchange MathJax. */ +export const defaultDisplayDelimiters: readonly MathJaxDelimiter[] = [ + ["$$", "$$"], + ["\\[", "\\]"], +]; diff --git a/plugins/official/mathjax/src/input-rules.ts b/plugins/official/mathjax/src/input-rules.ts new file mode 100644 index 00000000..fec2cfa0 --- /dev/null +++ b/plugins/official/mathjax/src/input-rules.ts @@ -0,0 +1,289 @@ +import { InputRule, inputRules } from "prosemirror-inputrules"; +import { + EditorState, + Plugin, + TextSelection, + Transaction, +} from "prosemirror-state"; +import { + defaultDisplayDelimiters, + defaultInlineDelimiters, + MathJaxDelimiter, + MathJaxOptions, +} from "./common"; + +const inlineNodeName = "math_inline"; +const blockNodeName = "math_block"; + +interface MathJaxRule { + inputRule: InputRule; + match: RegExp; + handler: ( + state: EditorState, + match: RegExpMatchArray, + start: number, + end: number + ) => Transaction | null; +} + +/** Escape a string so it can be embedded in a regular expression. */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Determine whether the character at an index is escaped. */ +function isEscaped(source: string, index: number): boolean { + let backslashes = 0; + for (let i = index - 1; i >= 0 && source[i] === "\\"; i--) { + backslashes++; + } + return backslashes % 2 === 1; +} + +/** Determine whether an index is inside an unfinished Markdown code span. */ +function isInsideCodeSpan(source: string, index: number): boolean { + let activeRunLength = 0; + + for (let cursor = 0; cursor < index; cursor++) { + if (source[cursor] !== "`" || isEscaped(source, cursor)) { + continue; + } + + let runEnd = cursor + 1; + while (runEnd < index && source[runEnd] === "`") { + runEnd++; + } + + const runLength = runEnd - cursor; + if (activeRunLength === 0) { + activeRunLength = runLength; + } else if (runLength === activeRunLength) { + activeRunLength = 0; + } + + cursor = runEnd - 1; + } + + return activeRunLength !== 0; +} + +/** Build a non-empty, single-line capture that stops at the closing delimiter. */ +function sourceCapture(close: string): string { + return `((?:(?!${escapeRegExp(close)})[^\\n])+?)`; +} + +/** Convert a completed inline formula into an atomic rich-text node. */ +function inlineMathInputRule( + [open, close]: MathJaxDelimiter, + displayDelimiters: readonly MathJaxDelimiter[] +): MathJaxRule { + const expression = new RegExp( + `(^|.)${escapeRegExp(open)}${sourceCapture(close)}${escapeRegExp(close)}$` + ); + const handler: MathJaxRule["handler"] = (state, match, start, end) => { + const prefix = match[1] ?? ""; + const source = match[2]; + const matchIndex = match.index ?? 0; + const openIndex = matchIndex + prefix.length; + const closeIndex = matchIndex + match[0].length - close.length; + const startsWithDisplayDelimiter = displayDelimiters.some( + ([displayOpen]) => + displayOpen.length > open.length && + displayOpen.endsWith(open) && + match.input.slice( + openIndex - (displayOpen.length - open.length), + openIndex + open.length + ) === displayOpen + ); + + if ( + !source.trim() || + isEscaped(match.input, openIndex) || + isEscaped(match.input, closeIndex) || + isInsideCodeSpan(match.input, openIndex) || + startsWithDisplayDelimiter + ) { + return null; + } + + const nodeType = state.schema.nodes[inlineNodeName]; + if (!nodeType) { + return null; + } + + return state.tr.replaceWith( + start + prefix.length, + end, + nodeType.create({ + source, + openDelimiter: open, + closeDelimiter: close, + }) + ); + }; + + return { + inputRule: new InputRule(expression, handler, { inCodeMark: false }), + match: expression, + handler, + }; +} + +/** Convert a completed, paragraph-sized display formula into a block node. */ +function blockMathInputRule([open, close]: MathJaxDelimiter): MathJaxRule { + const expression = new RegExp( + `^${escapeRegExp(open)}${sourceCapture(close)}${escapeRegExp(close)}$` + ); + + const handler: MathJaxRule["handler"] = (state, match, start) => { + const source = match[1]; + const closeIndex = match[0].length - close.length; + if (!source.trim() || isEscaped(match.input, closeIndex)) { + return null; + } + + const nodeType = state.schema.nodes[blockNodeName]; + const paragraph = state.doc.resolve(start); + if (!nodeType || !paragraph.parent.isTextblock) { + return null; + } + + const container = paragraph.node(paragraph.depth - 1); + const paragraphIndex = paragraph.index(paragraph.depth - 1); + if ( + !container.canReplaceWith( + paragraphIndex, + paragraphIndex + 1, + nodeType + ) + ) { + return null; + } + + const blockStart = paragraph.before(); + const blockEnd = paragraph.after(); + const mathNode = nodeType.create({ + source, + openDelimiter: open, + closeDelimiter: close, + }); + const tr = state.tr.replaceWith(blockStart, blockEnd, mathNode); + const afterMath = blockStart + mathNode.nodeSize; + + if (afterMath === tr.doc.content.size) { + const trailingParagraph = state.schema.nodes.paragraph.create(); + tr.insert(afterMath, trailingParagraph); + tr.setSelection(TextSelection.create(tr.doc, afterMath + 1)); + } + + return tr; + }; + + return { + inputRule: new InputRule(expression, handler, { inCodeMark: false }), + match: expression, + handler, + }; +} + +/** Build input rules that render completed formulas while the user types. */ +export function mathJaxInputRules(options?: MathJaxOptions) { + return inputRules({ + rules: buildMathJaxRules(options).map((rule) => rule.inputRule), + }); +} + +function buildMathJaxRules(options?: MathJaxOptions): MathJaxRule[] { + const inlineDelimiters = + options?.inlineDelimiters ?? defaultInlineDelimiters; + const displayDelimiters = + options?.displayDelimiters ?? defaultDisplayDelimiters; + + return [ + ...displayDelimiters.map(blockMathInputRule), + ...inlineDelimiters.map((delimiter) => + inlineMathInputRule(delimiter, displayDelimiters) + ), + ]; +} + +/** + * Apply the same rules after a browser DOM transaction. Some keyboard layouts + * insert characters such as `$` through DOM reconciliation instead of + * `handleTextInput`, which bypasses ProseMirror's standard input-rules hook. + */ +export function mathJaxTransactionRules(options?: MathJaxOptions): Plugin { + const rules = buildMathJaxRules(options); + + return new Plugin({ + appendTransaction: (transactions, _, state) => { + if ( + !transactions.some((transaction) => transaction.docChanged) || + transactions.some((transaction) => + state.plugins.some( + (plugin) => + ( + plugin.spec as { + isInputRules?: boolean; + } + ).isInputRules && transaction.getMeta(plugin) + ) + ) + ) { + return null; + } + + const { $cursor } = state.selection as TextSelection; + if (!$cursor) { + return null; + } + + const textBefore = $cursor.parent.textBetween( + Math.max(0, $cursor.parentOffset - 500), + $cursor.parentOffset, + null, + "\ufffc" + ); + + for (const rule of rules) { + if ( + $cursor.parent.type.spec.code || + $cursor.marks().some((mark) => mark.type.spec.code) + ) { + continue; + } + + const match = rule.match.exec(textBefore); + if (!match) { + continue; + } + + const start = $cursor.pos - match[0].length; + let hasCodeMark = false; + state.doc.nodesBetween(start, $cursor.pos, (node) => { + if ( + node.isInline && + node.marks.some((mark) => mark.type.spec.code) + ) { + hasCodeMark = true; + } + }); + if (hasCodeMark) { + continue; + } + + const transaction = rule.handler( + state, + match, + start, + $cursor.pos + ); + if (transaction) { + return transaction; + } + } + + return null; + }, + }); +} diff --git a/plugins/official/mathjax/src/math-view.ts b/plugins/official/mathjax/src/math-view.ts new file mode 100644 index 00000000..d7eb0813 --- /dev/null +++ b/plugins/official/mathjax/src/math-view.ts @@ -0,0 +1,77 @@ +import { Node as ProseMirrorNode } from "prosemirror-model"; +import { NodeView } from "prosemirror-view"; +import { MathJaxOptions } from "./common"; + +/** Rich-text node view that delegates MathJax rendering to the host. */ +export class MathJaxView implements NodeView { + readonly dom: HTMLElement; + private renderVersion = 0; + + constructor( + private node: ProseMirrorNode, + private readonly display: boolean, + private readonly options?: MathJaxOptions + ) { + this.dom = document.createElement(display ? "div" : "span"); + this.dom.classList.add( + "stacks-editor-math", + display ? "stacks-editor-math-display" : "stacks-editor-math-inline" + ); + this.dom.contentEditable = "false"; + this.render(); + } + + /** Update this view when ProseMirror replaces its backing node. */ + update(node: ProseMirrorNode): boolean { + if (node.type !== this.node.type) { + return false; + } + + this.node = node; + this.render(); + return true; + } + + /** Release any state owned by the host renderer. */ + destroy(): void { + this.renderVersion++; + try { + void Promise.resolve(this.options?.clear?.(this.dom)).catch( + (): void => {} + ); + } catch { + // Cleanup is best-effort because the node has already been removed. + } + } + + /** Render the current TeX source, preserving readable fallback content. */ + private render(): void { + const version = ++this.renderVersion; + const source = this.node.attrs.source as string; + const openDelimiter = this.node.attrs.openDelimiter as string; + const closeDelimiter = this.node.attrs.closeDelimiter as string; + const fallback = `${openDelimiter}${source}${closeDelimiter}`; + + this.dom.textContent = fallback; + + if (!this.options?.renderer) { + return; + } + + try { + const result = this.options.renderer({ + container: this.dom, + source, + display: this.display, + }); + + void Promise.resolve(result).catch(() => { + if (version === this.renderVersion) { + this.dom.textContent = fallback; + } + }); + } catch { + this.dom.textContent = fallback; + } + } +} diff --git a/plugins/official/mathjax/src/mathJaxPlugin.ts b/plugins/official/mathjax/src/mathJaxPlugin.ts new file mode 100644 index 00000000..b61bd3ec --- /dev/null +++ b/plugins/official/mathjax/src/mathJaxPlugin.ts @@ -0,0 +1,41 @@ +import { EditorPlugin } from "../../../../src"; +import { MathJaxOptions } from "./common"; +import { mathJaxInputRules, mathJaxTransactionRules } from "./input-rules"; +import { mathJaxPasteHandler } from "./paste-handler"; +import { MathJaxView } from "./math-view"; +import { + mathJaxMarkdownParser, + mathJaxMarkdownPlugin, + mathJaxMarkdownSerializer, + mathJaxRichTextNodeSpec, +} from "./schema"; + +/** Build the official MathJax plugin using host-provided rendering callbacks. */ +export const mathJaxPlugin: (options?: MathJaxOptions) => EditorPlugin = + (options) => () => ({ + richText: { + plugins: [ + mathJaxPasteHandler(options), + mathJaxInputRules(options), + mathJaxTransactionRules(options), + ], + nodeViews: { + math_inline: (node) => new MathJaxView(node, false, options), + math_block: (node) => new MathJaxView(node, true, options), + }, + }, + extendSchema: (schema) => { + schema.nodes = schema.nodes.append(mathJaxRichTextNodeSpec); + return schema; + }, + markdown: { + parser: mathJaxMarkdownParser, + serializers: { + nodes: mathJaxMarkdownSerializer, + marks: {}, + }, + alterMarkdownIt: (markdownIt) => { + markdownIt.use(mathJaxMarkdownPlugin, options); + }, + }, + }); diff --git a/plugins/official/mathjax/src/paste-handler.ts b/plugins/official/mathjax/src/paste-handler.ts new file mode 100644 index 00000000..d8b3d020 --- /dev/null +++ b/plugins/official/mathjax/src/paste-handler.ts @@ -0,0 +1,55 @@ +import MarkdownIt from "markdown-it"; +import { Slice } from "prosemirror-model"; +import { defaultMarkdownParser, MarkdownParser } from "prosemirror-markdown"; +import { Plugin } from "prosemirror-state"; +import { MathJaxOptions } from "./common"; +import { mathJaxMarkdownParser, mathJaxMarkdownPlugin } from "./schema"; + +/** Parse pasted Markdown as a complete document when it contains MathJax. */ +export function mathJaxPasteHandler(options?: MathJaxOptions): Plugin { + const markdownIt = new MarkdownIt("default", {}).use( + mathJaxMarkdownPlugin, + options + ); + + return new Plugin({ + props: { + handlePaste(view, event) { + const text = event.clipboardData?.getData("text/plain"); + if (!text || view.state.selection.$from.parent.type.spec.code) { + return false; + } + + const parser = new MarkdownParser( + view.state.schema, + markdownIt, + { + ...defaultMarkdownParser.tokens, + ...mathJaxMarkdownParser, + } + ); + const parsed = parser.parse(text); + let containsMath = false; + parsed.descendants((node) => { + if ( + node.type.name === "math_inline" || + node.type.name === "math_block" + ) { + containsMath = true; + } + }); + + if (!containsMath) { + return false; + } + + view.dispatch( + view.state.tr.replaceSelection( + Slice.maxOpen(parsed.content) + ) + ); + return true; + }, + }, + }); +} diff --git a/plugins/official/mathjax/src/schema.ts b/plugins/official/mathjax/src/schema.ts new file mode 100644 index 00000000..a6e95177 --- /dev/null +++ b/plugins/official/mathjax/src/schema.ts @@ -0,0 +1,313 @@ +import MarkdownIt, { Token } from "markdown-it"; +import { MarkdownParser } from "prosemirror-markdown"; +import { NodeSpec } from "prosemirror-model"; +import { MarkdownSerializerNodes } from "../../../../src"; +import { + defaultDisplayDelimiters, + defaultInlineDelimiters, + MathJaxDelimiter, + MathJaxOptions, +} from "./common"; + +const mathInlineToken = "math_inline"; +const mathBlockToken = "math_block"; +const escapedDollarToken = "math_escaped_dollar"; + +/** Determine whether the character at an index is escaped. */ +function isEscaped(source: string, index: number): boolean { + let backslashes = 0; + for (let i = index - 1; i >= 0 && source[i] === "\\"; i--) { + backslashes++; + } + return backslashes % 2 === 1; +} + +/** Find an unescaped closing delimiter in Markdown source. */ +function findClosingDelimiter( + source: string, + close: string, + from: number +): number { + let index = source.indexOf(close, from); + while (index !== -1 && isEscaped(source, index)) { + index = source.indexOf(close, index + close.length); + } + return index; +} + +/** Find an unescaped Markdown code-span boundary. */ +function findCodeBoundary(source: string, from: number): number { + let index = source.indexOf("`", from); + while (index !== -1 && isEscaped(source, index)) { + index = source.indexOf("`", index + 1); + } + return index; +} + +/** Attach preserved delimiter metadata to a Markdown token. */ +function setMathTokenAttributes( + token: Token, + source: string, + openDelimiter: string, + closeDelimiter: string +): void { + token.content = source; + token.attrSet("openDelimiter", openDelimiter); + token.attrSet("closeDelimiter", closeDelimiter); +} + +/** Build the Markdown-it inline rule for MathJax formulas. */ +function inlineMathRule( + delimiters: readonly MathJaxDelimiter[] +): MarkdownIt.ParserInline.RuleInline { + const orderedDelimiters = [...delimiters].sort( + ([left], [right]) => right.length - left.length + ); + + const ignoredClosers = new WeakMap>(); + + return (state, silent) => { + const ignoredForState = ignoredClosers.get(state); + if (ignoredForState?.has(state.pos)) { + if (!silent) { + ignoredForState.delete(state.pos); + } + return false; + } + + const delimiter = orderedDelimiters.find(([open]) => + state.src.startsWith(open, state.pos) + ); + if (!delimiter) { + return false; + } + + const [open, close] = delimiter; + const contentStart = state.pos + open.length; + const closeIndex = findClosingDelimiter(state.src, close, contentStart); + if (closeIndex === -1 || closeIndex === contentStart) { + return false; + } + + const codeBoundary = findCodeBoundary(state.src, contentStart); + if (codeBoundary !== -1 && codeBoundary < closeIndex) { + if (!silent) { + const ignored = ignoredForState ?? new Set(); + ignored.add(closeIndex); + ignoredClosers.set(state, ignored); + } + return false; + } + + if (!silent) { + const token = state.push(mathInlineToken, "math", 0); + setMathTokenAttributes( + token, + state.src.slice(contentStart, closeIndex), + open, + close + ); + } + + state.pos = closeIndex + close.length; + return true; + }; +} + +/** Build the Markdown-it block rule for MathJax display formulas. */ +function blockMathRule( + delimiters: readonly MathJaxDelimiter[] +): MarkdownIt.ParserBlock.RuleBlock { + const orderedDelimiters = [...delimiters].sort( + ([left], [right]) => right.length - left.length + ); + + return (state, startLine, endLine, silent) => { + const start = state.bMarks[startLine] + state.tShift[startLine]; + const delimiter = orderedDelimiters.find(([open]) => + state.src.startsWith(open, start) + ); + if (!delimiter) { + return false; + } + + const [open, close] = delimiter; + const sourceLines: string[] = []; + let nextLine = startLine; + let foundClose = false; + + for (let line = startLine; line < endLine; line++) { + const lineStart = state.bMarks[line] + state.tShift[line]; + const lineContent = state.src.slice(lineStart, state.eMarks[line]); + const content = + line === startLine + ? lineContent.slice(open.length) + : lineContent; + + if (line !== startLine && /^(?:`{3,}|~{3,})/.test(content)) { + return false; + } + + const closeIndex = findClosingDelimiter(content, close, 0); + if (closeIndex === -1) { + sourceLines.push(content); + continue; + } + + if (content.slice(closeIndex + close.length).trim()) { + return false; + } + + sourceLines.push(content.slice(0, closeIndex)); + nextLine = line + 1; + foundClose = true; + break; + } + + const source = sourceLines.join("\n"); + if (!foundClose || !source) { + return false; + } + + if (!silent) { + const token = state.push(mathBlockToken, "math", 0); + setMathTokenAttributes(token, source, open, close); + token.map = [startLine, nextLine]; + } + + state.line = nextLine; + return true; + }; +} + +/** Configure Markdown-it to tokenize MathJax outside Markdown code. */ +export function mathJaxMarkdownPlugin( + markdownIt: MarkdownIt, + options?: MathJaxOptions +): void { + const inlineDelimiters = + options?.inlineDelimiters ?? defaultInlineDelimiters; + const displayDelimiters = + options?.displayDelimiters ?? defaultDisplayDelimiters; + + markdownIt.inline.ruler.before( + "escape", + mathInlineToken, + inlineMathRule(inlineDelimiters) + ); + markdownIt.inline.ruler.before( + mathInlineToken, + escapedDollarToken, + (state, silent) => { + if (!state.src.startsWith("\\$", state.pos)) { + return false; + } + + if (!silent) { + const token = state.push(escapedDollarToken, "span", 0); + token.content = "$"; + } + state.pos += 2; + return true; + } + ); + markdownIt.block.ruler.before( + "fence", + mathBlockToken, + blockMathRule(displayDelimiters) + ); +} + +export const mathJaxMarkdownParser: MarkdownParser["tokens"] = { + [escapedDollarToken]: { + node: escapedDollarToken, + }, + [mathInlineToken]: { + node: mathInlineToken, + getAttrs: (token: Token) => ({ + source: token.content, + openDelimiter: token.attrGet("openDelimiter"), + closeDelimiter: token.attrGet("closeDelimiter"), + }), + }, + [mathBlockToken]: { + node: mathBlockToken, + getAttrs: (token: Token) => ({ + source: token.content, + openDelimiter: token.attrGet("openDelimiter"), + closeDelimiter: token.attrGet("closeDelimiter"), + }), + }, +}; + +export const mathJaxMarkdownSerializer: MarkdownSerializerNodes = { + [escapedDollarToken](state) { + state.write("\\$"); + }, + [mathInlineToken](state, node) { + state.write( + `${node.attrs.openDelimiter as string}${ + node.attrs.source as string + }${node.attrs.closeDelimiter as string}` + ); + }, + [mathBlockToken](state, node) { + const markdown = `${node.attrs.openDelimiter as string}${ + node.attrs.source as string + }${node.attrs.closeDelimiter as string}`; + markdown.split("\n").forEach((line, index) => { + if (index > 0) { + state.ensureNewLine(); + } + state.write(line); + }); + state.closeBlock(node); + }, +}; + +const mathAttributes: NodeSpec["attrs"] = { + source: { default: "" }, + openDelimiter: { default: "" }, + closeDelimiter: { default: "" }, +}; + +export const mathJaxRichTextNodeSpec: { [name: string]: NodeSpec } = { + [escapedDollarToken]: { + inline: true, + group: "inline", + atom: true, + selectable: true, + toDOM: () => [ + "span", + { class: "stacks-editor-math-escaped-dollar" }, + "$", + ], + }, + [mathInlineToken]: { + inline: true, + group: "inline", + atom: true, + selectable: true, + attrs: mathAttributes, + toDOM: (node) => [ + "span", + { class: "stacks-editor-math stacks-editor-math-inline" }, + `${node.attrs.openDelimiter as string}${node.attrs.source as string}${ + node.attrs.closeDelimiter as string + }`, + ], + }, + [mathBlockToken]: { + group: "block", + atom: true, + selectable: true, + attrs: mathAttributes, + toDOM: (node) => [ + "div", + { class: "stacks-editor-math stacks-editor-math-display" }, + `${node.attrs.openDelimiter as string}${node.attrs.source as string}${ + node.attrs.closeDelimiter as string + }`, + ], + }, +}; diff --git a/plugins/official/mathjax/test/input-rules.test.ts b/plugins/official/mathjax/test/input-rules.test.ts new file mode 100644 index 00000000..440ea383 --- /dev/null +++ b/plugins/official/mathjax/test/input-rules.test.ts @@ -0,0 +1,271 @@ +import { EditorState } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { Slice } from "prosemirror-model"; +import { richTextInputRules } from "../../../../src/rich-text/inputrules"; +import { stackOverflowValidateLink } from "../../../../src/shared/utils"; +import { MathJaxOptions, MathJaxRenderRequest } from "../src/common"; +import { buildMathJaxSchema, mathJaxExternalProvider } from "./test-helpers"; +import { StacksEditor } from "../../../../src/stacks-editor/editor"; +import { EditorType } from "../../../../src/shared/view"; +import { mathJaxPlugin } from "../src/mathJaxPlugin"; + +/** Create an editor view configured with the core and MathJax input rules. */ +function createView(options?: MathJaxOptions): EditorView { + const provider = mathJaxExternalProvider(options); + const schema = buildMathJaxSchema(options); + return new EditorView(document.createElement("div"), { + state: EditorState.create({ + schema, + plugins: [ + richTextInputRules(schema, { + validateLink: stackOverflowValidateLink, + }), + ...provider.plugins.richText, + ], + }), + nodeViews: provider.nodeViews, + }); +} + +/** Type text through ProseMirror's handleTextInput pipeline. */ +function typeText(view: EditorView, text: string): void { + for (const character of text) { + const { from, to } = view.state.selection; + const handled = view.someProp("handleTextInput", (handler) => + handler(view, from, to, character) + ); + + if (!handled) { + view.dispatch(view.state.tr.insertText(character, from, to)); + } + } +} + +describe("MathJax rich-text input rules", () => { + const originalIntersectionObserver = global.IntersectionObserver; + + beforeAll(() => { + global.IntersectionObserver = jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + takeRecords: jest.fn(), + root: null as Element | Document | null, + rootMargin: "0px", + thresholds: [] as number[], + })) as unknown as typeof IntersectionObserver; + }); + + afterAll(() => { + global.IntersectionObserver = originalIntersectionObserver; + }); + + it("renders a completed formula through a full StacksEditor instance", async () => { + const target = document.createElement("div"); + const editor = new StacksEditor(target, "", { + editorPlugins: [mathJaxPlugin()], + }); + const view = editor.editorView; + + view.dom.firstElementChild?.append(document.createTextNode("$x$")); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(view.state.doc.firstChild.firstChild.type.name).toBe( + "math_inline" + ); + editor.destroy(); + }); + + it.each([ + ["$x^2$", "$", "$", "x^2"], + ["\\(x^2\\)", "\\(", "\\)", "x^2"], + ])( + "renders a completed inline formula while typing: %s", + (text, openDelimiter, closeDelimiter, source) => { + const renderer = jest.fn( + ({ + container, + source: renderedSource, + }: MathJaxRenderRequest) => { + container.textContent = `rendered:${renderedSource}`; + } + ); + const view = createView({ renderer }); + + typeText(view, `Formula: ${text}`); + + const mathNode = view.state.doc.firstChild.lastChild; + expect(mathNode.type.name).toBe("math_inline"); + expect(mathNode.attrs).toMatchObject({ + source, + openDelimiter, + closeDelimiter, + }); + expect(renderer).toHaveBeenCalledTimes(1); + expect( + view.dom.querySelector(".stacks-editor-math-inline") + ?.textContent + ).toBe(`rendered:${source}`); + } + ); + + it("renders a formula inserted through a DOM reconciliation transaction", () => { + const view = createView(); + + view.dispatch(view.state.tr.insertText("Formula: $x^2$")); + + expect(view.state.doc.firstChild.lastChild.type.name).toBe( + "math_inline" + ); + expect(view.state.doc.firstChild.lastChild.attrs).toMatchObject({ + source: "x^2", + openDelimiter: "$", + closeDelimiter: "$", + }); + }); + + it("renders a pasted formula", () => { + const view = createView(); + const event = { + clipboardData: { + getData: () => "Formula: $x^2$", + files: [], + types: ["text/plain"], + }, + } as unknown as ClipboardEvent; + + const handled = view.someProp("handlePaste", (handler) => + handler(view, event, Slice.empty) + ); + + expect(handled).toBe(true); + expect(view.state.doc.firstChild.lastChild.type.name).toBe( + "math_inline" + ); + expect(view.state.doc.firstChild.lastChild.attrs.source).toBe("x^2"); + }); + + it("preserves a rich-text paste when switching through Markdown", () => { + const target = document.createElement("div"); + const editor = new StacksEditor(target, "", { + editorPlugins: [mathJaxPlugin()], + }); + const content = + "Texto normal. Precio escapado: \\$5. Código: `$not_math$`. Fórmula: $x^2 + y^2$"; + const event = { + clipboardData: { + getData: () => content, + files: [], + types: ["text/plain"], + }, + } as unknown as ClipboardEvent; + + editor.editorView.someProp("handlePaste", (handler) => + handler(editor.editorView, event, Slice.empty) + ); + editor.setView(EditorType.Commonmark); + editor.setView(EditorType.RichText); + + const mathNodes: string[] = []; + editor.document.descendants((node) => { + if (node.type.name === "math_inline") { + mathNodes.push(node.attrs.source as string); + } + }); + expect(mathNodes).toEqual(["x^2 + y^2"]); + editor.destroy(); + }); + + it("renders a multi-paragraph paste with display and inline math", () => { + const target = document.createElement("div"); + const editor = new StacksEditor(target, "", { + editorPlugins: [mathJaxPlugin()], + }); + const content = + "The quadratic formula is:\n\n$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$\n\nAnd here is inline math: $x_i = y^2 + 1$."; + const event = { + clipboardData: { + getData: () => content, + files: [], + types: ["text/plain"], + }, + } as unknown as ClipboardEvent; + + const handled = editor.editorView.someProp("handlePaste", (handler) => + handler(editor.editorView, event, Slice.empty) + ); + + const mathNodes: Array<{ type: string; source: string }> = []; + editor.document.descendants((node) => { + if (["math_inline", "math_block"].includes(node.type.name)) { + mathNodes.push({ + type: node.type.name, + source: node.attrs.source as string, + }); + } + }); + expect(handled).toBe(true); + expect(mathNodes).toEqual([ + { + type: "math_block", + source: "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", + }, + { type: "math_inline", source: "x_i = y^2 + 1" }, + ]); + editor.destroy(); + }); + + it.each([ + ["$$x^2$$", "$$", "$$", "x^2"], + ["\\[x^2\\]", "\\[", "\\]", "x^2"], + ])( + "renders a completed display formula while typing: %s", + (text, openDelimiter, closeDelimiter, source) => { + const view = createView(); + + typeText(view, text); + + expect(view.state.doc.firstChild.type.name).toBe("math_block"); + expect(view.state.doc.firstChild.attrs).toMatchObject({ + source, + openDelimiter, + closeDelimiter, + }); + expect(view.state.doc.lastChild.type.name).toBe("paragraph"); + } + ); + + it("does not render escaped dollars as formulas", () => { + const view = createView(); + + typeText(view, "Price: \\$5"); + + expect(view.state.doc.firstChild.childCount).toBe(1); + expect(view.state.doc.textContent).toBe("Price: \\$5"); + }); + + it("does not render formulas while typing inside an inline code span", () => { + const view = createView(); + + typeText(view, "Code: `$notMath$`"); + + const code = view.state.doc.firstChild.lastChild; + expect(code.isText).toBe(true); + expect(code.text).toBe("$notMath$"); + expect(code.marks.map((mark) => mark.type.name)).toContain("code"); + }); + + it("uses custom delimiters while typing", () => { + const view = createView({ + inlineDelimiters: [["%%", "%%"]], + }); + + typeText(view, "Formula: %%x^2%%"); + + expect(view.state.doc.firstChild.lastChild.attrs).toMatchObject({ + source: "x^2", + openDelimiter: "%%", + closeDelimiter: "%%", + }); + }); +}); diff --git a/plugins/official/mathjax/test/markdownit-plugin.test.ts b/plugins/official/mathjax/test/markdownit-plugin.test.ts new file mode 100644 index 00000000..1340eded --- /dev/null +++ b/plugins/official/mathjax/test/markdownit-plugin.test.ts @@ -0,0 +1,105 @@ +import MarkdownIt from "markdown-it"; +import { mathJaxMarkdownPlugin } from "../src/schema"; + +describe("mathJaxMarkdownPlugin", () => { + const markdownIt = new MarkdownIt("default", {}); + markdownIt.use(mathJaxMarkdownPlugin); + + it.each([ + ["$x^2$", "math_inline", "x^2", "$", "$"], + ["\\(x^2\\)", "math_inline", "x^2", "\\(", "\\)"], + ["$$x^2$$", "math_block", "x^2", "$$", "$$"], + ["\\[x^2\\]", "math_block", "x^2", "\\[", "\\]"], + ])( + "tokenizes %s", + (markdown, tokenType, source, openDelimiter, closeDelimiter) => { + const tokens = markdownIt.parse(markdown, {}); + const token = + tokenType === "math_inline" + ? tokens + .find((candidate) => candidate.type === "inline") + ?.children?.find( + (candidate) => candidate.type === tokenType + ) + : tokens.find((candidate) => candidate.type === tokenType); + + expect(token).toBeDefined(); + expect(token.content).toBe(source); + expect(token.attrGet("openDelimiter")).toBe(openDelimiter); + expect(token.attrGet("closeDelimiter")).toBe(closeDelimiter); + } + ); + + it("does not tokenize MathJax inside Markdown code", () => { + const tokens = markdownIt.parse( + "`$inline$`\n\n```text\n$$block$$\n```", + {} + ); + const allTokens = tokens.flatMap((token) => [ + token, + ...(token.children ?? []), + ]); + + expect( + allTokens.filter((token) => + ["math_inline", "math_block"].includes(token.type) + ) + ).toHaveLength(0); + }); + + it("does not allow inline MathJax to cross a code span", () => { + const tokens = markdownIt.parse("$before `code` after$", {}); + const allTokens = tokens.flatMap((token) => [ + token, + ...(token.children ?? []), + ]); + + expect( + allTokens.filter((token) => token.type === "math_inline") + ).toHaveLength(0); + }); + + it("does not allow display MathJax to cross a fenced code block", () => { + const tokens = markdownIt.parse( + "$$before\n```text\ncode\n```\nafter$$", + {} + ); + + expect( + tokens.filter((token) => token.type === "math_block") + ).toHaveLength(0); + }); + + it("removes blockquote prefixes from display MathJax source", () => { + const tokens = markdownIt.parse("> $$\n> \\frac{1}{2}\n> $$", {}); + const math = tokens.find((token) => token.type === "math_block"); + + expect(math?.content).toBe("\n\\frac{1}{2}\n"); + }); + + it("preserves escaped dollars as a dedicated token", () => { + const tokens = markdownIt.parse("Price: \\$5", {}); + const escapedDollar = tokens + .find((token) => token.type === "inline") + ?.children?.find((token) => token.type === "math_escaped_dollar"); + + expect(escapedDollar).toBeDefined(); + }); + + it.each(["$unclosed", "$$unclosed", "\\(unclosed", "\\[unclosed"])( + "leaves unmatched delimiters as text: %s", + (markdown) => { + const tokens = markdownIt.parse(markdown, {}); + const allTokens = tokens.flatMap((token) => [ + token, + ...(token.children ?? []), + ]); + + expect( + allTokens.filter((token) => + ["math_inline", "math_block"].includes(token.type) + ) + ).toHaveLength(0); + } + ); +}); diff --git a/plugins/official/mathjax/test/render.test.ts b/plugins/official/mathjax/test/render.test.ts new file mode 100644 index 00000000..90dbce44 --- /dev/null +++ b/plugins/official/mathjax/test/render.test.ts @@ -0,0 +1,99 @@ +import { RichTextEditor } from "../../../../src"; +import { MathJaxRenderRequest } from "../src/common"; +import { mathJaxExternalProvider } from "./test-helpers"; + +describe("MathJax rich-text rendering", () => { + it("renders inline and display formulas through the host callback", () => { + const renderer = jest.fn( + ({ container, source, display }: MathJaxRenderRequest) => { + container.textContent = `${ + display ? "display" : "inline" + }:${source}`; + } + ); + const target = document.createElement("div"); + + new RichTextEditor( + target, + "Inline $x^2$\n\n$$\\frac{1}{2}$$", + mathJaxExternalProvider({ renderer }), + {} + ); + + expect(renderer).toHaveBeenCalledTimes(2); + expect( + target.querySelector(".stacks-editor-math-inline")?.textContent + ).toBe("inline:x^2"); + expect( + target.querySelector(".stacks-editor-math-display")?.textContent + ).toBe("display:\\frac{1}{2}"); + }); + + it("falls back to the original delimited source without a renderer", () => { + const target = document.createElement("div"); + + new RichTextEditor( + target, + "Inline $x^2$", + mathJaxExternalProvider(), + {} + ); + + expect( + target.querySelector(".stacks-editor-math-inline")?.textContent + ).toBe("$x^2$"); + }); + + it("restores fallback content when the host renderer rejects", async () => { + const target = document.createElement("div"); + new RichTextEditor( + target, + "Inline $x^2$", + mathJaxExternalProvider({ + renderer: () => Promise.reject(new Error("typeset failed")), + }), + {} + ); + + await Promise.resolve(); + await Promise.resolve(); + + expect( + target.querySelector(".stacks-editor-math-inline")?.textContent + ).toBe("$x^2$"); + }); + + it("clears host renderer state when a formula node is destroyed", () => { + const clear = jest.fn(() => Promise.resolve()); + const editor = new RichTextEditor( + document.createElement("div"), + "Inline $x^2$", + mathJaxExternalProvider({ clear }), + {} + ); + + editor.destroy(); + + expect(clear).toHaveBeenCalledTimes(1); + expect(clear).toHaveBeenCalledWith(expect.any(HTMLElement)); + }); + + it.each([ + "Inline $x^2$ and \\(y^2\\).", + "$$\n\\frac{1}{2}\n$$", + "\\[\n\\sum_{i=1}^n i\n\\]", + "> $$\n> \\frac{1}{2}\n> $$", + "- $$\n \\frac{1}{2}\n $$", + "Price: \\$5 and formula $x$.", + "Code: `$notMath$` and formula $x$.", + ])("round-trips Markdown without losing MathJax source: %s", (markdown) => { + const editor = new RichTextEditor( + document.createElement("div"), + markdown, + mathJaxExternalProvider(), + {} + ); + + expect(editor.serializeContent().trimEnd()).toBe(markdown); + }); +}); diff --git a/plugins/official/mathjax/test/test-helpers.ts b/plugins/official/mathjax/test/test-helpers.ts new file mode 100644 index 00000000..a891c34a --- /dev/null +++ b/plugins/official/mathjax/test/test-helpers.ts @@ -0,0 +1,14 @@ +import { Schema } from "prosemirror-model"; +import { ExternalPluginProvider, richTextSchemaSpec } from "../../../../src"; +import { MathJaxOptions } from "../src/common"; +import { mathJaxPlugin } from "../src/mathJaxPlugin"; + +/** Construct an external plugin provider containing only MathJax. */ +export const mathJaxExternalProvider = (options?: MathJaxOptions) => + new ExternalPluginProvider([mathJaxPlugin(options)], {}); + +/** Construct the rich-text schema extended by the MathJax plugin. */ +export const buildMathJaxSchema = (options?: MathJaxOptions) => { + const provider = mathJaxExternalProvider(options); + return new Schema(provider.getFinalizedSchema(richTextSchemaSpec)); +};