Add LaTeX / math input and rendering support (#345)
authorginnyTheCat <ginnythecat@lelux.net>
Sun, 24 Apr 2022 15:48:35 +0000 (17:48 +0200)
committerGitHub <noreply@github.com>
Sun, 24 Apr 2022 15:48:35 +0000 (21:18 +0530)
* Initial display support

* Use better colors for error in math parsing

* Parse math markdown

* Use proper jsx

* Better copy support

* use css var directly

* Remove console.debug call

* Lazy load math module

* Show fallback while katex is loading

package-lock.json
package.json
src/app/atoms/math/Math.jsx [new file with mode: 0644]
src/app/molecules/message/Message.jsx
src/client/state/RoomsInput.js
src/util/markdown.js
src/util/sanitize.js
src/util/twemojify.js [deleted file]
src/util/twemojify.jsx [new file with mode: 0644]

index c028f04711f3aef868e5fe3c36ed5e4703d5a0b4..14063ed5f78071bcb6e14691d08ada13960fd74a 100644 (file)
         "flux": "^4.0.3",
         "formik": "^2.2.9",
         "html-react-parser": "^1.4.11",
+        "katex": "^0.15.2",
         "linkifyjs": "^2.1.9",
         "matrix-js-sdk": "^17.0.0",
         "micromark": "^3.0.10",
         "micromark-extension-gfm": "^2.0.1",
+        "micromark-extension-math": "^2.0.2",
         "micromark-util-chunked": "^1.0.0",
         "micromark-util-resolve-all": "^1.0.0",
         "micromark-util-symbol": "^1.0.1",
       "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=",
       "dev": true
     },
+    "node_modules/@types/katex": {
+      "version": "0.11.1",
+      "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.11.1.tgz",
+      "integrity": "sha512-DUlIj2nk0YnJdlWgsFuVKcX27MLW0KbKmGVoUHmFr+74FYYNUDAaj9ZqTADvsbE8rfxuVmSFc7KczYn5Y09ozg=="
+    },
     "node_modules/@types/mime": {
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
       "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.9.0.tgz",
       "integrity": "sha512-qOKJyNj/h+OWx7s5DePL6Zu1KeM9jPZhwBqs+7DzP6bGOvqzVCSf0xueYmVuaC/oQ/VtS2zLMLHdQFbkka+XDQ==",
       "dev": true,
+      "optional": true,
+      "peer": true,
       "dependencies": {
         "fast-deep-equal": "^3.1.1",
         "json-schema-traverse": "^1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
       "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
-      "dev": true
+      "dev": true,
+      "optional": true,
+      "peer": true
     },
     "node_modules/ajv-keywords": {
       "version": "3.5.2",
       "version": "8.3.0",
       "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
       "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
-      "dev": true,
       "engines": {
         "node": ">= 12"
       }
         "node": ">=4.0"
       }
     },
+    "node_modules/katex": {
+      "version": "0.15.2",
+      "resolved": "https://registry.npmjs.org/katex/-/katex-0.15.2.tgz",
+      "integrity": "sha512-FfZ/f6f8bQdLmJ3McXDNTkKenQkoXkItpW0I9bsG2wgb+8JAY5bwpXFtI8ZVrg5hc1wo1X/UIhdkVMpok46tEQ==",
+      "funding": [
+        "https://opencollective.com/katex",
+        "https://github.com/sponsors/katex"
+      ],
+      "dependencies": {
+        "commander": "^8.0.0"
+      },
+      "bin": {
+        "katex": "cli.js"
+      }
+    },
     "node_modules/kind-of": {
       "version": "6.0.3",
       "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/micromark-extension-math": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-2.0.2.tgz",
+      "integrity": "sha512-cFv2B/E4pFPBBFuGgLHkkNiFAIQv08iDgPH2HCuR2z3AUgMLecES5Cq7AVtwOtZeRrbA80QgMUk8VVW0Z+D2FA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/katex": "^0.11.0",
+        "katex": "^0.13.0",
+        "micromark-factory-space": "^1.0.0",
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0",
+        "uvu": "^0.5.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-math/node_modules/katex": {
+      "version": "0.13.24",
+      "resolved": "https://registry.npmjs.org/katex/-/katex-0.13.24.tgz",
+      "integrity": "sha512-jZxYuKCma3VS5UuxOx/rFV1QyGSl3Uy/i0kTJF3HgQ5xMinCQVF8Zd4bMY/9aI9b9A2pjIBOsjSSm68ykTAr8w==",
+      "funding": [
+        "https://opencollective.com/katex",
+        "https://github.com/sponsors/katex"
+      ],
+      "dependencies": {
+        "commander": "^8.0.0"
+      },
+      "bin": {
+        "katex": "cli.js"
+      }
+    },
     "node_modules/micromark-factory-destination": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.0.0.tgz",
       "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=",
       "dev": true
     },
