From b7c5902f67e0a179840c61de19e60dcf78b00316 Mon Sep 17 00:00:00 2001 From: ginnyTheCat Date: Sun, 24 Apr 2022 17:48:35 +0200 Subject: [PATCH] Add LaTeX / math input and rendering support (#345) * 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 | 116 +++++++++++++++++++++-- package.json | 2 + src/app/atoms/math/Math.jsx | 33 +++++++ src/app/molecules/message/Message.jsx | 2 +- src/client/state/RoomsInput.js | 7 +- src/util/markdown.js | 57 ++++++++++- src/util/sanitize.js | 3 +- src/util/{twemojify.js => twemojify.jsx} | 28 +++++- 8 files changed, 230 insertions(+), 18 deletions(-) create mode 100644 src/app/atoms/math/Math.jsx rename src/util/{twemojify.js => twemojify.jsx} (56%) diff --git a/package-lock.json b/package-lock.json index c028f04..14063ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,10 +21,12 @@ "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", @@ -2585,6 +2587,11 @@ "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", @@ -2945,6 +2952,8 @@ "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", @@ -2960,7 +2969,9 @@ "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", @@ -4170,7 +4181,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "dev": true, "engines": { "node": ">= 12" } @@ -8359,6 +8369,21 @@ "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", @@ -9095,6 +9120,40 @@ "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", @@ -15793,6 +15852,11 @@ "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", @@ -16107,15 +16171,14 @@ "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", @@ -16127,7 +16190,9 @@ "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 } } }, @@ -17121,8 +17186,7 @@ "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", @@ -20305,6 +20369,14 @@ "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", @@ -20922,6 +20994,30 @@ "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", diff --git a/package.json b/package.json index eb783b9..f790a3e 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,12 @@ "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 index 0000000..dcfd021 --- /dev/null +++ b/src/app/atoms/math/Math.jsx @@ -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 ; +}); +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; diff --git a/src/app/molecules/message/Message.jsx b/src/app/molecules/message/Message.jsx index 0749997..70ca87e 100644 --- a/src/app/molecules/message/Message.jsx +++ b/src/app/molecules/message/Message.jsx @@ -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); diff --git a/src/client/state/RoomsInput.js b/src/client/state/RoomsInput.js index 1e2fa19..4bbd3d8 100644 --- a/src/client/state/RoomsInput.js +++ b/src/client/state/RoomsInput.js @@ -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>)$/); if (bodyParts === null) return result; diff --git a/src/util/markdown.js b/src/util/markdown.js index 0a6472e..2ce613b 100644 --- a/src/util/markdown.js +++ b/src/util/markdown.js @@ -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('

'); + this.raw(value); + this.tag('
'); + 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(''); + this.raw(value); + this.tag(''); + }, + mathTextData(token) { + this.raw(this.sliceSerialize(token)); + }, + }, +}; + +export { + inlineExtension, + spoilerExtension, spoilerExtensionHtml, + mathExtensionHtml, +}; diff --git a/src/util/sanitize.js b/src/util/sanitize.js index 1f1fbfb..3b23052 100644 --- a/src/util/sanitize.js +++ b/src/util/sanitize.js @@ -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.jsx similarity index 56% rename from src/util/twemojify.js rename to src/util/twemojify.jsx index bcef586..aed8b09 100644 --- a/src/util/twemojify.js +++ b/src/util/twemojify.jsx @@ -1,17 +1,41 @@ /* 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 ( + {maths}}> + + + ); + } + 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) { +export function twemojify(text, opts, linkify = false, sanitize = true, maths = false) { if (typeof text !== 'string') return text; let content = text; @@ -25,5 +49,5 @@ export function twemojify(text, opts, linkify = false, sanitize = true) { rel: 'noreferrer noopener', }); } - return parse(content); + return parse(content, maths ? mathOptions : null); }