Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-mathjax-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stackoverflow/stacks-editor": minor
---

Add MathJax parsing and rendering support to the rich-text editor.
7 changes: 7 additions & 0 deletions plugins/official/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
36 changes: 36 additions & 0 deletions plugins/official/mathjax/src/common.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
/** Host callback that clears renderer-owned state from a container. */
clear?: (container: HTMLElement) => void | Promise<void>;
}

/** 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[] = [
["$$", "$$"],
["\\[", "\\]"],
];
289 changes: 289 additions & 0 deletions plugins/official/mathjax/src/input-rules.ts
Original file line number Diff line number Diff line change
@@ -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])+?)`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Typing $x+\$y$ never converts, but entering the same source in Markdown mode renders correctly. This regex stops at escaped dollars too—can we use escape-aware delimiter matching here?

Image

To reproduce

  1. In an empty Rich Text editor, type $x+\$y$ character by character
  2. Note that it stays as literal text instead of rendering
  3. Switch to Markdown mode
  4. Replace the contents with the exact same source: $x+\$y$
  5. Switch back to Rich Text mode
  6. Note that the formula now renders—the typing and Markdown parsing paths handle the same source differently

}

/** 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);
Comment on lines +163 to +170

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Completing display math in the middle of a paragraph deletes everything after the cursor. Can we preserve that text, or only convert when the formula fills the whole paragraph?

Image

To reproduce

  1. In an empty Rich Text editor, type $$x$ KEEP THIS character by character
  2. Place the cursor immediately before the space preceding KEEP THIS
  3. Type one more $ to complete $$x$$
  4. Note that the formula renders, but KEEP THIS is deleted

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;
}
Comment on lines +219 to +234

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Backspace immediately after typing $x$ doesn’t undo the conversion. This hook turns the restored text straight back into math. Can we exclude input-rule undo transactions here?

Image

To reproduce

  1. In an empty Rich Text editor, type $x$ character by character
  2. Once the formula renders, immediately press Backspace without moving the cursor
  3. Note that the formula remains rendered instead of reverting to literal $x$


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;
},
});
}
Loading
Loading