+    "@types/katex": {
+      "version": "0.11.1",
+      "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.11.1.tgz",
+      "integrity": "sha512-DUlIj2nk0YnJdlWgsFuVKcX27MLW0KbKmGVoUHmFr+74FYYNUDAaj9ZqTADvsbE8rfxuVmSFc7KczYn5Y09ozg=="
+    },
     "@types/mime": {
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
       "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
       "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
       "dev": true,
-      "requires": {
-        "ajv": "^8.0.0"
-      },
+      "requires": {},
       "dependencies": {
         "ajv": {
-          "version": "8.9.0",
-          "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.9.0.tgz",
+          "version": "https://registry.npmjs.org/ajv/-/ajv-8.9.0.tgz",
           "integrity": "sha512-qOKJyNj/h+OWx7s5DePL6Zu1KeM9jPZhwBqs+7DzP6bGOvqzVCSf0xueYmVuaC/oQ/VtS2zLMLHdQFbkka+XDQ==",
           "dev": true,
+          "optional": true,
+          "peer": true,
           "requires": {
             "fast-deep-equal": "^3.1.1",
             "json-schema-traverse": "^1.0.0",
           "version": "1.0.0",
           "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
           "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
-          "dev": true
+          "dev": true,
+          "optional": true,
+          "peer": true
         }
       }
     },
     "commander": {
       "version": "8.3.0",
       "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
-      "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
-      "dev": true
+      "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="
     },
     "commondir": {
       "version": "1.0.1",
         "object.assign": "^4.1.2"
       }
     },
+    "katex": {
+      "version": "0.15.2",
+      "resolved": "https://registry.npmjs.org/katex/-/katex-0.15.2.tgz",
+      "integrity": "sha512-FfZ/f6f8bQdLmJ3McXDNTkKenQkoXkItpW0I9bsG2wgb+8JAY5bwpXFtI8ZVrg5hc1wo1X/UIhdkVMpok46tEQ==",
+      "requires": {
+        "commander": "^8.0.0"
+      }
+    },
     "kind-of": {
       "version": "6.0.3",
       "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
         "uvu": "^0.5.0"
       }
     },
+    "micromark-extension-math": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-2.0.2.tgz",
+      "integrity": "sha512-cFv2B/E4pFPBBFuGgLHkkNiFAIQv08iDgPH2HCuR2z3AUgMLecES5Cq7AVtwOtZeRrbA80QgMUk8VVW0Z+D2FA==",
+      "requires": {
+        "@types/katex": "^0.11.0",
+        "katex": "^0.13.0",
+        "micromark-factory-space": "^1.0.0",
+        "micromark-util-character": "^1.0.0",
+        "micromark-util-symbol": "^1.0.0",
+        "micromark-util-types": "^1.0.0",
+        "uvu": "^0.5.0"
+      },
+      "dependencies": {
+        "katex": {
+          "version": "0.13.24",
+          "resolved": "https://registry.npmjs.org/katex/-/katex-0.13.24.tgz",
+          "integrity": "sha512-jZxYuKCma3VS5UuxOx/rFV1QyGSl3Uy/i0kTJF3HgQ5xMinCQVF8Zd4bMY/9aI9b9A2pjIBOsjSSm68ykTAr8w==",
+          "requires": {
+            "commander": "^8.0.0"
+          }
+        }
+      }
+    },
     "micromark-factory-destination": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.0.0.tgz",
index eb783b913ec67e44a82625538ff7b86463204d03..f790a3e2fc9ab901d878fe1d367e59aea07fbae9 100644 (file)
     "flux": "^4.0.3",
     "formik": "^2.2.9",
     "html-react-parser": "^1.4.11",
+    "katex": "^0.15.2",
     "linkifyjs": "^2.1.9",
     "matrix-js-sdk": "^17.0.0",
     "micromark": "^3.0.10",
     "micromark-extension-gfm": "^2.0.1",
