Add support for LaTeX inline and block expressions. (#4446)

* Add support for LaTeX inline and block expressions. (#4364)

Co-authored-by: Tom Moor <tom@getoutline.com>

* tsc

* Show heading markers when LaTeX block is being edited

* Tab to space, name katex chunk

* Fork htmldiff, add support for math nodes

Co-authored-by: luisbc92 <luiscarlos.banuelos@gmail.com>
This commit is contained in:
Tom Moor
2022-11-27 06:27:56 -08:00
committed by GitHub
parent cb1b8e9764
commit fa8685d241
16 changed files with 1785 additions and 21 deletions

View File

@@ -11,6 +11,109 @@ export type Props = {
theme: DefaultTheme;
};
const mathStyle = (props: Props) => `
/* Based on https://github.com/benrbray/prosemirror-math/blob/master/style/math.css */
.math-node {
min-width: 1em;
min-height: 1em;
font-size: 0.95em;
font-family: ${props.theme.fontFamilyMono};
cursor: auto;
}
.math-node.empty-math .math-render::before {
content: "(empty math)";
color: ${props.theme.brand.red};
}
.math-node .math-render.parse-error::before {
content: "(math error)";
color: ${props.theme.brand.red};
cursor: help;
}
.math-node.ProseMirror-selectednode {
outline: none;
}
.math-node .math-src {
display: none;
color: ${props.theme.codeStatement};
tab-size: 4;
}
.math-node.ProseMirror-selectednode .math-src {
display: inline;
}
.math-node.ProseMirror-selectednode .math-render {
display: none;
}
math-inline {
display: inline; white-space: nowrap;
}
math-inline .math-render {
display: inline-block;
font-size: 0.85em;
}
math-inline .math-src .ProseMirror {
display: inline;
border-radius: 4px;
border: 1px solid ${props.theme.codeBorder};
padding: 3px 4px;
margin: 0px 3px;
font-family: ${props.theme.fontFamilyMono};
font-size: 80%;
}
math-block {
display: block;
}
math-block .math-render {
display: block;
}
math-block.ProseMirror-selectednode {
border-radius: 4px;
border: 1px solid ${props.theme.codeBorder};
background: ${props.theme.codeBackground};
padding: 0.75em 1em;
font-family: ${props.theme.fontFamilyMono};
font-size: 80%;
}
math-block .math-src .ProseMirror {
width: 100%;
display: block;
}
math-block .katex-display {
margin: 0;
}
p::selection, p > *::selection {
background-color: #c0c0c0;
}
.katex-html *::selection {
background-color: none !important;
}
.math-node.math-select .math-render {
background-color: #c0c0c0ff;
}
math-inline.math-select .math-render {
padding-top: 2px;
}
`;
const style = (props: Props) => `
flex-grow: ${props.grow ? 1 : 0};
justify-content: start;
@@ -329,6 +432,7 @@ h6:not(.placeholder):before {
content: "H6";
}
.ProseMirror[contenteditable="true"]:focus-within,
.ProseMirror-focused {
h1,
h2,
@@ -1254,6 +1358,7 @@ table {
}
}
.ProseMirror[contenteditable="true"]:focus-within,
.ProseMirror-focused .block-menu-trigger,
.block-menu-trigger:active,
.block-menu-trigger:focus {
@@ -1344,6 +1449,7 @@ del[data-operation-index] {
const EditorContainer = styled.div<Props>`
${style};
${mathStyle};
`;
export default EditorContainer;

View File

@@ -0,0 +1,86 @@
import {
mathBackspaceCmd,
insertMathCmd,
makeInlineMathInputRule,
REGEX_INLINE_MATH_DOLLARS,
mathSchemaSpec,
} from "@benrbray/prosemirror-math";
import { PluginSimple } from "markdown-it";
import {
chainCommands,
deleteSelection,
selectNodeBackward,
joinBackward,
} from "prosemirror-commands";
import {
NodeSpec,
NodeType,
Schema,
Node as ProsemirrorNode,
} from "prosemirror-model";
import { EditorState, Plugin } from "prosemirror-state";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import MathPlugin from "../plugins/Math";
import mathRule from "../rules/math";
import { Dispatch } from "../types";
import Node from "./Node";
export default class Math extends Node {
get name() {
return "math_inline";
}
get schema(): NodeSpec {
return mathSchemaSpec.nodes.math_inline;
}
commands({ type }: { type: NodeType }) {
return () => (state: EditorState, dispatch: Dispatch) => {
dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView());
return true;
};
}
inputRules({ schema }: { schema: Schema }) {
return [
makeInlineMathInputRule(
REGEX_INLINE_MATH_DOLLARS,
schema.nodes.math_inline
),
];
}
keys({ type }: { type: NodeType }) {
return {
"Mod-Space": insertMathCmd(type),
Backspace: chainCommands(
deleteSelection,
mathBackspaceCmd,
joinBackward,
selectNodeBackward
),
};
}
get plugins(): Plugin[] {
return [MathPlugin];
}
get rulePlugins(): PluginSimple[] {
return [mathRule];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.write("$");
state.text(node.textContent, false);
state.write("$");
}
parseMarkdown() {
return {
node: "math_inline",
block: "math_inline",
noCloseToken: true,
};
}
}

View File

@@ -0,0 +1,53 @@
import {
makeBlockMathInputRule,
REGEX_BLOCK_MATH_DOLLARS,
mathSchemaSpec,
} from "@benrbray/prosemirror-math";
import { PluginSimple } from "markdown-it";
import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import mathRule from "../rules/math";
import { Dispatch } from "../types";
import Node from "./Node";
export default class MathBlock extends Node {
get name() {
return "math_block";
}
get schema(): NodeSpec {
return mathSchemaSpec.nodes.math_display;
}
get rulePlugins(): PluginSimple[] {
return [mathRule];
}
commands({ type }: { type: NodeType }) {
return () => (state: EditorState, dispatch: Dispatch) => {
dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView());
return true;
};
}
inputRules({ type }: { type: NodeType }) {
return [makeBlockMathInputRule(REGEX_BLOCK_MATH_DOLLARS, type)];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.write("$$\n");
state.text(node.textContent, false);
state.ensureNewLine();
state.write("$$");
state.closeBlock(node);
}
parseMarkdown() {
return {
node: "math_block",
block: "math_block",
noCloseToken: true,
};
}
}

View File

@@ -13,6 +13,8 @@ import Embed from "../nodes/Embed";
import Heading from "../nodes/Heading";
import HorizontalRule from "../nodes/HorizontalRule";
import ListItem from "../nodes/ListItem";
import Math from "../nodes/Math";
import MathBlock from "../nodes/MathBlock";
import Node from "../nodes/Node";
import Notice from "../nodes/Notice";
import OrderedList from "../nodes/OrderedList";
@@ -36,6 +38,8 @@ const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
OrderedList,
Embed,
ListItem,
Math,
MathBlock,
Attachment,
Notice,
Heading,

View File

@@ -0,0 +1,75 @@
import { MathView } from "@benrbray/prosemirror-math";
import { Node as ProseNode } from "prosemirror-model";
import { Plugin, PluginKey, PluginSpec } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
export interface IMathPluginState {
macros: { [cmd: string]: string };
activeNodeViews: MathView[];
prevCursorPos: number;
}
const MATH_PLUGIN_KEY = new PluginKey<IMathPluginState>("prosemirror-math");
export function createMathView(displayMode: boolean) {
return (
node: ProseNode,
view: EditorView,
getPos: boolean | (() => number)
): MathView => {
// dynamically load katex styles and fonts
import(
/* webpackChunkName: "katex" */
"katex/dist/katex.min.css"
);
const pluginState = MATH_PLUGIN_KEY.getState(view.state);
if (!pluginState) {
throw new Error("no math plugin!");
}
const nodeViews = pluginState.activeNodeViews;
// set up NodeView
const nodeView = new MathView(
node,
view,
getPos as () => number,
{ katexOptions: { displayMode, macros: pluginState.macros } },
MATH_PLUGIN_KEY,
() => {
nodeViews.splice(nodeViews.indexOf(nodeView));
}
);
nodeViews.push(nodeView);
return nodeView;
};
}
const mathPluginSpec: PluginSpec<IMathPluginState> = {
key: MATH_PLUGIN_KEY,
state: {
init() {
return {
macros: {},
activeNodeViews: [],
prevCursorPos: 0,
};
},
apply(tr, value, oldState) {
return {
activeNodeViews: value.activeNodeViews,
macros: value.macros,
prevCursorPos: oldState.selection.from,
};
},
},
props: {
nodeViews: {
math_inline: createMathView(false),
math_block: createMathView(true),
},
},
};
export default new Plugin(mathPluginSpec);

180
shared/editor/rules/math.ts Normal file
View File

@@ -0,0 +1,180 @@
import MarkdownIt from "markdown-it";
import StateBlock from "markdown-it/lib/rules_block/state_block";
import StateInline from "markdown-it/lib/rules_inline/state_inline";
// test if potential opening or closing delimiter
// assumes that there is a "$" at state.src[pos]
function isValidDelimiter(state: StateInline, pos: number) {
const max = state.posMax;
let canOpen = true,
canClose = true;
const prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1;
const nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1;
// check non-whitespace conditions for open/close, and
// check that closing delimiter isn't followed by a number
if (
prevChar === 0x20 || // " "
prevChar === 0x09 || // "\t"
(nextChar >= 0x30 && nextChar <= 0x39) // "0" - "9"
) {
canClose = false;
}
if (nextChar === 0x20 || nextChar === 0x09) {
canOpen = false;
}
return { canOpen, canClose };
}
function mathInline(state: StateInline, silent: boolean): boolean {
let match, token, res, pos;
if (state.src[state.pos] !== "$") {
return false;
}
res = isValidDelimiter(state, state.pos);
if (!res.canOpen) {
if (!silent) {
state.pending += "$";
}
state.pos += 1;
return true;
}
// first check for and bypass all properly escaped delimiters
// this loop will assume that the first leading backtick can not
// be the first character in state.src, which is known since
// we have found an opening delimiter already
const start = state.pos + 1;
match = start;
while ((match = state.src.indexOf("$", match)) !== 1) {
// found potential $, look for escapes, pos will point to
// first non escape when complete
pos = match - 1;
while (state.src[pos] === "\\") {
pos -= 1;
}
// even number of escapes, potential closing delimiter found
if ((match - pos) % 2 === 1) {
break;
}
match += 1;
}
// no closing delimiter found, consume $ and continue
if (match === -1) {
if (!silent) {
state.pending += "$";
}
state.pos = start;
return true;
}
// check if we have empty content (ex. $$) do not parse
if (match - start === 0) {
if (!silent) {
state.pending += "$$";
}
state.pos = start + 1;
return true;
}
// check for valid closing delimiter
res = isValidDelimiter(state, match);
if (!res.canClose) {
if (!silent) {
state.pending += "$";
}
state.pos = start;
return true;
}
if (!silent) {
token = state.push("math_inline", "math", 0);
token.markup = "$";
token.content = state.src.slice(start, match);
}
state.pos = match + 1;
return true;
}
function mathDisplay(
state: StateBlock,
start: number,
end: number,
silent: boolean
) {
let firstLine,
lastLine,
next,
lastPos,
found = false,
pos = state.bMarks[start] + state.tShift[start],
max = state.eMarks[start];
if (pos + 2 > max) {
return false;
}
if (state.src.slice(pos, pos + 2) !== "$$") {
return false;
}
pos += 2;
firstLine = state.src.slice(pos, max);
if (silent) {
return true;
}
if (firstLine.trim().slice(-2) === "$$") {
// Single line expression
firstLine = firstLine.trim().slice(0, -2);
found = true;
}
for (next = start; !found; ) {
next++;
if (next >= end) {
break;
}
pos = state.bMarks[next] + state.tShift[next];
max = state.eMarks[next];
if (pos < max && state.tShift[next] < state.blkIndent) {
// non-empty line with negative indent should stop the list:
break;
}
if (state.src.slice(pos, max).trim().slice(-2) === "$$") {
lastPos = state.src.slice(0, max).lastIndexOf("$$");
lastLine = state.src.slice(pos, lastPos);
found = true;
}
}
state.line = next + 1;
const token = state.push("math_block", "math", 0);
token.block = true;
token.content =
(firstLine && firstLine.trim() ? firstLine + "\n" : "") +
state.getLines(start + 1, next, state.tShift[start], true) +
(lastLine && lastLine.trim() ? lastLine : "");
token.map = [start, state.line];
token.markup = "$$";
return true;
}
export default function markdownMath(md: MarkdownIt) {
md.inline.ruler.after("escape", "math_inline", mathInline);
md.block.ruler.after("blockquote", "math_block", mathDisplay, {
alt: ["paragraph", "reference", "blockquote", "list"],
});
}

View File

@@ -277,6 +277,8 @@
"Bold": "Bold",
"Subheading": "Subheading",
"Table": "Table",
"Math inline (LaTeX)": "Math inline (LaTeX)",
"Math block (LaTeX)": "Math block (LaTeX)",
"Tip": "Tip",
"Tip notice": "Tip notice",
"Show diagram": "Show diagram",
@@ -571,7 +573,9 @@
"Numbered list": "Numbered list",
"Blockquote": "Blockquote",
"Horizontal divider": "Horizontal divider",
"LaTeX block": "LaTeX block",
"Inline code": "Inline code",
"Inline LaTeX": "Inline LaTeX",
"Sign In": "Sign In",
"Continue with Email": "Continue with Email",
"Continue with {{ authProviderName }}": "Continue with {{ authProviderName }}",

1
shared/typings/styles.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.css";