diff --git a/package-lock.json b/package-lock.json index 90312a8..8acb3d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,9 @@ "matrix-js-sdk": "^15.4.0", "micromark": "^3.0.3", "micromark-extension-gfm": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-symbol": "^1.0.1", "prop-types": "^15.8.1", "react": "^17.0.2", "react-autosize-textarea": "^7.1.0", @@ -3003,6 +3006,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", @@ -3018,7 +3023,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", @@ -9989,9 +9996,9 @@ } }, "node_modules/micromark-util-symbol": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.0.0.tgz", - "integrity": "sha512-NZA01jHRNCt4KlOROn8/bGi6vvpEmlXld7EHcRH+aYWUfL3Wc8JLUNNlqUMKa0hhz6GrpUWsHtzPmKof57v0gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.0.1.tgz", + "integrity": "sha512-oKDEMK2u5qqAptasDAwWDXq0tG9AssVwAx3E9bBF3t/shRIGsWIRG+cGafs2p/SnDSOecnt6hZPCE2o6lHfFmQ==", "funding": [ { "type": "GitHub Sponsors", @@ -10001,7 +10008,8 @@ "type": "OpenCollective", "url": "https://opencollective.com/unified" } - ] + ], + "license": "MIT" }, "node_modules/micromark-util-types": { "version": "1.0.1", @@ -18336,15 +18344,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", @@ -18356,7 +18363,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 } } }, @@ -23789,9 +23798,9 @@ } }, "micromark-util-symbol": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.0.0.tgz", - "integrity": "sha512-NZA01jHRNCt4KlOROn8/bGi6vvpEmlXld7EHcRH+aYWUfL3Wc8JLUNNlqUMKa0hhz6GrpUWsHtzPmKof57v0gQ==" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.0.1.tgz", + "integrity": "sha512-oKDEMK2u5qqAptasDAwWDXq0tG9AssVwAx3E9bBF3t/shRIGsWIRG+cGafs2p/SnDSOecnt6hZPCE2o6lHfFmQ==" }, "micromark-util-types": { "version": "1.0.1", diff --git a/package.json b/package.json index cef77b9..011555c 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "matrix-js-sdk": "^15.4.0", "micromark": "^3.0.3", "micromark-extension-gfm": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-symbol": "^1.0.1", "prop-types": "^15.8.1", "react": "^17.0.2", "react-autosize-textarea": "^7.1.0", diff --git a/src/client/state/RoomsInput.js b/src/client/state/RoomsInput.js index 1f0f8a4..bc01f22 100644 --- a/src/client/state/RoomsInput.js +++ b/src/client/state/RoomsInput.js @@ -3,6 +3,7 @@ import { micromark } from 'micromark'; import { gfm, gfmHtml } from 'micromark-extension-gfm'; import encrypt from 'browser-encrypt-attachment'; import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji'; +import { spoilerExtension, spoilerExtensionHtml } from '../../util/markdown'; import cons from './cons'; import settings from './settings'; @@ -84,8 +85,8 @@ function getVideoThumbnail(video, width, height, mimeType) { function getFormattedBody(markdown) { const result = micromark(markdown, { - extensions: [gfm()], - htmlExtensions: [gfmHtml], + extensions: [gfm(), spoilerExtension()], + htmlExtensions: [gfmHtml, spoilerExtensionHtml], }); const bodyParts = result.match(/^(

)(.*)(<\/p>)$/); if (bodyParts === null) return result; @@ -406,7 +407,7 @@ class RoomsInput extends EventEmitter { // Apply formatting if relevant const formattedBody = formatAndEmojifyText( this.matrixClient.getRoom(roomId), - editedBody + editedBody, ); if (formattedBody !== editedBody) { content.formatted_body = ` * ${formattedBody}`; diff --git a/src/util/markdown.js b/src/util/markdown.js new file mode 100644 index 0000000..0a6472e --- /dev/null +++ b/src/util/markdown.js @@ -0,0 +1,143 @@ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-plusplus */ +/* eslint-disable no-continue */ + +import { codes } from 'micromark-util-symbol/codes'; +import { types } from 'micromark-util-symbol/types'; +import { resolveAll } from 'micromark-util-resolve-all'; +import { splice } from 'micromark-util-chunked'; + +function inlineExtension(marker, len, key) { + const keySeq = `${key}Sequence`; + const keySeqTmp = `${keySeq}Temporary`; + + return () => { + function tokenize(effects, ok, nok) { + const { previous, events } = this; + + let size = 0; + + function more(code) { + // consume more markers if the maximum length hasn't been reached yet + if (code === marker && size < len) { + effects.consume(code); + size += 1; + return more; + } + + // check for minimum length + if (size < len) return nok(code); + + effects.exit(keySeqTmp); + return ok(code); + } + + function start(code) { + // ignore code if it's not a marker + if (code !== marker) return nok(code); + + if (previous === marker + && events[events.length - 1][1].type !== types.characterEscape) return nok(code); + + effects.enter(keySeqTmp); + return more(code); + } + + return start; + } + + function resolve(events, context) { + let i = -1; + + while (++i < events.length) { + if (events[i][0] !== 'enter' || events[i][1].type !== keySeqTmp) continue; + + let open = i; + while (open--) { + if (events[open][0] !== 'exit' || events[open][1].type !== keySeqTmp) continue; + + events[i][1].type = keySeq; + events[open][1].type = keySeq; + + const border = { + type: key, + start: { ...events[open][1].start }, + end: { ...events[i][1].end }, + }; + + const text = { + type: `${key}Text`, + start: { ...events[open][1].end }, + end: { ...events[i][1].start }, + }; + + const nextEvents = [ + ['enter', border, context], + ['enter', events[open][1], context], + ['exit', events[open][1], context], + ['enter', text, context], + ]; + + splice( + nextEvents, + nextEvents.length, + 0, + resolveAll( + context.parser.constructs.insideSpan.null, + events.slice(open + 1, i), + context, + ), + ); + + splice(nextEvents, nextEvents.length, 0, [ + ['exit', text, context], + ['enter', events[i][1], context], + ['exit', events[i][1], context], + ['exit', border, context], + ]); + + splice(events, open - 1, i - open + 3, nextEvents); + + i = open + nextEvents.length - 2; + break; + } + } + + events.forEach((event) => { + if (event[1].type === keySeqTmp) { + event[1].type = types.data; + } + }); + + return events; + } + + const tokenizer = { + tokenize, + resolveAll: resolve, + }; + + return { + text: { [marker]: tokenizer }, + insideSpan: { null: [tokenizer] }, + attentionMarkers: { null: [marker] }, + }; + }; +} + +const spoilerExtension = inlineExtension(codes.verticalBar, 2, 'spoiler'); + +const spoilerExtensionHtml = { + enter: { + spoiler() { + this.tag(''); + }, + }, + exit: { + spoiler() { + this.tag(''); + }, + }, +}; + +export { inlineExtension, spoilerExtension, spoilerExtensionHtml };