+    "micromark-extension-math": "^2.0.2",
     "micromark-util-chunked": "^1.0.0",
     "micromark-util-resolve-all": "^1.0.0",
     "micromark-util-symbol": "^1.0.1",
diff --git a/src/app/atoms/math/Math.jsx b/src/app/atoms/math/Math.jsx
new file mode 100644 (file)
index 0000000..dcfd021
--- /dev/null
@@ -0,0 +1,33 @@
+import React, { useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+
+import katex from 'katex';
+import 'katex/dist/katex.min.css';
+
+import 'katex/dist/contrib/copy-tex';
+import 'katex/dist/contrib/copy-tex.css';
+
+const Math = React.memo(({
+  content, throwOnError, errorColor, displayMode,
+}) => {
+  const ref = useRef(null);
+
+  useEffect(() => {
+    katex.render(content, ref.current, { throwOnError, errorColor, displayMode });
+  }, [content, throwOnError, errorColor, displayMode]);
+
+  return <span ref={ref} />;
+});
+Math.defaultProps = {
+  throwOnError: null,
+  errorColor: null,
+  displayMode: null,
+};
+Math.propTypes = {
+  content: PropTypes.string.isRequired,
+  throwOnError: PropTypes.bool,
+  errorColor: PropTypes.string,
+  displayMode: PropTypes.bool,
+};
+
+export default Math;
index 07499977ab97f71648111d03ee3377fef5869c8b..70ca87e3f29900afd8fec8feb4d504e1736fac42 100644 (file)
@@ -189,7 +189,7 @@ const MessageBody = React.memo(({
   let content = null;
   if (isCustomHTML) {
     try {
-      content = twemojify(sanitizeCustomHtml(body), undefined, true, false);
+      content = twemojify(sanitizeCustomHtml(body), undefined, true, false, true);
     } catch {
       console.error('Malformed custom html: ', body);
       content = twemojify(body, undefined);
index 1e2fa1921f675855be24cd8bbe7e7cb40dc8a389..4bbd3d88a2729bdc5f6bdb3f1141b1fe542848a9 100644 (file)
@@ -2,8 +2,9 @@ import EventEmitter from 'events';
 import { micromark } from 'micromark';
 import { gfm, gfmHtml } from 'micromark-extension-gfm';
 import encrypt from 'browser-encrypt-attachment';
+import { math } from 'micromark-extension-math';
 import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji';
-import { spoilerExtension, spoilerExtensionHtml } from '../../util/markdown';
+import { mathExtensionHtml, spoilerExtension, spoilerExtensionHtml } from '../../util/markdown';
 import cons from './cons';
 import settings from './settings';
 
@@ -85,8 +86,8 @@ function getVideoThumbnail(video, width, height, mimeType) {
 
 function getFormattedBody(markdown) {
   const result = micromark(markdown, {
-    extensions: [gfm(), spoilerExtension()],
-    htmlExtensions: [gfmHtml(), spoilerExtensionHtml],
+    extensions: [gfm(), spoilerExtension(), math()],
+    htmlExtensions: [gfmHtml(), spoilerExtensionHtml, mathExtensionHtml],
   });
   const bodyParts = result.match(/^(<p>)(.*)(<\/p>)$/);
   if (bodyParts === null) return result;
index 0a6472e9ed9b01de386d2914cec123ae52a56072..2ce613b0443dde058c57e020ccff99762f1bc535 100644 (file)
@@ -140,4 +140,59 @@ const spoilerExtensionHtml = {
   },
 };
 
-export { inlineExtension, spoilerExtension, spoilerExtensionHtml };
+const mathExtensionHtml = {
+  enter: {
+    mathFlow() {
+      this.lineEndingIfNeeded();
+    },
+    mathFlowFenceMeta() {
+      this.buffer();
+    },
+    mathText() {
+      this.buffer();
+    },
+  },
+  exit: {
+    mathFlow() {
+      const value = this.encode(this.resume().replace(/(?:\r?\n|\r)$/, ''));
+      this.tag('<div data-mx-maths="');
+      this.tag(value);
+      this.tag('"><code>');
+      this.raw(value);
+      this.tag('</code></div>');
+      this.setData('mathFlowOpen');
+      this.setData('slurpOneLineEnding');
+    },
+    mathFlowFence() {
+      // After the first fence.
+      if (!this.getData('mathFlowOpen')) {
+        this.setData('mathFlowOpen', true);
+        this.setData('slurpOneLineEnding', true);
+        this.buffer();
+      }
+    },
+    mathFlowFenceMeta() {
+      this.resume();
+    },
+    mathFlowValue(token) {
+      this.raw(this.sliceSerialize(token));
+    },
+    mathText() {
+      const value = this.encode(this.resume());
+      this.tag('<span data-mx-maths="');
+      this.tag(value);
+      this.tag('"><code>');
+      this.raw(value);
+      this.tag('</code></span>');
+    },
+    mathTextData(token) {
+      this.raw(this.sliceSerialize(token));
+    },
+  },
+};
+
+export {
+  inlineExtension,
+  spoilerExtension, spoilerExtensionHtml,
+  mathExtensionHtml,
+};
index 1f1fbfbe7789da37313598832adfc21f1d16968a..3b230523b6000d6b3c5e1d3627ca88b52c0cf235 100644 (file)
@@ -15,7 +15,8 @@ const urlSchemes = ['https', 'http', 'ftp', 'mailto', 'magnet'];
 
 const permittedTagToAttributes = {
   font: ['style', 'data-mx-bg-color', 'data-mx-color', 'color'],
-  span: ['style', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'data-mx-pill', 'data-mx-ping'],
+  span: ['style', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'data-mx-maths', 'data-mx-pill', 'data-mx-ping'],
+  div: ['data-mx-maths'],
   a: ['name', 'target', 'href', 'rel'],
   img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
   o: ['start'],
diff --git a/src/util/twemojify.js b/src/util/twemojify.js
deleted file mode 100644 (file)
index bcef586..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-/* eslint-disable import/prefer-default-export */
-import linkifyHtml from 'linkifyjs/html';
-import parse from 'html-react-parser';
-import twemoji from 'twemoji';
-import { sanitizeText } from './sanitize';
-
-/**
- * @param {string} text - text to twemojify
- * @param {object|undefined} opts - options for tweomoji.parse
- * @param {boolean} [linkify=false] - convert links to html tags (default: false)
- * @param {boolean} [sanitize=true] - sanitize html text (default: true)
- * @returns React component
- */
-export function twemojify(text, opts, linkify = false, sanitize = true) {
-  if (typeof text !== 'string') return text;
-  let content = text;
-
-  if (sanitize) {
-    content = sanitizeText(content);
-  }
-  content = twemoji.parse(content, opts);
-  if (linkify) {
-    content = linkifyHtml(content, {
-      target: '_blank',
-      rel: 'noreferrer noopener',
-    });
-  }
-  return parse(content);
-}
diff --git a/src/util/twemojify.jsx b/src/util/twemojify.jsx
new file mode 100644 (file)
index 0000000..aed8b09
--- /dev/null
@@ -0,0 +1,53 @@
+/* eslint-disable import/prefer-default-export */
+import React, { lazy, Suspense } from 'react';
+
+import linkifyHtml from 'linkifyjs/html';
+import parse from 'html-react-parser';
+import twemoji from 'twemoji';
+import { sanitizeText } from './sanitize';
+
+const Math = lazy(() => import('../app/atoms/math/Math'));
+
+const mathOptions = {
+  replace: (node) => {
+    const maths = node.attribs?.['data-mx-maths'];
+    if (maths) {
+      return (
+        <Suspense fallback={<code>{maths}</code>}>
+          <Math
+            content={maths}
+            throwOnError={false}
+            errorColor="var(--tc-danger-normal)"
+            displayMode={node.name === 'div'}
+          />
+        </Suspense>
+      );
+    }
+    return null;
+  },
+};
+
+/**
+ * @param {string} text - text to twemojify
+ * @param {object|undefined} opts - options for tweomoji.parse
+ * @param {boolean} [linkify=false] - convert links to html tags (default: false)
+ * @param {boolean} [sanitize=true] - sanitize html text (default: true)
+ * @param {boolean} [maths=false] - render maths (default: false)
+ * @returns React component
+ */
+export function twemojify(text, opts, linkify = false, sanitize = true, maths = false) {
+  if (typeof text !== 'string') return text;
+  let content = text;
+
+  if (sanitize) {
+    content = sanitizeText(content);
+  }
+  content = twemoji.parse(content, opts);
+  if (linkify) {
+    content = linkifyHtml(content, {
+      target: '_blank',
+      rel: 'noreferrer noopener',
+    });
+  }
+  return parse(content, maths ? mathOptions : null);
+}