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 /^