From b24f858369578a496a20fe47c89762f2711403cd Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Fri, 27 Oct 2023 21:27:22 +1100 Subject: [PATCH] Improve Editor related bugs and add multiline md (#1507) * remove shift from editor hotkeys * fix inline markdown not working * add block md parser - WIP * emojify and linkify text without react-parser * no need to sanitize text when emojify * parse block markdown in editor output - WIP * add inline parser option in block md parser * improve codeblock regex * ignore html tag when parsing inline md in block md * add list markdown rule in block parser * re-generate block markdown on edit * change copy from inline markdown to markdown * fix trim reply from body regex * fix jumbo emoji in reply message * fix broken list regex in block markdown * enable markdown by defualt --- src/app/components/editor/Toolbar.tsx | 19 +- src/app/components/editor/input.ts | 151 +++++++--- src/app/components/editor/keyboard.ts | 12 +- src/app/components/editor/output.ts | 41 ++- src/app/components/message/Reply.tsx | 9 +- src/app/organisms/room/RoomInput.tsx | 3 +- src/app/organisms/room/RoomTimeline.tsx | 3 +- .../organisms/room/message/MessageEditor.tsx | 3 +- src/app/organisms/room/message/styles.css.ts | 2 +- src/app/organisms/settings/Settings.jsx | 4 +- src/app/plugins/react-custom-html-parser.tsx | 46 +-- src/app/state/settings.ts | 2 +- src/app/utils/markdown.ts | 277 ++++++++++++++---- src/app/utils/room.ts | 2 +- src/app/utils/sanitize.ts | 11 +- 15 files changed, 425 insertions(+), 160 deletions(-) diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx index 6feae00..5d5e989 100644 --- a/src/app/components/editor/Toolbar.tsx +++ b/src/app/components/editor/Toolbar.tsx @@ -148,7 +148,7 @@ export function HeadingBlockButton() { } + tooltip={} delay={500} > {(triggerRef) => ( @@ -163,7 +163,7 @@ export function HeadingBlockButton() { )} } + tooltip={} delay={500} > {(triggerRef) => ( @@ -178,7 +178,7 @@ export function HeadingBlockButton() { )} } + tooltip={} delay={500} > {(triggerRef) => ( @@ -277,12 +277,7 @@ export function Toolbar() { - } + tooltip={} /> } + tooltip={} /> } + tooltip={} /> @@ -335,7 +330,7 @@ export function Toolbar() { } + tooltip={} delay={500} > {(triggerRef) => ( diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts index 5860df0..272b970 100644 --- a/src/app/components/editor/input.ts +++ b/src/app/components/editor/input.ts @@ -13,11 +13,9 @@ import { HeadingElement, HeadingLevel, InlineElement, - ListItemElement, MentionElement, OrderedListElement, ParagraphElement, - QuoteLineElement, UnorderedListElement, } from './slate'; import { parseMatrixToUrl } from '../../utils/matrix'; @@ -117,17 +115,14 @@ const parseInlineNodes = (node: ChildNode): InlineElement[] => { return []; }; -const parseBlockquoteNode = (node: Element): BlockQuoteElement => { - const children: QuoteLineElement[] = []; +const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElement[] => { + const quoteLines: Array = []; let lineHolder: InlineElement[] = []; const appendLine = () => { if (lineHolder.length === 0) return; - children.push({ - type: BlockType.QuoteLine, - children: lineHolder, - }); + quoteLines.push(lineHolder); lineHolder = []; }; @@ -145,10 +140,7 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement => { if (child.name === 'p') { appendLine(); - children.push({ - type: BlockType.QuoteLine, - children: child.children.flatMap((c) => parseInlineNodes(c)), - }); + quoteLines.push(child.children.flatMap((c) => parseInlineNodes(c))); return; } @@ -157,42 +149,71 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement => { }); appendLine(); - return { - type: BlockType.BlockQuote, - children, - }; -}; -const parseCodeBlockNode = (node: Element): CodeBlockElement => { - const children: CodeLineElement[] = []; + if (node.attribs['data-md'] !== undefined) { + return quoteLines.map((lineChildren) => ({ + type: BlockType.Paragraph, + children: [{ text: `${node.attribs['data-md']} ` }, ...lineChildren], + })); + } - const code = parseNodeText(node).trim(); - code.split('\n').forEach((lineTxt) => - children.push({ - type: BlockType.CodeLine, + return [ + { + type: BlockType.BlockQuote, + children: quoteLines.map((lineChildren) => ({ + type: BlockType.QuoteLine, + children: lineChildren, + })), + }, + ]; +}; +const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => { + const codeLines = parseNodeText(node).trim().split('\n'); + + if (node.attribs['data-md'] !== undefined) { + const pLines = codeLines.map((lineText) => ({ + type: BlockType.Paragraph, children: [ { - text: lineTxt, + text: lineText, }, ], - }) - ); + })); + const childCode = node.children[0]; + const className = + isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : ''; + const prefix = { text: `${node.attribs['data-md']}${className.replace('language-', '')}` }; + const suffix = { text: node.attribs['data-md'] }; + return [ + { type: BlockType.Paragraph, children: [prefix] }, + ...pLines, + { type: BlockType.Paragraph, children: [suffix] }, + ]; + } - return { - type: BlockType.CodeBlock, - children, - }; + return [ + { + type: BlockType.CodeBlock, + children: codeLines.map((lineTxt) => ({ + type: BlockType.CodeLine, + children: [ + { + text: lineTxt, + }, + ], + })), + }, + ]; }; -const parseListNode = (node: Element): OrderedListElement | UnorderedListElement => { - const children: ListItemElement[] = []; +const parseListNode = ( + node: Element +): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => { + const listLines: Array = []; let lineHolder: InlineElement[] = []; const appendLine = () => { if (lineHolder.length === 0) return; - children.push({ - type: BlockType.ListItem, - children: lineHolder, - }); + listLines.push(lineHolder); lineHolder = []; }; @@ -210,10 +231,7 @@ const parseListNode = (node: Element): OrderedListElement | UnorderedListElement if (child.name === 'li') { appendLine(); - children.push({ - type: BlockType.ListItem, - children: child.children.flatMap((c) => parseInlineNodes(c)), - }); + listLines.push(child.children.flatMap((c) => parseInlineNodes(c))); return; } @@ -222,17 +240,54 @@ const parseListNode = (node: Element): OrderedListElement | UnorderedListElement }); appendLine(); - return { - type: node.name === 'ol' ? BlockType.OrderedList : BlockType.UnorderedList, - children, - }; + if (node.attribs['data-md'] !== undefined) { + const prefix = node.attribs['data-md'] || '-'; + const [starOrHyphen] = prefix.match(/^\*|-$/) ?? []; + return listLines.map((lineChildren) => ({ + type: BlockType.Paragraph, + children: [ + { text: `${starOrHyphen ? `${starOrHyphen} ` : `${prefix}. `} ` }, + ...lineChildren, + ], + })); + } + + if (node.name === 'ol') { + return [ + { + type: BlockType.OrderedList, + children: listLines.map((lineChildren) => ({ + type: BlockType.ListItem, + children: lineChildren, + })), + }, + ]; + } + + return [ + { + type: BlockType.UnorderedList, + children: listLines.map((lineChildren) => ({ + type: BlockType.ListItem, + children: lineChildren, + })), + }, + ]; }; -const parseHeadingNode = (node: Element): HeadingElement => { +const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => { const children = node.children.flatMap((child) => parseInlineNodes(child)); const headingMatch = node.name.match(/^h([123456])$/); const [, g1AsLevel] = headingMatch ?? ['h3', '3']; const level = parseInt(g1AsLevel, 10); + + if (node.attribs['data-md'] !== undefined) { + return { + type: BlockType.Paragraph, + children: [{ text: `${node.attribs['data-md']} ` }, ...children], + }; + } + return { type: BlockType.Heading, level: (level <= 3 ? level : 3) as HeadingLevel, @@ -278,17 +333,17 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => { if (node.name === 'blockquote') { appendLine(); - children.push(parseBlockquoteNode(node)); + children.push(...parseBlockquoteNode(node)); return; } if (node.name === 'pre') { appendLine(); - children.push(parseCodeBlockNode(node)); + children.push(...parseCodeBlockNode(node)); return; } if (node.name === 'ol' || node.name === 'ul') { appendLine(); - children.push(parseListNode(node)); + children.push(...parseListNode(node)); return; } diff --git a/src/app/components/editor/keyboard.ts b/src/app/components/editor/keyboard.ts index 370f3e8..19c05ba 100644 --- a/src/app/components/editor/keyboard.ts +++ b/src/app/components/editor/keyboard.ts @@ -8,22 +8,22 @@ export const INLINE_HOTKEYS: Record = { 'mod+b': MarkType.Bold, 'mod+i': MarkType.Italic, 'mod+u': MarkType.Underline, - 'mod+shift+u': MarkType.StrikeThrough, + 'mod+s': MarkType.StrikeThrough, 'mod+[': MarkType.Code, 'mod+h': MarkType.Spoiler, }; const INLINE_KEYS = Object.keys(INLINE_HOTKEYS); export const BLOCK_HOTKEYS: Record = { - 'mod+shift+7': BlockType.OrderedList, - 'mod+shift+8': BlockType.UnorderedList, + 'mod+7': BlockType.OrderedList, + 'mod+8': BlockType.UnorderedList, "mod+'": BlockType.BlockQuote, 'mod+;': BlockType.CodeBlock, }; const BLOCK_KEYS = Object.keys(BLOCK_HOTKEYS); -const isHeading1 = isKeyHotkey('mod+shift+1'); -const isHeading2 = isKeyHotkey('mod+shift+2'); -const isHeading3 = isKeyHotkey('mod+shift+3'); +const isHeading1 = isKeyHotkey('mod+1'); +const isHeading2 = isKeyHotkey('mod+2'); +const isHeading3 = isKeyHotkey('mod+3'); /** * @return boolean true if shortcut is toggled. diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index 307ef8a..fa15bb5 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -3,11 +3,12 @@ import { Descendant, Text } from 'slate'; import { sanitizeText } from '../../utils/sanitize'; import { BlockType } from './types'; import { CustomElement } from './slate'; -import { parseInlineMD } from '../../utils/markdown'; +import { parseBlockMD, parseInlineMD, replaceMatch } from '../../utils/markdown'; export type OutputOptions = { allowTextFormatting?: boolean; - allowMarkdown?: boolean; + allowInlineMarkdown?: boolean; + allowBlockMarkdown?: boolean; }; const textToCustomHtml = (node: Text, opts: OutputOptions): string => { @@ -21,7 +22,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => { if (node.spoiler) string = `${string}`; } - if (opts.allowMarkdown && string === sanitizeText(node.text)) { + if (opts.allowInlineMarkdown && string === sanitizeText(node.text)) { string = parseInlineMD(string); } @@ -64,14 +65,42 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => { } }; +const HTML_TAG_REG = /<([a-z]+)(?![^>]*\/>)[^<]*<\/\1>/; +const ignoreHTMLParseInlineMD = (text: string): string => { + if (text === '') return text; + const match = text.match(HTML_TAG_REG); + if (!match) return parseInlineMD(text); + const [matchedTxt] = match; + return replaceMatch((txt) => [ignoreHTMLParseInlineMD(txt)], text, match, matchedTxt).join(''); +}; + export const toMatrixCustomHTML = ( node: Descendant | Descendant[], opts: OutputOptions ): string => { - const parseNode = (n: Descendant) => { + let markdownLines = ''; + const parseNode = (n: Descendant, index: number, targetNodes: Descendant[]) => { + if (opts.allowBlockMarkdown && 'type' in n && n.type === BlockType.Paragraph) { + const line = toMatrixCustomHTML(n, { + ...opts, + allowInlineMarkdown: false, + allowBlockMarkdown: false, + }) + .replace(/$/, '\n') + .replace(/^>/, '>'); + markdownLines += line; + if (index === targetNodes.length - 1) { + return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD); + } + return ''; + } + + const parsedMarkdown = parseBlockMD(markdownLines, ignoreHTMLParseInlineMD); + markdownLines = ''; const isCodeLine = 'type' in n && n.type === BlockType.CodeLine; - if (isCodeLine) return toMatrixCustomHTML(n, {}); - return toMatrixCustomHTML(n, opts); + if (isCodeLine) return `${parsedMarkdown}${toMatrixCustomHTML(n, {})}`; + + return `${parsedMarkdown}${toMatrixCustomHTML(n, { ...opts, allowBlockMarkdown: false })}`; }; if (Array.isArray(node)) return node.map(parseNode).join(''); if (Text.isText(node)) return textToCustomHtml(node, opts); diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 6eaab31..c9b6b8d 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -59,6 +59,9 @@ export const Reply = as<'div', ReplyProps>( }; }, [replyEvent, mx, room, eventId]); + const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted'; + const bodyJSX = body ? trimReplyFromBody(body) : fallbackBody; + return ( ( {replyEvent !== undefined ? ( - {replyEvent?.getContent().msgtype === 'm.bad.encrypted' ? ( - - ) : ( - (body && trimReplyFromBody(body)) ?? fallbackBody - )} + {badEncryption ? : bodyJSX} ) : ( ( let customHtml = trimCustomHtml( toMatrixCustomHTML(editor.children, { allowTextFormatting: true, - allowMarkdown: isMarkdown, + allowBlockMarkdown: isMarkdown, + allowInlineMarkdown: isMarkdown, }) ); let msgType = MsgType.Text; diff --git a/src/app/organisms/room/RoomTimeline.tsx b/src/app/organisms/room/RoomTimeline.tsx index 2d38245..c1b0445 100644 --- a/src/app/organisms/room/RoomTimeline.tsx +++ b/src/app/organisms/room/RoomTimeline.tsx @@ -89,6 +89,7 @@ import { getReactionContent, isMembershipChanged, reactionOrEditEvent, + trimReplyFromBody, } from '../../utils/room'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; @@ -999,7 +1000,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent(); if (typeof body !== 'string') return null; - const jumboEmoji = JUMBO_EMOJI_REG.test(body); + const jumboEmoji = JUMBO_EMOJI_REG.test(trimReplyFromBody(body)); return ( ( const customHtml = trimCustomHtml( toMatrixCustomHTML(editor.children, { allowTextFormatting: true, - allowMarkdown: isMarkdown, + allowBlockMarkdown: isMarkdown, + allowInlineMarkdown: isMarkdown, }) ); diff --git a/src/app/organisms/room/message/styles.css.ts b/src/app/organisms/room/message/styles.css.ts index a5f2f6b..801f698 100644 --- a/src/app/organisms/room/message/styles.css.ts +++ b/src/app/organisms/room/message/styles.css.ts @@ -81,5 +81,5 @@ export const ReactionsContainer = style({ }); export const ReactionsTooltipText = style({ - wordBreak: 'break-all', + wordBreak: 'break-word', }); diff --git a/src/app/organisms/settings/Settings.jsx b/src/app/organisms/settings/Settings.jsx index 2b706ed..1b04669 100644 --- a/src/app/organisms/settings/Settings.jsx +++ b/src/app/organisms/settings/Settings.jsx @@ -152,14 +152,14 @@ function AppearanceSection() { content={{`Use ${isMacOS() ? KeySymbol.Command : 'Ctrl'} + ENTER to send message and ENTER for newline.`}} /> setIsMarkdown(!isMarkdown) } /> )} - content={Format messages with inline markdown syntax before sending.} + content={Format messages with markdown syntax before sending.} /> import('./react-prism/ReactPrism')); -const EMOJI_REG = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g'); +const EMOJI_REG = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`); export const LINKIFY_OPTS: LinkifyOpts = { attributes: { @@ -35,25 +35,31 @@ export const LINKIFY_OPTS: LinkifyOpts = { ignoreTags: ['span'], }; -const emojifyParserOptions: HTMLReactParserOptions = { - replace: (domNode) => { - if (domNode instanceof DOMText) { - return {domNode.data}; - } - return undefined; - }, +const stringToEmojifyJSX = (text: string): (string | JSX.Element)[] => { + const match = text.match(EMOJI_REG); + if (!match) return [text]; + + const [emoji] = match; + + return replaceMatch( + stringToEmojifyJSX, + text, + match, + + + {emoji} + + + ); }; -export const emojifyAndLinkify = (unsafeText: string, linkify?: boolean) => { - const emojifyHtml = sanitizeText(unsafeText).replace( - EMOJI_REG, - (emoji) => - `${emoji}` - ); +export const emojifyAndLinkify = (text: string, linkify?: boolean) => { + const emojifyJSX = stringToEmojifyJSX(text); - return <>{parse(emojifyHtml, linkify ? emojifyParserOptions : undefined)}; + if (linkify) { + return {emojifyJSX}; + } + return emojifyJSX; }; export const getReactCustomHtmlParser = ( @@ -171,6 +177,8 @@ export const getReactCustomHtmlParser = ( if (typeof codeReact === 'string') { let lang = props.className; if (lang === 'language-rs') lang = 'language-rust'; + else if (lang === 'language-js') lang = 'language-javascript'; + else if (lang === 'language-ts') lang = 'language-typescript'; return ( {codeReact}}> {codeReact}}> diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 3a7832c..4393b64 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -28,7 +28,7 @@ export interface Settings { const defaultSettings: Settings = { themeIndex: 0, useSystemTheme: true, - isMarkdown: false, + isMarkdown: true, editorToolbar: false, twitterEmoji: false, diff --git a/src/app/utils/markdown.ts b/src/app/utils/markdown.ts index 9fda679..c6bb391 100644 --- a/src/app/utils/markdown.ts +++ b/src/app/utils/markdown.ts @@ -1,25 +1,46 @@ -export type PlainMDParser = (text: string) => 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 const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => + text.slice(0, match.index); +export const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => + text.slice((match.index ?? 0) + match[0].length); -export type MatchReplacer = ( - parse: PlainMDParser, +export const replaceMatch = ( + convertPart: (txt: string) => Array, text: string, match: MatchResult, - content: string -) => string; + content: C +): Array => [ + ...convertPart(beforeMatch(text, match)), + content, + ...convertPart(afterMatch(text, match)), +]; -export type RuleRunner = (parse: PlainMDParser, text: string, rule: MDRule) => string | undefined; -export type RulesRunner = ( - parse: PlainMDParser, +/* + ***************** + * INLINE PARSER * + ***************** + */ + +export type InlineMDParser = (text: string) => string; + +export type InlineMatchConverter = (parse: InlineMDParser, match: MatchResult) => string; + +export type InlineMDRule = { + match: RuleMatch; + html: InlineMatchConverter; +}; + +export type InlineRuleRunner = ( + parse: InlineMDParser, text: string, - rules: MDRule[] + rule: InlineMDRule +) => string | undefined; +export type InlineRulesRunner = ( + parse: InlineMDParser, + text: string, + rules: InlineMDRule[] ) => string | undefined; const MIN_ANY = '(.+?)'; @@ -31,11 +52,11 @@ const BOLD_NEG_LA_1 = '(?!\\*)'; const BOLD_REG_1 = new RegExp( `${URL_NEG_LB}${BOLD_PREFIX_1}${MIN_ANY}${BOLD_PREFIX_1}${BOLD_NEG_LA_1}` ); -const BoldRule: MDRule = { +const BoldRule: InlineMDRule = { match: (text) => text.match(BOLD_REG_1), html: (parse, match) => { - const [, g1] = match; - return `${parse(g1)}`; + const [, , g2] = match; + return `${parse(g2)}`; }, }; @@ -45,11 +66,11 @@ const ITALIC_NEG_LA_1 = '(?!\\*)'; const ITALIC_REG_1 = new RegExp( `${URL_NEG_LB}${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}` ); -const ItalicRule1: MDRule = { +const ItalicRule1: InlineMDRule = { match: (text) => text.match(ITALIC_REG_1), html: (parse, match) => { - const [, g1] = match; - return `${parse(g1)}`; + const [, , g2] = match; + return `${parse(g2)}`; }, }; @@ -59,11 +80,11 @@ const ITALIC_NEG_LA_2 = '(?!_)'; const ITALIC_REG_2 = new RegExp( `${URL_NEG_LB}${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}` ); -const ItalicRule2: MDRule = { +const ItalicRule2: InlineMDRule = { match: (text) => text.match(ITALIC_REG_2), html: (parse, match) => { - const [, g1] = match; - return `${parse(g1)}`; + const [, , g2] = match; + return `${parse(g2)}`; }, }; @@ -73,11 +94,11 @@ const UNDERLINE_NEG_LA_1 = '(?!_)'; const UNDERLINE_REG_1 = new RegExp( `${URL_NEG_LB}${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}` ); -const UnderlineRule: MDRule = { +const UnderlineRule: InlineMDRule = { match: (text) => text.match(UNDERLINE_REG_1), html: (parse, match) => { - const [, g1] = match; - return `${parse(g1)}`; + const [, , g2] = match; + return `${parse(g2)}`; }, }; @@ -87,25 +108,23 @@ const STRIKE_NEG_LA_1 = '(?!~)'; const STRIKE_REG_1 = new RegExp( `${URL_NEG_LB}${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}` ); -const StrikeRule: MDRule = { +const StrikeRule: InlineMDRule = { match: (text) => text.match(STRIKE_REG_1), html: (parse, match) => { - const [, g1] = match; - return `${parse(g1)}`; + const [, , g2] = match; + return `${parse(g2)}`; }, }; const CODE_MD_1 = '`'; const CODE_PREFIX_1 = '`'; const CODE_NEG_LA_1 = '(?!`)'; -const CODE_REG_1 = new RegExp( - `${URL_NEG_LB}${CODE_PREFIX_1}${MIN_ANY}${CODE_PREFIX_1}${CODE_NEG_LA_1}` -); -const CodeRule: MDRule = { +const CODE_REG_1 = new RegExp(`${URL_NEG_LB}${CODE_PREFIX_1}(.+?)${CODE_PREFIX_1}${CODE_NEG_LA_1}`); +const CodeRule: InlineMDRule = { match: (text) => text.match(CODE_REG_1), html: (parse, match) => { - const [, g1] = match; - return `${g1}`; + const [, , g2] = match; + return `${g2}`; }, }; @@ -115,18 +134,18 @@ const SPOILER_NEG_LA_1 = '(?!\\|)'; const SPOILER_REG_1 = new RegExp( `${URL_NEG_LB}${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}` ); -const SpoilerRule: MDRule = { +const SpoilerRule: InlineMDRule = { match: (text) => text.match(SPOILER_REG_1), html: (parse, match) => { - const [, g1] = match; - return `${parse(g1)}`; + const [, , g2] = match; + return `${parse(g2)}`; }, }; const LINK_ALT = `\\[${MIN_ANY}\\]`; const LINK_URL = `\\((https?:\\/\\/.+?)\\)`; const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`); -const LinkRule: MDRule = { +const LinkRule: InlineMDRule = { match: (text) => text.match(LINK_REG_1), html: (parse, match) => { const [, g1, g2] = match; @@ -134,19 +153,11 @@ const LinkRule: MDRule = { }, }; -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 runInlineRule: InlineRuleRunner = (parse, text, rule) => { const matchResult = rule.match(text); if (matchResult) { const content = rule.html(parse, matchResult); - return replaceMatch(parse, text, matchResult, content); + return replaceMatch((txt) => [parse(txt)], text, matchResult, content).join(''); } return undefined; }; @@ -155,10 +166,10 @@ const runRule: RuleRunner = (parse, text, rule) => { * 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 runInlineRules: InlineRulesRunner = (parse, text, rules) => { const matchResults = rules.map((rule) => rule.match(text)); - let targetRule: MDRule | undefined; + let targetRule: InlineMDRule | undefined; let targetResult: MatchResult | undefined; for (let i = 0; i < matchResults.length; i += 1) { @@ -176,7 +187,7 @@ const runRules: RulesRunner = (parse, text, rules) => { if (targetRule && targetResult) { const content = targetRule.html(parse, targetResult); - return replaceMatch(parse, text, targetResult, content); + return replaceMatch((txt) => [parse(txt)], text, targetResult, content).join(''); } return undefined; }; @@ -191,11 +202,167 @@ const LeveledRules = [ LinkRule, ]; -export const parseInlineMD = (text: string): string => { +export const parseInlineMD: InlineMDParser = (text) => { + if (text === '') return text; let result: string | undefined; - if (!result) result = runRule(parseInlineMD, text, CodeRule); + if (!result) result = runInlineRule(parseInlineMD, text, CodeRule); - if (!result) result = runRules(parseInlineMD, text, LeveledRules); + if (!result) result = runInlineRules(parseInlineMD, text, LeveledRules); + + return result ?? text; +}; + +/* + **************** + * BLOCK PARSER * + **************** + */ + +export type BlockMDParser = (test: string, parseInline?: (txt: string) => string) => string; + +export type BlockMatchConverter = ( + match: MatchResult, + parseInline?: (txt: string) => string +) => string; + +export type BlockMDRule = { + match: RuleMatch; + html: BlockMatchConverter; +}; + +export type BlockRuleRunner = ( + parse: BlockMDParser, + text: string, + rule: BlockMDRule, + parseInline?: (txt: string) => string +) => string | undefined; + +const HEADING_REG_1 = /^(#{1,6}) +(.+)\n?/m; +const HeadingRule: BlockMDRule = { + match: (text) => text.match(HEADING_REG_1), + html: (match, parseInline) => { + const [, g1, g2] = match; + const level = g1.length; + return `${parseInline ? parseInline(g2) : g2}`; + }, +}; + +const CODEBLOCK_MD_1 = '```'; +const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((.+\n)+)`{3} *(?!.)\n?/m; +const CodeBlockRule: BlockMDRule = { + match: (text) => text.match(CODEBLOCK_REG_1), + html: (match) => { + const [, g1, g2] = match; + const classNameAtt = g1 ? ` class="language-${g1}"` : ''; + return `
${g2}
`; + }, +}; + +const BLOCKQUOTE_MD_1 = '>'; +const QUOTE_LINE_PREFIX = /^> */; +const BLOCKQUOTE_TRAILING_NEWLINE = /\n$/; +const BLOCKQUOTE_REG_1 = /(^>.*\n?)+/m; +const BlockQuoteRule: BlockMDRule = { + match: (text) => text.match(BLOCKQUOTE_REG_1), + html: (match, parseInline) => { + const [blockquoteText] = match; + + const lines = blockquoteText + .replace(BLOCKQUOTE_TRAILING_NEWLINE, '') + .split('\n') + .map((lineText) => { + const line = lineText.replace(QUOTE_LINE_PREFIX, ''); + if (parseInline) return `${parseInline(line)}
`; + return `${line}
`; + }) + .join(''); + return `
${lines}
`; + }, +}; + +const ORDERED_LIST_MD_1 = '-'; +const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */; +const O_LIST_START = /^([\d])\./; +const O_LIST_TYPE = /^([aAiI])\./; +const O_LIST_TRAILING_NEWLINE = /\n$/; +const ORDERED_LIST_REG_1 = /(^(-|[\da-zA-Z]\.) +.+\n?)+/m; +const OrderedListRule: BlockMDRule = { + match: (text) => text.match(ORDERED_LIST_REG_1), + html: (match, parseInline) => { + const [listText] = match; + const [, listStart] = listText.match(O_LIST_START) ?? []; + const [, listType] = listText.match(O_LIST_TYPE) ?? []; + + const lines = listText + .replace(O_LIST_TRAILING_NEWLINE, '') + .split('\n') + .map((lineText) => { + const line = lineText.replace(O_LIST_ITEM_PREFIX, ''); + const txt = parseInline ? parseInline(line) : line; + return `
  • ${txt}

  • `; + }) + .join(''); + + const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`; + const startAtt = listStart ? ` start="${listStart}"` : ''; + const typeAtt = listType ? ` type="${listType}"` : ''; + return `
      ${lines}
    `; + }, +}; + +const UNORDERED_LIST_MD_1 = '*'; +const U_LIST_ITEM_PREFIX = /^\* */; +const U_LIST_TRAILING_NEWLINE = /\n$/; +const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m; +const UnorderedListRule: BlockMDRule = { + match: (text) => text.match(UNORDERED_LIST_REG_1), + html: (match, parseInline) => { + const [listText] = match; + + const lines = listText + .replace(U_LIST_TRAILING_NEWLINE, '') + .split('\n') + .map((lineText) => { + const line = lineText.replace(U_LIST_ITEM_PREFIX, ''); + const txt = parseInline ? parseInline(line) : line; + return `
  • ${txt}

  • `; + }) + .join(''); + + return `
      ${lines}
    `; + }, +}; + +const runBlockRule: BlockRuleRunner = (parse, text, rule, parseInline) => { + const matchResult = rule.match(text); + if (matchResult) { + const content = rule.html(matchResult, parseInline); + return replaceMatch((txt) => [parse(txt, parseInline)], text, matchResult, content).join(''); + } + return undefined; +}; + +export const parseBlockMD: BlockMDParser = (text, parseInline) => { + if (text === '') return text; + let result: string | undefined; + + if (!result) result = runBlockRule(parseBlockMD, text, CodeBlockRule, parseInline); + if (!result) result = runBlockRule(parseBlockMD, text, BlockQuoteRule, parseInline); + if (!result) result = runBlockRule(parseBlockMD, text, OrderedListRule, parseInline); + if (!result) result = runBlockRule(parseBlockMD, text, UnorderedListRule, parseInline); + if (!result) result = runBlockRule(parseBlockMD, text, HeadingRule, parseInline); + + // replace \n with
    because want to preserve empty lines + if (!result) { + if (parseInline) { + result = text + .split('\n') + .map((lineText) => parseInline(lineText)) + .join('
    '); + } else { + result = text.replace(/\n/g, '
    '); + } + } return result ?? text; }; diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index adb6dc0..a2cb3a9 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -256,7 +256,7 @@ export const getRoomAvatarUrl = (mx: MatrixClient, room: Room): string | undefin }; export const trimReplyFromBody = (body: string): string => { - const match = body.match(/^>\s<.+?>\s.+\n\n/); + const match = body.match(/^> <.+?> .+\n(>.*\n)*?\n/m); if (!match) return body; return body.slice(match[0].length); }; diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts index 8e7c128..48ab0b8 100644 --- a/src/app/utils/sanitize.ts +++ b/src/app/utils/sanitize.ts @@ -59,9 +59,18 @@ const permittedTagToAttributes = { 'data-md', ], div: ['data-mx-maths'], + blockquote: ['data-md'], + h1: ['data-md'], + h2: ['data-md'], + h3: ['data-md'], + h4: ['data-md'], + h5: ['data-md'], + h6: ['data-md'], + pre: ['data-md', 'class'], + ol: ['start', 'type', 'data-md'], + ul: ['data-md'], a: ['name', 'target', 'href', 'rel', 'data-md'], img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'], - ol: ['start'], code: ['class', 'data-md'], strong: ['data-md'], i: ['data-md'],