editor: add new POC for math nodes

This commit is contained in:
Abdullah Atta
2023-07-27 12:31:42 +05:00
parent 4f1f9ecb6b
commit 6a17bc03e7
10 changed files with 652 additions and 103 deletions

View File

@@ -36,7 +36,7 @@ import stripIndent from "strip-indent";
import { nanoid } from "nanoid";
import Languages from "./languages.json";
interface Indent {
export interface Indent {
type: "tab" | "space";
amount: number;
}
@@ -349,7 +349,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
if (this.options.exitOnTripleEnter && exitOnTripleEnter(editor, $from))
return true;
const indentation = parseIndentation($from.parent);
const indentation = parseIndentation($from.parent, this.name);
if (indentation) return indentOnEnter(editor, $from, indentation);
return false;
@@ -420,7 +420,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
const indentation = parseIndentation($from.parent);
const indentation = parseIndentation($from.parent, this.name);
if (!indentation) return false;
const indentToken = indent(indentation);
@@ -452,7 +452,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
if ($from.parent.type !== this.type) {
return false;
}
const indentation = parseIndentation($from.parent);
const indentation = parseIndentation($from.parent, this.name);
if (!indentation) return false;
const { lines } = $from.parent.attrs as CodeBlockAttributes;
@@ -518,7 +518,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
const indent = fixIndentation(
text,
parseIndentation(view.state.selection.$from.parent)
parseIndentation(view.state.selection.$from.parent, this.name)
);
const { tr } = view.state;
@@ -584,11 +584,12 @@ export type CaretPosition = {
from: number;
};
export function toCaretPosition(
name: string,
selection: Selection,
lines?: CodeLine[]
): CaretPosition | undefined {
const { $from, $to, $head } = selection;
if ($from.parent.type.name !== CodeBlock.name) return;
if ($from.parent.type.name !== name) return;
lines = lines || getLines($from.parent);
for (const line of lines) {
@@ -611,7 +612,7 @@ export function getLines(node: ProsemirrorNode) {
return lines || [];
}
function exitOnTripleEnter(editor: Editor, $from: ResolvedPos) {
export function exitOnTripleEnter(editor: Editor, $from: ResolvedPos) {
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
const endsWithDoubleNewline = $from.parent.textContent.endsWith("\n\n");
@@ -630,7 +631,11 @@ function exitOnTripleEnter(editor: Editor, $from: ResolvedPos) {
.run();
}
function indentOnEnter(editor: Editor, $from: ResolvedPos, options: Indent) {
export function indentOnEnter(
editor: Editor,
$from: ResolvedPos,
options: Indent
) {
const { indentation, newline } = getNewline($from, options) || {};
if (!newline) return false;
@@ -657,7 +662,7 @@ function getNewline($from: ResolvedPos, options: Indent) {
};
}
type CodeLine = {
export type CodeLine = {
index: number;
from: number;
to: number;
@@ -697,7 +702,7 @@ export function toCodeLines(code: string, pos: number): CodeLine[] {
return positions;
}
function getSelectedLines(lines: CodeLine[], selection: Selection) {
export function getSelectedLines(lines: CodeLine[], selection: Selection) {
const { $from, $to } = selection;
return lines.filter(
(line) =>
@@ -707,8 +712,11 @@ function getSelectedLines(lines: CodeLine[], selection: Selection) {
);
}
function parseIndentation(node: ProsemirrorNode): Indent | undefined {
if (node.type.name !== CodeBlock.name) return undefined;
export function parseIndentation(
node: ProsemirrorNode,
name: string
): Indent | undefined {
if (node.type.name !== name) return undefined;
const { indentType, indentLength } = node.attrs;
return {
@@ -725,12 +733,12 @@ function inRange(x: number, a: number, b: number) {
return x >= a && x <= b;
}
function indent(options: Indent) {
export function indent(options: Indent) {
const char = options.type === "space" ? " " : "\t";
return char.repeat(options.amount);
}
function compareCaretPosition(
export function compareCaretPosition(
prev: CaretPosition | undefined,
next: CaretPosition | undefined
): boolean {
@@ -744,7 +752,7 @@ function compareCaretPosition(
/**
* Persist selection between transaction steps
*/
function withSelection(
export function withSelection(
tr: Transaction,
callback: (tr: Transaction) => void
): boolean {

View File

@@ -109,7 +109,9 @@ export function HighlighterPlugin({
name: string;
defaultLanguage: string | null | undefined;
}) {
const HIGHLIGHTER_PLUGIN_KEY = new PluginKey<HighlighterState>("highlighter");
const HIGHLIGHTER_PLUGIN_KEY = new PluginKey<HighlighterState>(
`${name}-highlighter`
);
const HIGHLIGHTED_BLOCKS: Set<string> = new Set();
return new Plugin<HighlighterState>({
@@ -293,6 +295,7 @@ function updateSelection(
}
const position = toCaretPosition(
name,
newState.selection,
isDocChanged ? toCodeLines(node.textContent, pos) : undefined
);

View File

@@ -0,0 +1,183 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box, Flex, Text } from "@theme-ui/components";
import { useEffect, useRef } from "react";
import { Button } from "../../components/button";
import { useTimer } from "../../hooks/use-timer";
import { SelectionBasedReactNodeViewProps } from "../react/types";
import { MathBlock, MathBlockAttributes } from "./math-block";
import { loadKatex } from "./plugin/renderers/katex";
export function MathBlockComponent(
props: SelectionBasedReactNodeViewProps<MathBlockAttributes>
) {
const { editor, node, forwardRef, getPos } = props;
const { indentLength, indentType, caretPosition } = node.attrs;
const toolbarRef = useRef<HTMLDivElement>(null);
const { enabled, start } = useTimer(1000);
const isActive = editor.isActive(MathBlock.name);
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isActive) return;
(async function () {
const pos = getPos();
const node = editor.current?.state.doc.nodeAt(pos);
const text = node?.textContent;
if (text && elementRef.current) {
const katex = await loadKatex();
elementRef.current.innerHTML = katex.renderToString(text, {
displayMode: true,
globalGroup: true,
throwOnError: false
});
}
})();
}, [isActive]);
return (
<>
<Flex
sx={{
flexDirection: "column",
borderRadius: "default",
overflow: "hidden",
...(isActive ? {} : { height: "1px", width: 0, visibility: "hidden" })
}}
>
<Text
ref={forwardRef}
as="pre"
autoCorrect="off"
autoCapitalize="none"
css={{}}
sx={{
"div, span.token, span.line-number-widget, span.line-number::before":
{
fontFamily: "monospace",
fontSize: "code",
whiteSpace: "pre", // TODO !important
tabSize: 1
},
position: "relative",
lineHeight: "20px",
bg: "codeBg",
color: "static",
overflowX: "auto",
display: "flex",
px: 2,
pt: 2,
pb: 2
}}
spellCheck={false}
/>
<Flex
ref={toolbarRef}
contentEditable={false}
sx={{
bg: "codeBg",
alignItems: "center",
justifyContent: "flex-end",
borderTop: "1px solid var(--codeBorder)"
}}
>
{caretPosition ? (
<Text variant={"subBody"} sx={{ mr: 1, color: "codeFg" }}>
Line {caretPosition.line}, Column {caretPosition.column}{" "}
{caretPosition.selected
? `(${caretPosition.selected} selected)`
: ""}
</Text>
) : null}
<Button
variant={"icon"}
sx={{
p: 1,
opacity: "1 !important",
":hover": { bg: "codeSelection" }
}}
title="Toggle indentation mode"
disabled={!editor.isEditable}
onClick={() => {
if (!editor.isEditable) return;
editor.commands.changeCodeBlockIndentation({
type: indentType === "space" ? "tab" : "space",
amount: indentLength
});
}}
>
<Text variant={"subBody"} sx={{ color: "codeFg" }}>
{indentType === "space" ? "Spaces" : "Tabs"}: {indentLength}
</Text>
</Button>
<Button
variant={"icon"}
sx={{
opacity: "1 !important",
p: 1,
bg: "transparent",
":hover": { bg: "codeSelection" }
}}
disabled={true}
>
<Text
variant={"subBody"}
spellCheck={false}
sx={{ color: "codeFg" }}
>
Latex
</Text>
</Button>
{node.textContent?.length > 0 ? (
<Button
variant={"icon"}
sx={{
opacity: "1 !important",
p: 1,
mr: 1,
bg: "transparent",
":hover": { bg: "codeSelection" }
}}
onClick={() => {
editor.commands.copyToClipboard(node.textContent);
start();
}}
title="Copy to clipboard"
>
<Text
variant={"subBody"}
spellCheck={false}
sx={{ color: "codeFg" }}
>
{enabled ? "Copied" : "Copy"}
</Text>
</Button>
) : null}
</Flex>
</Flex>
<Box contentEditable={false} ref={elementRef} />
</>
);
}

View File

@@ -0,0 +1,88 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Text } from "@theme-ui/components";
import { useRef, useEffect } from "react";
import { SelectionBasedReactNodeViewProps } from "../react";
import { loadKatex } from "./plugin/renderers/katex";
import { MathInline } from "./math-inline";
const HIDDEN_STYLES = {
visibility: "hidden" as const,
width: 0,
height: 0,
display: "inline-block" as const,
position: "absolute" as const
};
const VISIBLE_STYLES = {
visibility: "visible" as const
};
export function InlineMathComponent(props: SelectionBasedReactNodeViewProps) {
const { editor, getPos, forwardRef } = props;
const elementRef = useRef<HTMLDivElement>(null);
const isActive = editor.isActive(MathInline.name);
useEffect(() => {
if (isActive) return;
(async function () {
const pos = getPos();
const node = editor.current?.state.doc.nodeAt(pos);
const text = node?.textContent;
if (text && elementRef.current) {
const katex = await loadKatex();
elementRef.current.innerHTML = katex.renderToString(text, {
displayMode: false,
globalGroup: true,
throwOnError: false
});
}
})();
}, [isActive]);
return (
<>
<Text
as="code"
sx={{
...(isActive
? {
...VISIBLE_STYLES,
":before, :after": { content: `"$$"`, color: "fontTertiary" }
}
: HIDDEN_STYLES)
}}
>
<Text as="span" ref={forwardRef} />
</Text>
<Text
as="span"
contentEditable={false}
ref={elementRef}
sx={{
...(!isActive ? VISIBLE_STYLES : HIDDEN_STYLES),
".katex": { fontSize: "1em" }
}}
/>
</>
);
}

View File

@@ -17,9 +17,25 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Node, mergeAttributes } from "@tiptap/core";
import { insertMathNode } from "./plugin";
import { NodeSelection } from "prosemirror-state";
import { Node, mergeAttributes, textblockTypeInputRule } from "@tiptap/core";
import { nanoid } from "nanoid";
import { Node as ProsemirrorNode } from "prosemirror-model";
import { HighlighterPlugin } from "../code-block/highlighter";
import {
CaretPosition,
CodeLine,
Indent,
compareCaretPosition,
exitOnTripleEnter,
getSelectedLines,
indent,
indentOnEnter,
parseIndentation,
withSelection
} from "../code-block";
import { createSelectionBasedNodeView } from "../react";
import { MathBlockComponent } from "./block";
import { findParentNodeClosestToPos } from "@tiptap/core";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -29,19 +45,278 @@ declare module "@tiptap/core" {
}
}
export type MathBlockAttributes = {
language: string;
indentType: Indent["type"];
indentLength: number;
lines: CodeLine[];
caretPosition?: CaretPosition;
};
// simple inputrule for block math
const REGEX_BLOCK_MATH_DOLLARS = /\$\$\$\s+$/; //new RegExp("\$\$\s+$", "i");
export const MathBlock = Node.create({
name: "mathBlock",
group: "block math",
content: "text*", // important!
atom: true, // important!
// atom: true, // important!
code: true,
draggable: false,
marks: "",
addAttributes() {
return {
language: {
default: "latex",
rendered: false
},
id: {
default: undefined,
rendered: false,
parseHTML: () => createMathBlockId()
},
caretPosition: {
default: undefined,
rendered: false
},
lines: {
default: [],
rendered: false
},
indentType: {
default: "space",
parseHTML: (element) => {
const indentType = element.dataset.indentType;
return indentType;
},
renderHTML: (attributes) => {
if (!attributes.indentType) {
return {};
}
return {
"data-indent-type": attributes.indentType
};
}
},
indentLength: {
default: 2,
parseHTML: (element) => {
const indentLength = element.dataset.indentLength;
return indentLength;
},
renderHTML: (attributes) => {
if (!attributes.indentLength) {
return {};
}
return {
"data-indent-length": attributes.indentLength
};
}
}
};
},
addKeyboardShortcuts() {
return {
"Mod-a": ({ editor }) => {
const { $anchor } = this.editor.state.selection;
if ($anchor.parent.type.name !== this.name) {
return false;
}
const codeblock = findParentNodeClosestToPos(
$anchor,
(node) => node.type.name === this.type.name
);
if (!codeblock) return false;
return editor.commands.setTextSelection({
from: codeblock.pos + 1,
to: codeblock.pos + codeblock.node.nodeSize - 1
});
},
// remove code block when at start of document or code block is empty
Backspace: ({ editor }) => {
const { empty, $anchor } = editor.state.selection;
const currentNode = $anchor.parent;
const nextNode = editor.state.doc.nodeAt($anchor.pos + 1);
const isCodeBlock = (node: ProsemirrorNode | null) =>
node && node.type.name === this.name;
const isAtStart = $anchor.pos === 1;
if (!empty) {
return false;
}
if (
isAtStart ||
(isCodeBlock(currentNode) && !currentNode.textContent.length)
) {
return this.editor.commands.deleteNode(this.type);
}
// on android due to composition issues with various keyboards,
// sometimes backspace is detected one node behind. We need to
// manually handle this case.
else if (
nextNode &&
isCodeBlock(nextNode) &&
!nextNode.textContent.length
) {
return this.editor.commands.command(({ tr }) => {
tr.delete($anchor.pos + 1, $anchor.pos + 1 + nextNode.nodeSize);
return true;
});
}
return false;
},
// exit node on triple enter
Enter: ({ editor }) => {
const { state } = editor;
const { selection } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
if (this.options.exitOnTripleEnter && exitOnTripleEnter(editor, $from))
return true;
const indentation = parseIndentation($from.parent, this.name);
if (indentation) return indentOnEnter(editor, $from, indentation);
return false;
},
// exit node on arrow up
ArrowUp: ({ editor }) => {
if (!this.options.exitOnArrowUp) {
return false;
}
const { state } = editor;
const { selection } = state;
const { $anchor, empty } = selection;
if (!empty || $anchor.parent.type !== this.type) {
return false;
}
const isAtStart = $anchor.pos === 1;
if (!isAtStart) {
return false;
}
return editor.commands.insertContentAt(0, "<p></p>");
},
// exit node on arrow down
ArrowDown: ({ editor }) => {
if (!this.options.exitOnArrowDown) {
return false;
}
const { state } = editor;
const { selection, doc } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
if (!isAtEnd) {
return false;
}
const after = $from.after();
if (after === undefined) {
return false;
}
const nodeAfter = doc.nodeAt(after);
if (nodeAfter) {
editor.commands.setNodeSelection($from.before());
return false;
}
return editor.commands.exitCode();
},
"Shift-Tab": ({ editor }) => {
const { state } = editor;
const { selection } = state;
const { $from } = selection;
if ($from.parent.type !== this.type) {
return false;
}
const indentation = parseIndentation($from.parent, this.name);
if (!indentation) return false;
const indentToken = indent(indentation);
const { lines } = $from.parent.attrs as MathBlockAttributes;
const selectedLines = getSelectedLines(lines, selection);
return editor
.chain()
.command(({ tr }) =>
withSelection(tr, (tr) => {
for (const line of selectedLines) {
if (line.text(indentToken.length) !== indentToken) continue;
tr.delete(
tr.mapping.map(line.from),
tr.mapping.map(line.from + indentation.amount)
);
}
})
)
.run();
},
Tab: ({ editor }) => {
const { state } = editor;
const { selection } = state;
const { $from } = selection;
if ($from.parent.type !== this.type) {
return false;
}
const indentation = parseIndentation($from.parent, this.name);
if (!indentation) return false;
const { lines } = $from.parent.attrs as MathBlockAttributes;
const selectedLines = getSelectedLines(lines, selection);
return editor
.chain()
.command(({ tr }) =>
withSelection(tr, (tr) => {
const indentToken = indent(indentation);
if (selectedLines.length === 1)
return tr.insertText(indentToken, $from.pos);
for (const line of selectedLines) {
tr.insertText(indentToken, tr.mapping.map(line.from));
}
})
)
.run();
}
};
},
parseHTML() {
return [
{
tag: "div[class*='math-block']" // important!
tag: "div[class*='math-block']", // important!
preserveWhitespace: "full"
}
];
},
@@ -58,38 +333,50 @@ export const MathBlock = Node.create({
return {
insertMathBlock:
() =>
({ state, dispatch, view }) => {
return insertMathNode(this.type)(state, dispatch, view);
({ commands }) => {
return commands.setNode(this.name, {
id: createMathBlockId()
});
}
};
},
addInputRules() {
return [
{
textblockTypeInputRule({
find: REGEX_BLOCK_MATH_DOLLARS,
handler: ({ state, range }) => {
const { from: start, to: end } = range;
const $start = state.doc.resolve(start);
if (
!$start
.node(-1)
.canReplaceWith(
$start.index(-1),
$start.indexAfter(-1),
this.type
)
)
return null;
const tr = state.tr
.delete(start, end)
.setBlockType(start, start, this.type, null);
tr.setSelection(
NodeSelection.create(tr.doc, tr.mapping.map($start.pos - 1))
);
type: this.type,
getAttributes: {
id: createMathBlockId()
}
}
})
];
},
addProseMirrorPlugins() {
return [HighlighterPlugin({ name: this.name, defaultLanguage: "latex" })];
},
addNodeView() {
return createSelectionBasedNodeView(MathBlockComponent, {
contentDOMFactory: () => {
const content = document.createElement("div");
content.classList.add("node-content-wrapper");
content.style.whiteSpace = "pre";
// caret is not visible if content element width is 0px
content.style.minWidth = "20px";
return { dom: content };
},
shouldUpdate: ({ attrs: prev }, { attrs: next }) => {
return (
compareCaretPosition(prev.caretPosition, next.caretPosition) ||
prev.indentType !== next.indentType
);
}
});
}
});
function createMathBlockId() {
return `mathBlock-${nanoid(12)}`;
}

View File

@@ -18,7 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Node, mergeAttributes } from "@tiptap/core";
import { mathPlugin } from "./plugin";
import { createSelectionBasedNodeView } from "../react";
import { InlineMathComponent } from "./component";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -28,25 +29,15 @@ declare module "@tiptap/core" {
}
}
// simple input rule for inline math
const REGEX_INLINE_MATH_DOLLARS = /\$\$(.+)\$\$/; //new RegExp("\$(.+)\$", "i");
// negative lookbehind regex notation allows for escaped \$ delimiters
// (requires browser supporting ECMA2018 standard -- currently only Chrome / FF)
// (see https://javascript.info/regexp-lookahead-lookbehind)
// const REGEX_INLINE_MATH_DOLLARS_ESCAPED: RegExp = (() => {
// // attempt to create regex with negative lookbehind
// try {
// return new RegExp("(?<!\\\\)\\$(.+)(?<!\\\\)\\$");
// } catch (e) {
// return REGEX_INLINE_MATH_DOLLARS;
// }
// })();
const REGEX_INLINE_MATH_DOLLARS = /\$\$(.+)\$\$/;
export const MathInline = Node.create({
name: "mathInline",
group: "inline math",
group: "inline",
content: "text*", // important!
marks: "",
inline: true, // important!
atom: true, // important!
draggable: false,
code: true,
parseHTML() {
@@ -79,8 +70,11 @@ export const MathInline = Node.create({
};
},
addProseMirrorPlugins() {
return [mathPlugin];
addNodeView() {
return createSelectionBasedNodeView(InlineMathComponent, {
contentDOMFactory: true,
wrapperFactory: () => document.createElement("span")
});
},
addInputRules() {

View File

@@ -17,16 +17,19 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type katex from "katex";
import { MathRenderer } from "./types";
async function loadKatex() {
const { default: katex } = await import("katex");
let Katex: typeof katex;
export async function loadKatex() {
if (Katex) return Katex;
const { default: _katex } = await import("katex");
// Chemistry formulas support
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TODO: maybe rewrite this in typescript?
await import("katex/contrib/mhchem/mhchem");
return katex;
Katex = _katex;
return Katex;
}
export const KatexRenderer: MathRenderer = {

View File

@@ -126,7 +126,9 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
getContentDOM(): ContentDOM {
if (!this.options.contentDOMFactory) return;
if (this.options.contentDOMFactory === true) {
const content = document.createElement("div");
const content = document.createElement(
this.node.isInline ? "span" : "div"
);
content.classList.add(
`${this.node.type.name.toLowerCase()}-content-wrapper`
);

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from "react";
import { DecorationSet } from "prosemirror-view";
import { Node as PMNode } from "prosemirror-model";
import { Selection, NodeSelection } from "prosemirror-state";
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
import {
stateKey as SelectionChangePluginKey,
ReactNodeViewState
@@ -234,6 +234,22 @@ export class SelectionBasedNodeView<
) {
return true;
}
} else {
const newTextSelection = this.isSelectionInsideNode(
selection.from,
selection.to
);
const oldTextSelection = this.isSelectionInsideNode(
oldSelection.from,
oldSelection.to
);
if (
(newTextSelection && !oldTextSelection) ||
(oldTextSelection && !newTextSelection)
) {
return true;
}
}
const movedInToSelection =

View File

@@ -395,41 +395,6 @@ span:focus .fake-cursor {
display: none;
}
/* -- Inline Math --------------------------------------- */
.math-inline {
display: inline;
white-space: nowrap;
}
.math-inline .math-render {
display: inline-block;
/* font-size: 0.85em; */
cursor: pointer;
}
.math-inline .math-src .ProseMirror {
display: inline;
/* Necessary to fix FireFox bug with contenteditable, https://bugzilla.mozilla.org/show_bug.cgi?id=1252108 */
border-right: 1px solid transparent;
border-left: 1px solid transparent;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas,
Liberation Mono, monospace !important;
}
.math-inline.ProseMirror-selectednode {
background-color: var(--bgSecondary);
padding: 3px;
border-radius: 5px;
border: 1px solid var(--border);
}
.math-inline .math-src::after,
.math-inline .math-src::before {
content: "$$";
color: var(--disabled);
}
/* -- Block Math ---------------------------------------- */
.math-block {