From 5940cf24a0ed86149202496bba4a23ad923134f2 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 9 Oct 2023 22:26:54 +1100 Subject: [PATCH] Inline markdown in editor (#1442) * add inline markdown in editor * send markdown re-generative data in tags * enable vscode format on save * fix match italic and diff order * prevent formatting in code block * make code md rule highest * improve inline markdown parsing * add comment * improve code logic --- src/app/components/editor/output.ts | 43 ++++-- src/app/organisms/room/RoomInput.tsx | 10 +- src/app/organisms/settings/Settings.jsx | 11 +- src/app/utils/markdown.ts | 191 ++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 18 deletions(-) create mode 100644 src/app/utils/markdown.ts diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index 5d0443f..92c86dd 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -2,15 +2,28 @@ import { Descendant, Text } from 'slate'; import { sanitizeText } from '../../utils/sanitize'; import { BlockType } from './Elements'; import { CustomElement, FormattedText } from './slate'; +import { parseInlineMD } from '../../utils/markdown'; -const textToCustomHtml = (node: FormattedText): string => { +export type OutputOptions = { + allowTextFormatting?: boolean; + allowMarkdown?: boolean; +}; + +const textToCustomHtml = (node: FormattedText, opts: OutputOptions): string => { let string = sanitizeText(node.text); - if (node.bold) string = `${string}`; - if (node.italic) string = `${string}`; - if (node.underline) string = `${string}`; - if (node.strikeThrough) string = `${string}`; - if (node.code) string = `${string}`; - if (node.spoiler) string = `${string}`; + if (opts.allowTextFormatting) { + if (node.bold) string = `${string}`; + if (node.italic) string = `${string}`; + if (node.underline) string = `${string}`; + if (node.strikeThrough) string = `${string}`; + if (node.code) string = `${string}`; + if (node.spoiler) string = `${string}`; + } + + if (opts.allowMarkdown && string === sanitizeText(node.text)) { + string = parseInlineMD(string); + } + return string; }; @@ -47,11 +60,19 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => { } }; -export const toMatrixCustomHTML = (node: Descendant | Descendant[]): string => { - if (Array.isArray(node)) return node.map((n) => toMatrixCustomHTML(n)).join(''); - if (Text.isText(node)) return textToCustomHtml(node); +export const toMatrixCustomHTML = ( + node: Descendant | Descendant[], + opts: OutputOptions +): string => { + const parseNode = (n: Descendant) => { + const isCodeLine = 'type' in n && n.type === BlockType.CodeLine; + if (isCodeLine) return toMatrixCustomHTML(n, {}); + return toMatrixCustomHTML(n, opts); + }; + if (Array.isArray(node)) return node.map(parseNode).join(''); + if (Text.isText(node)) return textToCustomHtml(node, opts); - const children = node.children.map((n) => toMatrixCustomHTML(n)).join(''); + const children = node.children.map(parseNode).join(''); return elementToCustomHtml(node, children); }; diff --git a/src/app/organisms/room/RoomInput.tsx b/src/app/organisms/room/RoomInput.tsx index efef03a..7564d5f 100644 --- a/src/app/organisms/room/RoomInput.tsx +++ b/src/app/organisms/room/RoomInput.tsx @@ -108,6 +108,7 @@ export const RoomInput = forwardRef( ({ editor, roomViewRef, roomId }, ref) => { const mx = useMatrixClient(); const room = mx.getRoom(roomId); + const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); @@ -251,7 +252,12 @@ export const RoomInput = forwardRef( uploadBoardHandlers.current?.handleSend(); const plainText = toPlainText(editor.children).trim(); - const customHtml = trimCustomHtml(toMatrixCustomHTML(editor.children)); + const customHtml = trimCustomHtml( + toMatrixCustomHTML(editor.children, { + allowTextFormatting: true, + allowMarkdown: isMarkdown, + }) + ); if (plainText === '') return; @@ -288,7 +294,7 @@ export const RoomInput = forwardRef( resetEditorHistory(editor); setReplyDraft(); sendTypingStatus(false); - }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft]); + }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { diff --git a/src/app/organisms/settings/Settings.jsx b/src/app/organisms/settings/Settings.jsx index fef1586..bd9ce04 100644 --- a/src/app/organisms/settings/Settings.jsx +++ b/src/app/organisms/settings/Settings.jsx @@ -6,7 +6,7 @@ import cons from '../../../client/state/cons'; import settings from '../../../client/state/settings'; import navigation from '../../../client/state/navigation'; import { - toggleSystemTheme, toggleMarkdown, + toggleSystemTheme, toggleNotifications, toggleNotificationSounds, } from '../../../client/action/settings'; import { usePermission } from '../../hooks/usePermission'; @@ -52,6 +52,7 @@ function AppearanceSection() { const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout'); const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing'); const [useSystemEmoji, setUseSystemEmoji] = useSetting(settingsAtom, 'useSystemEmoji'); + const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [hideMembershipEvents, setHideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); @@ -138,14 +139,14 @@ function AppearanceSection() { } /> { toggleMarkdown(); updateState({}); }} + isActive={isMarkdown} + onToggle={() => setIsMarkdown(!isMarkdown) } /> )} - content={Format messages with markdown syntax before sending.} + content={Format messages with inline markdown syntax before sending.} /> string; +export type MatchResult = RegExpMatchArray | RegExpExecArray; +export type RuleMatch = (text: string) => MatchResult | null; +export type MatchConverter = (parse: PlainMDParser, match: MatchResult) => string; + +export type MDRule = { + match: RuleMatch; + html: MatchConverter; +}; + +export type MatchReplacer = ( + parse: PlainMDParser, + text: string, + match: MatchResult, + content: string +) => string; + +export type RuleRunner = (parse: PlainMDParser, text: string, rule: MDRule) => string | undefined; +export type RulesRunner = ( + parse: PlainMDParser, + text: string, + rules: MDRule[] +) => string | undefined; + +const MIN_ANY = '(.+?)'; + +const BOLD_MD_1 = '**'; +const BOLD_PREFIX_1 = '\\*{2}'; +const BOLD_NEG_LA_1 = '(?!\\*)'; +const BOLD_REG_1 = new RegExp(`${BOLD_PREFIX_1}${MIN_ANY}${BOLD_PREFIX_1}${BOLD_NEG_LA_1}`); +const BoldRule: MDRule = { + match: (text) => text.match(BOLD_REG_1), + html: (parse, match) => { + const [, g1] = match; + const child = parse(g1); + return `${child}`; + }, +}; + +const ITALIC_MD_1 = '*'; +const ITALIC_PREFIX_1 = '\\*'; +const ITALIC_NEG_LA_1 = '(?!\\*)'; +const ITALIC_REG_1 = new RegExp(`${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}`); +const ItalicRule1: MDRule = { + match: (text) => text.match(ITALIC_REG_1), + html: (parse, match) => { + const [, g1] = match; + return `${parse(g1)}`; + }, +}; + +const ITALIC_MD_2 = '_'; +const ITALIC_PREFIX_2 = '_'; +const ITALIC_NEG_LA_2 = '(?!_)'; +const ITALIC_REG_2 = new RegExp(`${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}`); +const ItalicRule2: MDRule = { + match: (text) => text.match(ITALIC_REG_2), + html: (parse, match) => { + const [, g1] = match; + return `${parse(g1)}`; + }, +}; + +const UNDERLINE_MD_1 = '__'; +const UNDERLINE_PREFIX_1 = '_{2}'; +const UNDERLINE_NEG_LA_1 = '(?!_)'; +const UNDERLINE_REG_1 = new RegExp( + `${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}` +); +const UnderlineRule: MDRule = { + match: (text) => text.match(UNDERLINE_REG_1), + html: (parse, match) => { + const [, g1] = match; + return `${parse(g1)}`; + }, +}; + +const STRIKE_MD_1 = '~~'; +const STRIKE_PREFIX_1 = '~{2}'; +const STRIKE_NEG_LA_1 = '(?!~)'; +const STRIKE_REG_1 = new RegExp(`${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}`); +const StrikeRule: MDRule = { + match: (text) => text.match(STRIKE_REG_1), + html: (parse, match) => { + const [, g1] = match; + return `${parse(g1)}`; + }, +}; + +const CODE_MD_1 = '`'; +const CODE_PREFIX_1 = '`'; +const CODE_NEG_LA_1 = '(?!`)'; +const CODE_REG_1 = new RegExp(`${CODE_PREFIX_1}${MIN_ANY}${CODE_PREFIX_1}${CODE_NEG_LA_1}`); +const CodeRule: MDRule = { + match: (text) => text.match(CODE_REG_1), + html: (parse, match) => { + const [, g1] = match; + return `${g1}`; + }, +}; + +const SPOILER_MD_1 = '||'; +const SPOILER_PREFIX_1 = '\\|{2}'; +const SPOILER_NEG_LA_1 = '(?!\\|)'; +const SPOILER_REG_1 = new RegExp( + `${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}` +); +const SpoilerRule: MDRule = { + match: (text) => text.match(SPOILER_REG_1), + html: (parse, match) => { + const [, g1] = match; + return `${parse(g1)}`; + }, +}; + +const LINK_ALT = `\\[${MIN_ANY}\\]`; +const LINK_URL = `\\((https?:\\/\\/.+?)\\)`; +const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`); +const LinkRule: MDRule = { + match: (text) => text.match(LINK_REG_1), + html: (parse, match) => { + const [, g1, g2] = match; + return `${parse(g1)}`; + }, +}; + +const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => + text.slice(0, match.index); +const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => + text.slice((match.index ?? 0) + match[0].length); + +const replaceMatch: MatchReplacer = (parse, text, match, content) => + `${parse(beforeMatch(text, match))}${content}${parse(afterMatch(text, match))}`; + +const runRule: RuleRunner = (parse, text, rule) => { + const matchResult = rule.match(text); + if (matchResult) { + const content = rule.html(parse, matchResult); + return replaceMatch(parse, text, matchResult, content); + } + return undefined; +}; + +/** + * Runs multiple rules at the same time to better handle nested rules. + * Rules will be run in the order they appear. + */ +const runRules: RulesRunner = (parse, text, rules) => { + const matchResults = rules.map((rule) => rule.match(text)); + + let targetRule: MDRule | undefined; + let targetResult: MatchResult | undefined; + + for (let i = 0; i < matchResults.length; i += 1) { + const currentResult = matchResults[i]; + if (currentResult && typeof currentResult.index === 'number') { + if ( + !targetResult || + (typeof targetResult?.index === 'number' && currentResult.index < targetResult.index) + ) { + targetResult = currentResult; + targetRule = rules[i]; + } + } + } + + if (targetRule && targetResult) { + const content = targetRule.html(parse, targetResult); + return replaceMatch(parse, text, targetResult, content); + } + return undefined; +}; + +const LeveledRules = [ + BoldRule, + ItalicRule1, + UnderlineRule, + ItalicRule2, + StrikeRule, + SpoilerRule, + LinkRule, +]; + +export const parseInlineMD = (text: string): string => { + let result: string | undefined; + if (!result) result = runRule(parseInlineMD, text, CodeRule); + + if (!result) result = runRules(parseInlineMD, text, LeveledRules); + + return result ?? text; +};