Improve Editor related bugs and add multiline md (#1507)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Fri, 27 Oct 2023 10:27:22 +0000 (21:27 +1100)
committerGitHub <noreply@github.com>
Fri, 27 Oct 2023 10:27:22 +0000 (21:27 +1100)
* 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

15 files changed:
src/app/components/editor/Toolbar.tsx
src/app/components/editor/input.ts
src/app/components/editor/keyboard.ts
src/app/components/editor/output.ts
src/app/components/message/Reply.tsx
src/app/organisms/room/RoomInput.tsx
src/app/organisms/room/RoomTimeline.tsx
src/app/organisms/room/message/MessageEditor.tsx
src/app/organisms/room/message/styles.css.ts
src/app/organisms/settings/Settings.jsx
src/app/plugins/react-custom-html-parser.tsx
src/app/state/settings.ts
src/app/utils/markdown.ts
src/app/utils/room.ts
src/app/utils/sanitize.ts

index 6feae0095f3ab446a6ef9efe8dd9aafd028e776c..5d5e98943fe3f7a7a2c82de0a862e8fd4d790408 100644 (file)
@@ -148,7 +148,7 @@ export function HeadingBlockButton() {
           <Menu style={{ padding: config.space.S100 }}>
             <Box gap="100">
               <TooltipProvider
-                tooltip={<BtnTooltip text="Heading 1" shortCode={`${modKey} + Shift + 1`} />}
+                tooltip={<BtnTooltip text="Heading 1" shortCode={`${modKey} + 1`} />}
                 delay={500}
               >
                 {(triggerRef) => (
@@ -163,7 +163,7 @@ export function HeadingBlockButton() {
                 )}
               </TooltipProvider>
               <TooltipProvider
-                tooltip={<BtnTooltip text="Heading 2" shortCode={`${modKey} + Shift + 2`} />}
+                tooltip={<BtnTooltip text="Heading 2" shortCode={`${modKey} + 2`} />}
                 delay={500}
               >
                 {(triggerRef) => (
@@ -178,7 +178,7 @@ export function HeadingBlockButton() {
                 )}
               </TooltipProvider>
               <TooltipProvider
-                tooltip={<BtnTooltip text="Heading 3" shortCode={`${modKey} + Shift + 3`} />}
+                tooltip={<BtnTooltip text="Heading 3" shortCode={`${modKey} + 3`} />}
                 delay={500}
               >
                 {(triggerRef) => (
@@ -277,12 +277,7 @@ export function Toolbar() {
               <MarkButton
                 format={MarkType.StrikeThrough}
                 icon={Icons.Strike}
-                tooltip={
-                  <BtnTooltip
-                    text="Strike Through"
-                    shortCode={`${modKey} + ${KeySymbol.Shift} + U`}
-                  />
-                }
+                tooltip={<BtnTooltip text="Strike Through" shortCode={`${modKey} + S`} />}
               />
               <MarkButton
                 format={MarkType.Code}
@@ -311,12 +306,12 @@ export function Toolbar() {
             <BlockButton
               format={BlockType.OrderedList}
               icon={Icons.OrderList}
-              tooltip={<BtnTooltip text="Ordered List" shortCode={`${modKey} + Shift + 7`} />}
+              tooltip={<BtnTooltip text="Ordered List" shortCode={`${modKey} + 7`} />}
             />
             <BlockButton
               format={BlockType.UnorderedList}
               icon={Icons.UnorderList}
-              tooltip={<BtnTooltip text="Unordered List" shortCode={`${modKey} + Shift + 8`} />}
+              tooltip={<BtnTooltip text="Unordered List" shortCode={`${modKey} + 8`} />}
             />
             <HeadingBlockButton />
           </Box>
@@ -335,7 +330,7 @@ export function Toolbar() {
           <Box className={css.MarkdownBtnBox} shrink="No" grow="Yes" justifyContent="End">
             <TooltipProvider
               align="End"
-              tooltip={<BtnTooltip text="Inline Markdown" />}
+              tooltip={<BtnTooltip text="Toggle Markdown" />}
               delay={500}
             >
               {(triggerRef) => (
index 5860df04f730b393142ec83794b696224a97fcef..272b9707abb7af90550bfdb3f7d6b86436cccd65 100644 (file)
@@ -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<InlineElement[]> = [];
   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,
-  };
+  if (node.attribs['data-md'] !== undefined) {
+    return quoteLines.map((lineChildren) => ({
+      type: BlockType.Paragraph,
+      children: [{ text: `${node.attribs['data-md']} ` }, ...lineChildren],
+    }));
+  }
+
+  return [
+    {
+      type: BlockType.BlockQuote,
+      children: quoteLines.map((lineChildren) => ({
+        type: BlockType.QuoteLine,
+        children: lineChildren,
+      })),
+    },
+  ];
 };
-const parseCodeBlockNode = (node: Element): CodeBlockElement => {
-  const children: CodeLineElement[] = [];
+const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => {
+  const codeLines = parseNodeText(node).trim().split('\n');
 
-  const code = parseNodeText(node).trim();
-  code.split('\n').forEach((lineTxt) =>
-    children.push({
-      type: BlockType.CodeLine,
+  if (node.attribs['data-md'] !== undefined) {
+    const pLines = codeLines.map<ParagraphElement>((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<CodeLineElement>((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<InlineElement[]> = [];
   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;
       }
 
index 370f3e8276db28cad2b09546d1f817cf9440c45a..19c05bac752bdd23dd4eb56fa4ad08e69d5152bb 100644 (file)
@@ -8,22 +8,22 @@ export const INLINE_HOTKEYS: Record<string, MarkType> = {
   '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<string, BlockType> = {
-  '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.
index 307ef8a2b6c9973db555a348093c6e037bd948a8..fa15bb582da89c5908c472b6f047284e9eaf455c 100644 (file)
@@ -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 = `<span data-mx-spoiler>${string}</span>`;
   }
 
-  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(/<br\/>$/, '\n')
+        .replace(/^&gt;/, '>');
+      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);
index 6eaab31ed4d9bf1993a73f23d06a5c7c9fe7e592..c9b6b8d8b2b0c7625e99d73525583830fafed926 100644 (file)
@@ -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 (
       <Box
         className={classNames(css.Reply, className)}
@@ -82,11 +85,7 @@ export const Reply = as<'div', ReplyProps>(
         <Box grow="Yes" className={css.ReplyContent}>
           {replyEvent !== undefined ? (
             <Text className={css.ReplyContentText} size="T300" truncate>
-              {replyEvent?.getContent().msgtype === 'm.bad.encrypted' ? (
-                <MessageBadEncryptedContent />
-              ) : (
-                (body && trimReplyFromBody(body)) ?? fallbackBody
-              )}
+              {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
             </Text>
           ) : (
             <LinePlaceholder
index 8dc4e6447adecccfd15b5f8785dd7cdebfd1d326..e6c4fb73c268c8c8e823ceeb1e284cdcf3dbc8b2 100644 (file)
@@ -244,7 +244,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
       let customHtml = trimCustomHtml(
         toMatrixCustomHTML(editor.children, {
           allowTextFormatting: true,
-          allowMarkdown: isMarkdown,
+          allowBlockMarkdown: isMarkdown,
+          allowInlineMarkdown: isMarkdown,
         })
       );
       let msgType = MsgType.Text;
index 2d3824548f499ab33b89446a737f29e34710efd2..c1b0445834d1393819e2552a2d651555c177dee1 100644 (file)
@@ -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 (
         <Text
index 0756c38ed30fe1d651c5646435b5391e64ca601c..006b46a8868f2c7e208d59ef45509d99c47dbe6c 100644 (file)
@@ -77,7 +77,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
         const customHtml = trimCustomHtml(
           toMatrixCustomHTML(editor.children, {
             allowTextFormatting: true,
-            allowMarkdown: isMarkdown,
+            allowBlockMarkdown: isMarkdown,
+            allowInlineMarkdown: isMarkdown,
           })
         );
 
index a5f2f6b5efbcb8fb63f5f297c05644703049b6d7..801f698d79927d35cf6cff0237ef93bfd26ca39b 100644 (file)
@@ -81,5 +81,5 @@ export const ReactionsContainer = style({
 });
 
 export const ReactionsTooltipText = style({
-  wordBreak: 'break-all',
+  wordBreak: 'break-word',
 });
index 2b706edad755aeec26b4c0742aed57ec6faa93bf..1b04669cb18d568826b96c2dab37b0909e382d18 100644 (file)
@@ -152,14 +152,14 @@ function AppearanceSection() {
           content={<Text variant="b3">{`Use ${isMacOS() ? KeySymbol.Command : 'Ctrl'} + ENTER to send message and ENTER for newline.`}</Text>}
         />
         <SettingTile
-          title="Inline Markdown formatting"
+          title="Markdown formatting"
           options={(
             <Toggle
               isActive={isMarkdown}
               onToggle={() => setIsMarkdown(!isMarkdown) }
             />
           )}
-          content={<Text variant="b3">Format messages with inline markdown syntax before sending.</Text>}
+          content={<Text variant="b3">Format messages with markdown syntax before sending.</Text>}
         />
         <SettingTile
           title="Hide membership events"
index 478a8a3be26a8740efed3d82498c4cfd6746a577..928419c54879e578ba8b583c2b17655cbae2c010 100644 (file)
@@ -1,6 +1,6 @@
 /* eslint-disable jsx-a11y/alt-text */
 import React, { ReactEventHandler, Suspense, lazy } from 'react';
-import parse, {
+import {
   Element,
   Text as DOMText,
   HTMLReactParserOptions,
@@ -17,12 +17,12 @@ import * as css from '../styles/CustomHtml.css';
 import { getMxIdLocalPart, getRoomWithCanonicalAlias } from '../utils/matrix';
 import { getMemberDisplayName } from '../utils/room';
 import { EMOJI_PATTERN, URL_NEG_LB } from '../utils/regex';
-import { sanitizeText } from '../utils/sanitize';
 import { getHexcodeForEmoji, getShortcodeFor } from './emoji';
+import { replaceMatch } from '../utils/markdown';
 
 const ReactPrism = lazy(() => 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 <Linkify options={LINKIFY_OPTS}>{domNode.data}</Linkify>;
-    }
-    return undefined;
-  },
-};
+const stringToEmojifyJSX = (text: string): (string | JSX.Element)[] => {
+  const match = text.match(EMOJI_REG);
+  if (!match) return [text];
+
+  const [emoji] = match;
 
-export const emojifyAndLinkify = (unsafeText: string, linkify?: boolean) => {
-  const emojifyHtml = sanitizeText(unsafeText).replace(
-    EMOJI_REG,
-    (emoji) =>
-      `<span class="${css.EmoticonBase}"><span class="${css.Emoticon()}" title="${getShortcodeFor(
-        getHexcodeForEmoji(emoji)
-      )}">${emoji}</span></span>`
+  return replaceMatch(
+    stringToEmojifyJSX,
+    text,
+    match,
+    <span className={css.EmoticonBase}>
+      <span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(emoji))}>
+        {emoji}
+      </span>
+    </span>
   );
+};
+
+export const emojifyAndLinkify = (text: string, linkify?: boolean) => {
+  const emojifyJSX = stringToEmojifyJSX(text);
 
-  return <>{parse(emojifyHtml, linkify ? emojifyParserOptions : undefined)}</>;
+  if (linkify) {
+    return <Linkify options={LINKIFY_OPTS}>{emojifyJSX}</Linkify>;
+  }
+  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 (
                 <ErrorBoundary fallback={<code {...props}>{codeReact}</code>}>
                   <Suspense fallback={<code {...props}>{codeReact}</code>}>
index 3a7832cd91cf4300bbb59b417ca44e6545808986..4393b64dbc0e3f9a9fb5dcb695bc5a75be06c413 100644 (file)
@@ -28,7 +28,7 @@ export interface Settings {
 const defaultSettings: Settings = {
   themeIndex: 0,
   useSystemTheme: true,
-  isMarkdown: false,
+  isMarkdown: true,
   editorToolbar: false,
   twitterEmoji: false,
 
index 9fda6794db3f61d9566d7a395f4ab15441414aa8..c6bb3914a34873fe0f6e71fe12c5066dcde08a93 100644 (file)
@@ -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 = <C>(
+  convertPart: (txt: string) => Array<string | C>,
   text: string,
   match: MatchResult,
-  content: string
-) => string;
+  content: C
+): Array<string | C> => [
+  ...convertPart(beforeMatch(text, match)),
+  content,
+  ...convertPart(afterMatch(text, match)),
+];
+
+/*
+ *****************
+ * INLINE PARSER *
+ *****************
+ */
+
+export type InlineMDParser = (text: string) => string;
 
-export type RuleRunner = (parse: PlainMDParser, text: string, rule: MDRule) => string | undefined;
-export type RulesRunner = (
-  parse: PlainMDParser,
+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 `<strong data-md="${BOLD_MD_1}">${parse(g1)}</strong>`;
+    const [, , g2] = match;
+    return `<strong data-md="${BOLD_MD_1}">${parse(g2)}</strong>`;
   },
 };
 
@@ -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 `<i data-md="${ITALIC_MD_1}">${parse(g1)}</i>`;
+    const [, , g2] = match;
+    return `<i data-md="${ITALIC_MD_1}">${parse(g2)}</i>`;
   },
 };
 
@@ -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 `<i data-md="${ITALIC_MD_2}">${parse(g1)}</i>`;
+    const [, , g2] = match;
+    return `<i data-md="${ITALIC_MD_2}">${parse(g2)}</i>`;
   },
 };
 
@@ -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 `<u data-md="${UNDERLINE_MD_1}">${parse(g1)}</u>`;
+    const [, , g2] = match;
+    return `<u data-md="${UNDERLINE_MD_1}">${parse(g2)}</u>`;
   },
 };
 
@@ -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 `<del data-md="${STRIKE_MD_1}">${parse(g1)}</del>`;
+    const [, , g2] = match;
+    return `<del data-md="${STRIKE_MD_1}">${parse(g2)}</del>`;
   },
 };
 
 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 `<code data-md="${CODE_MD_1}">${g1}</code>`;
+    const [, , g2] = match;
+    return `<code data-md="${CODE_MD_1}">${g2}</code>`;
   },
 };
 
@@ -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 `<span data-md="${SPOILER_MD_1}" data-mx-spoiler>${parse(g1)}</span>`;
+    const [, , g2] = match;
+    return `<span data-md="${SPOILER_MD_1}" data-mx-spoiler>${parse(g2)}</span>`;
   },
 };
 
 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 = runInlineRules(parseInlineMD, text, LeveledRules);
+
+  return result ?? text;
+};
+
+/*
+ ****************
+ * BLOCK PARSER *
+ ****************
+ */
+
+export type BlockMDParser = (test: string, parseInline?: (txt: string) => string) => string;
 
-  if (!result) result = runRules(parseInlineMD, text, LeveledRules);
+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 `<h${level} data-md="${g1}">${parseInline ? parseInline(g2) : g2}</h${level}>`;
+  },
+};
+
+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 `<pre data-md="${CODEBLOCK_MD_1}"><code${classNameAtt}>${g2}</code$></pre>`;
+  },
+};
+
+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)}<br/>`;
+        return `${line}<br/>`;
+      })
+      .join('');
+    return `<blockquote data-md="${BLOCKQUOTE_MD_1}">${lines}</blockquote>`;
+  },
+};
+
+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 `<li><p>${txt}</p></li>`;
+      })
+      .join('');
+
+    const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`;
+    const startAtt = listStart ? ` start="${listStart}"` : '';
+    const typeAtt = listType ? ` type="${listType}"` : '';
+    return `<ol ${dataMdAtt}${startAtt}${typeAtt}>${lines}</ol>`;
+  },
+};
+
+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 `<li><p>${txt}</p></li>`;
+      })
+      .join('');
+
+    return `<ul data-md="${UNORDERED_LIST_MD_1}">${lines}</ul>`;
+  },
+};
+
+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 <br/> because want to preserve empty lines
+  if (!result) {
+    if (parseInline) {
+      result = text
+        .split('\n')
+        .map((lineText) => parseInline(lineText))
+        .join('<br/>');
+    } else {
+      result = text.replace(/\n/g, '<br/>');
+    }
+  }
 
   return result ?? text;
 };
index adb6dc088fc0198e1c441596caa6b876637b79bb..a2cb3a9f17fd10ae038a944893f4a9ff27e7f5b1 100644 (file)
@@ -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);
 };
index 8e7c1283b02dc58bd0963ae1339c06b834c3e654..48ab0b8d1f79cdcd3adc6d24d559574f60360afc 100644 (file)
@@ -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'],