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:
@@ -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;
|
||||
|
||||
86
shared/editor/nodes/Math.ts
Normal file
86
shared/editor/nodes/Math.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
53
shared/editor/nodes/MathBlock.ts
Normal file
53
shared/editor/nodes/MathBlock.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
75
shared/editor/plugins/Math.ts
Normal file
75
shared/editor/plugins/Math.ts
Normal 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
180
shared/editor/rules/math.ts
Normal 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"],
|
||||
});
|
||||
}
|
||||
@@ -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
1
shared/typings/styles.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
Reference in New Issue
Block a user