diff --git a/app/editor/menus/block.tsx b/app/editor/menus/block.tsx
index 291526aa1..b4d070180 100644
--- a/app/editor/menus/block.tsx
+++ b/app/editor/menus/block.tsx
@@ -18,6 +18,7 @@ import {
AttachmentIcon,
ClockIcon,
CalendarIcon,
+ MathIcon,
} from "outline-icons";
import * as React from "react";
import styled from "styled-components";
@@ -124,6 +125,12 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
shortcut: "^ ⇧ \\",
keywords: "script",
},
+ {
+ name: "math_block",
+ title: dictionary.mathBlock,
+ icon: ,
+ keywords: "math katex latex",
+ },
{
name: "hr",
title: dictionary.hr,
diff --git a/app/hooks/useDictionary.ts b/app/hooks/useDictionary.ts
index 670017490..c970ff810 100644
--- a/app/hooks/useDictionary.ts
+++ b/app/hooks/useDictionary.ts
@@ -69,6 +69,8 @@ export default function useDictionary() {
strong: t("Bold"),
subheading: t("Subheading"),
table: t("Table"),
+ mathInline: t("Math inline (LaTeX)"),
+ mathBlock: t("Math block (LaTeX)"),
tip: t("Tip"),
tipNotice: t("Tip notice"),
showDiagram: t("Show diagram"),
diff --git a/app/scenes/KeyboardShortcuts.tsx b/app/scenes/KeyboardShortcuts.tsx
index 02273e975..c7cbcad31 100644
--- a/app/scenes/KeyboardShortcuts.tsx
+++ b/app/scenes/KeyboardShortcuts.tsx
@@ -339,6 +339,14 @@ function KeyboardShortcuts() {
shortcut: {"```"},
label: t("Code block"),
},
+ {
+ shortcut: (
+ <>
+ $$ Space
+ >
+ ),
+ label: t("LaTeX block"),
+ },
{
shortcut: {":::"},
label: t("Info notice"),
@@ -359,6 +367,10 @@ function KeyboardShortcuts() {
shortcut: "`code`",
label: t("Inline code"),
},
+ {
+ shortcut: "$latex$",
+ label: t("Inline LaTeX"),
+ },
{
shortcut: "==highlight==",
label: t("Highlight"),
diff --git a/package.json b/package.json
index a36b03309..62af1a0dd 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,7 @@
"@babel/plugin-transform-regenerator": "^7.10.4",
"@babel/preset-env": "^7.16.0",
"@babel/preset-react": "^7.16.0",
+ "@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^4.2.2",
"@bull-board/koa": "^4.6.2",
"@dnd-kit/core": "^6.0.5",
@@ -113,6 +114,7 @@
"json-loader": "0.5.7",
"jsonwebtoken": "^8.5.0",
"jszip": "^3.10.1",
+ "katex": "^0.16.3",
"kbar": "0.1.0-beta.28",
"koa": "^2.13.4",
"koa-body": "^4.2.0",
@@ -246,6 +248,7 @@
"@types/ioredis": "^4.28.1",
"@types/jest": "^28.1.6",
"@types/jsonwebtoken": "^8.5.8",
+ "@types/katex": "^0.14.0",
"@types/koa": "^2.13.4",
"@types/koa-compress": "^4.0.3",
"@types/koa-helmet": "^6.0.4",
@@ -309,6 +312,7 @@
"babel-plugin-transform-typescript-metadata": "^0.3.2",
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.3",
"concurrently": "^7.4.0",
+ "css-loader": "5.2.6",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.6",
"eslint": "^7.32.0",
@@ -335,6 +339,7 @@
"prettier": "^2.0.5",
"react-refresh": "^0.14.0",
"rimraf": "^2.5.4",
+ "style-loader": "2.0.0",
"terser-webpack-plugin": "^4.1.0",
"typescript": "^4.7.4",
"url-loader": "^4.1.1",
diff --git a/server/models/helpers/DocumentHelper.tsx b/server/models/helpers/DocumentHelper.tsx
index 3c3eda484..74c3dab06 100644
--- a/server/models/helpers/DocumentHelper.tsx
+++ b/server/models/helpers/DocumentHelper.tsx
@@ -4,7 +4,6 @@ import {
} from "@getoutline/y-prosemirror";
import { JSDOM } from "jsdom";
import { escapeRegExp } from "lodash";
-import diff from "node-htmldiff";
import { Node, DOMSerializer } from "prosemirror-model";
import * as React from "react";
import { renderToString } from "react-dom/server";
@@ -19,6 +18,7 @@ import { parser, schema } from "@server/editor";
import Logger from "@server/logging/Logger";
import Document from "@server/models/Document";
import type Revision from "@server/models/Revision";
+import diff from "@server/utils/diff";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import { getSignedUrl } from "@server/utils/s3";
import Attachment from "../Attachment";
diff --git a/server/utils/diff.ts b/server/utils/diff.ts
new file mode 100644
index 000000000..e19c940d0
--- /dev/null
+++ b/server/utils/diff.ts
@@ -0,0 +1,1118 @@
+// Forked from https://github.com/inkling/htmldiff.js/blob/master/js/htmldiff.js
+
+// The MIT License (MIT)
+
+// Copyright (c) 2012 The Network Inc. and contributors
+// Copyright (c) 2022 idesis GmbH, Max-Keith-Straße 66 (E 11), D-45136 Essen, https://www.idesis.de
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy of
+// this software and associated documentation files (the "Software"), to deal in
+// the Software without restriction, including without limitation the rights to
+// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+// of the Software, and to permit persons to whom the Software is furnished to do
+// so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+/**
+ * htmldiff.js is a library that compares HTML content. It creates a diff between two
+ * HTML documents by combining the two documents and wrapping the differences with
+ * and tags. Here is a high-level overview of how the diff works.
+ *
+ * 1. Tokenize the before and after HTML with htmlToTokens.
+ * 2. Generate a list of operations that convert the before list of tokens to the after
+ * list of tokens with calculateOperations, which does the following:
+ * a. Find all the matching blocks of tokens between the before and after lists of
+ * tokens with findMatchingBlocks. This is done by finding the single longest
+ * matching block with findMatch, then iteratively finding the next longest
+ * matching blocks that precede and follow the longest matching block.
+ * b. Determine insertions, deletions, and replacements from the matching blocks.
+ * This is done in calculateOperations.
+ * 3. Render the list of operations by wrapping tokens with and tags where
+ * appropriate with renderOperations.
+ *
+ * Example usage:
+ *
+ * var htmldiff = require('htmldiff.js');
+ *
+ * htmldiff('this is some text
', 'this is some more text
')
+ * == 'this is some more text
'
+ *
+ * htmldiff('this is some text
', 'this is some more text
', 'diff-class')
+ * == 'this is some more text
'
+ */
+
+"use strict";
+
+type Token = {
+ string: string;
+ key: string;
+};
+
+type Segment = {
+ beforeTokens: Array;
+ afterTokens: Array;
+ beforeIndex: number;
+ afterIndex: number;
+
+ beforeMap: object;
+ afterMap: object;
+};
+
+type MatchT = {
+ segment: Segment;
+ length: number;
+
+ startInBefore: number;
+ endInBefore: number;
+ startInAfter: number;
+ endInAfter: number;
+
+ segmentStartInBefore: number;
+ segmentStartInAfter: number;
+ segmentEndInBefore: number;
+ segmentEndInAfter: number;
+};
+
+type OperationType = "insert" | "delete" | "replace" | "equal" | "none";
+
+type Operation = {
+ action: OperationType;
+ startInBefore: number;
+ endInBefore: number | null;
+ startInAfter: number | null;
+ endInAfter: number | null;
+};
+
+function isEndOfTag(char: string) {
+ return char === ">";
+}
+
+function isStartOfTag(char: string) {
+ return char === "<";
+}
+
+function isWhitespace(char: string) {
+ return /^\s+$/.test(char);
+}
+
+/**
+ * Determines if the given token is a tag.
+ *
+ * @param {string} token The token in question.
+ *
+ * @return {boolean|string} False if the token is not a tag, or the tag name otherwise.
+ */
+function isTag(token: string): boolean | string {
+ const match = token.match(/^\s*<([^!>][^>]*)>\s*$/);
+ return !!match && match[1].trim().split(" ")[0];
+}
+
+function isntTag(token: string) {
+ return !isTag(token);
+}
+
+function isStartofHTMLComment(word: string) {
+ return /^