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
This commit is contained in:
Ajay Bura 2023-10-27 21:27:22 +11:00 committed by GitHub
parent 72bb5b42af
commit b24f858369
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 425 additions and 160 deletions

View file

@ -148,7 +148,7 @@ export function HeadingBlockButton() {
<Menu style={{ padding: config.space.S100 }}> <Menu style={{ padding: config.space.S100 }}>
<Box gap="100"> <Box gap="100">
<TooltipProvider <TooltipProvider
tooltip={<BtnTooltip text="Heading 1" shortCode={`${modKey} + Shift + 1`} />} tooltip={<BtnTooltip text="Heading 1" shortCode={`${modKey} + 1`} />}
delay={500} delay={500}
> >
{(triggerRef) => ( {(triggerRef) => (
@ -163,7 +163,7 @@ export function HeadingBlockButton() {
)} )}
</TooltipProvider> </TooltipProvider>
<TooltipProvider <TooltipProvider
tooltip={<BtnTooltip text="Heading 2" shortCode={`${modKey} + Shift + 2`} />} tooltip={<BtnTooltip text="Heading 2" shortCode={`${modKey} + 2`} />}
delay={500} delay={500}
> >
{(triggerRef) => ( {(triggerRef) => (
@ -178,7 +178,7 @@ export function HeadingBlockButton() {
)} )}
</TooltipProvider> </TooltipProvider>
<TooltipProvider <TooltipProvider
tooltip={<BtnTooltip text="Heading 3" shortCode={`${modKey} + Shift + 3`} />} tooltip={<BtnTooltip text="Heading 3" shortCode={`${modKey} + 3`} />}
delay={500} delay={500}
> >
{(triggerRef) => ( {(triggerRef) => (
@ -277,12 +277,7 @@ export function Toolbar() {
<MarkButton <MarkButton
format={MarkType.StrikeThrough} format={MarkType.StrikeThrough}
icon={Icons.Strike} icon={Icons.Strike}
tooltip={ tooltip={<BtnTooltip text="Strike Through" shortCode={`${modKey} + S`} />}
<BtnTooltip
text="Strike Through"
shortCode={`${modKey} + ${KeySymbol.Shift} + U`}
/>
}
/> />
<MarkButton <MarkButton
format={MarkType.Code} format={MarkType.Code}
@ -311,12 +306,12 @@ export function Toolbar() {
<BlockButton <BlockButton
format={BlockType.OrderedList} format={BlockType.OrderedList}
icon={Icons.OrderList} icon={Icons.OrderList}
tooltip={<BtnTooltip text="Ordered List" shortCode={`${modKey} + Shift + 7`} />} tooltip={<BtnTooltip text="Ordered List" shortCode={`${modKey} + 7`} />}
/> />
<BlockButton <BlockButton
format={BlockType.UnorderedList} format={BlockType.UnorderedList}
icon={Icons.UnorderList} icon={Icons.UnorderList}
tooltip={<BtnTooltip text="Unordered List" shortCode={`${modKey} + Shift + 8`} />} tooltip={<BtnTooltip text="Unordered List" shortCode={`${modKey} + 8`} />}
/> />
<HeadingBlockButton /> <HeadingBlockButton />
</Box> </Box>
@ -335,7 +330,7 @@ export function Toolbar() {
<Box className={css.MarkdownBtnBox} shrink="No" grow="Yes" justifyContent="End"> <Box className={css.MarkdownBtnBox} shrink="No" grow="Yes" justifyContent="End">
<TooltipProvider <TooltipProvider
align="End" align="End"
tooltip={<BtnTooltip text="Inline Markdown" />} tooltip={<BtnTooltip text="Toggle Markdown" />}
delay={500} delay={500}
> >
{(triggerRef) => ( {(triggerRef) => (

View file

@ -13,11 +13,9 @@ import {
HeadingElement, HeadingElement,
HeadingLevel, HeadingLevel,
InlineElement, InlineElement,
ListItemElement,
MentionElement, MentionElement,
OrderedListElement, OrderedListElement,
ParagraphElement, ParagraphElement,
QuoteLineElement,
UnorderedListElement, UnorderedListElement,
} from './slate'; } from './slate';
import { parseMatrixToUrl } from '../../utils/matrix'; import { parseMatrixToUrl } from '../../utils/matrix';
@ -117,17 +115,14 @@ const parseInlineNodes = (node: ChildNode): InlineElement[] => {
return []; return [];
}; };
const parseBlockquoteNode = (node: Element): BlockQuoteElement => { const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElement[] => {
const children: QuoteLineElement[] = []; const quoteLines: Array<InlineElement[]> = [];
let lineHolder: InlineElement[] = []; let lineHolder: InlineElement[] = [];
const appendLine = () => { const appendLine = () => {
if (lineHolder.length === 0) return; if (lineHolder.length === 0) return;
children.push({ quoteLines.push(lineHolder);
type: BlockType.QuoteLine,
children: lineHolder,
});
lineHolder = []; lineHolder = [];
}; };
@ -145,10 +140,7 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement => {
if (child.name === 'p') { if (child.name === 'p') {
appendLine(); appendLine();
children.push({ quoteLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
type: BlockType.QuoteLine,
children: child.children.flatMap((c) => parseInlineNodes(c)),
});
return; return;
} }
@ -157,42 +149,71 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement => {
}); });
appendLine(); appendLine();
return { if (node.attribs['data-md'] !== undefined) {
type: BlockType.BlockQuote, return quoteLines.map((lineChildren) => ({
children, type: BlockType.Paragraph,
}; children: [{ text: `${node.attribs['data-md']} ` }, ...lineChildren],
}; }));
const parseCodeBlockNode = (node: Element): CodeBlockElement => { }
const children: CodeLineElement[] = [];
const code = parseNodeText(node).trim(); return [
code.split('\n').forEach((lineTxt) => {
children.push({ 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<ParagraphElement>((lineText) => ({
type: BlockType.Paragraph,
children: [
{
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: codeLines.map<CodeLineElement>((lineTxt) => ({
type: BlockType.CodeLine, type: BlockType.CodeLine,
children: [ children: [
{ {
text: lineTxt, text: lineTxt,
}, },
], ],
}) })),
); },
];
return {
type: BlockType.CodeBlock,
children,
}; };
}; const parseListNode = (
const parseListNode = (node: Element): OrderedListElement | UnorderedListElement => { node: Element
const children: ListItemElement[] = []; ): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
const listLines: Array<InlineElement[]> = [];
let lineHolder: InlineElement[] = []; let lineHolder: InlineElement[] = [];
const appendLine = () => { const appendLine = () => {
if (lineHolder.length === 0) return; if (lineHolder.length === 0) return;
children.push({ listLines.push(lineHolder);
type: BlockType.ListItem,
children: lineHolder,
});
lineHolder = []; lineHolder = [];
}; };
@ -210,10 +231,7 @@ const parseListNode = (node: Element): OrderedListElement | UnorderedListElement
if (child.name === 'li') { if (child.name === 'li') {
appendLine(); appendLine();
children.push({ listLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
type: BlockType.ListItem,
children: child.children.flatMap((c) => parseInlineNodes(c)),
});
return; return;
} }
@ -222,17 +240,54 @@ const parseListNode = (node: Element): OrderedListElement | UnorderedListElement
}); });
appendLine(); appendLine();
return { if (node.attribs['data-md'] !== undefined) {
type: node.name === 'ol' ? BlockType.OrderedList : BlockType.UnorderedList, const prefix = node.attribs['data-md'] || '-';
children, 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 | ParagraphElement => {
const parseHeadingNode = (node: Element): HeadingElement => {
const children = node.children.flatMap((child) => parseInlineNodes(child)); const children = node.children.flatMap((child) => parseInlineNodes(child));
const headingMatch = node.name.match(/^h([123456])$/); const headingMatch = node.name.match(/^h([123456])$/);
const [, g1AsLevel] = headingMatch ?? ['h3', '3']; const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
const level = parseInt(g1AsLevel, 10); const level = parseInt(g1AsLevel, 10);
if (node.attribs['data-md'] !== undefined) {
return {
type: BlockType.Paragraph,
children: [{ text: `${node.attribs['data-md']} ` }, ...children],
};
}
return { return {
type: BlockType.Heading, type: BlockType.Heading,
level: (level <= 3 ? level : 3) as HeadingLevel, level: (level <= 3 ? level : 3) as HeadingLevel,
@ -278,17 +333,17 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
if (node.name === 'blockquote') { if (node.name === 'blockquote') {
appendLine(); appendLine();
children.push(parseBlockquoteNode(node)); children.push(...parseBlockquoteNode(node));
return; return;
} }
if (node.name === 'pre') { if (node.name === 'pre') {
appendLine(); appendLine();
children.push(parseCodeBlockNode(node)); children.push(...parseCodeBlockNode(node));
return; return;
} }
if (node.name === 'ol' || node.name === 'ul') { if (node.name === 'ol' || node.name === 'ul') {
appendLine(); appendLine();
children.push(parseListNode(node)); children.push(...parseListNode(node));
return; return;
} }

View file

@ -8,22 +8,22 @@ export const INLINE_HOTKEYS: Record<string, MarkType> = {
'mod+b': MarkType.Bold, 'mod+b': MarkType.Bold,
'mod+i': MarkType.Italic, 'mod+i': MarkType.Italic,
'mod+u': MarkType.Underline, 'mod+u': MarkType.Underline,
'mod+shift+u': MarkType.StrikeThrough, 'mod+s': MarkType.StrikeThrough,
'mod+[': MarkType.Code, 'mod+[': MarkType.Code,
'mod+h': MarkType.Spoiler, 'mod+h': MarkType.Spoiler,
}; };
const INLINE_KEYS = Object.keys(INLINE_HOTKEYS); const INLINE_KEYS = Object.keys(INLINE_HOTKEYS);
export const BLOCK_HOTKEYS: Record<string, BlockType> = { export const BLOCK_HOTKEYS: Record<string, BlockType> = {
'mod+shift+7': BlockType.OrderedList, 'mod+7': BlockType.OrderedList,
'mod+shift+8': BlockType.UnorderedList, 'mod+8': BlockType.UnorderedList,
"mod+'": BlockType.BlockQuote, "mod+'": BlockType.BlockQuote,
'mod+;': BlockType.CodeBlock, 'mod+;': BlockType.CodeBlock,
}; };
const BLOCK_KEYS = Object.keys(BLOCK_HOTKEYS); const BLOCK_KEYS = Object.keys(BLOCK_HOTKEYS);
const isHeading1 = isKeyHotkey('mod+shift+1'); const isHeading1 = isKeyHotkey('mod+1');
const isHeading2 = isKeyHotkey('mod+shift+2'); const isHeading2 = isKeyHotkey('mod+2');
const isHeading3 = isKeyHotkey('mod+shift+3'); const isHeading3 = isKeyHotkey('mod+3');
/** /**
* @return boolean true if shortcut is toggled. * @return boolean true if shortcut is toggled.

View file

@ -3,11 +3,12 @@ import { Descendant, Text } from 'slate';
import { sanitizeText } from '../../utils/sanitize'; import { sanitizeText } from '../../utils/sanitize';
import { BlockType } from './types'; import { BlockType } from './types';
import { CustomElement } from './slate'; import { CustomElement } from './slate';
import { parseInlineMD } from '../../utils/markdown'; import { parseBlockMD, parseInlineMD, replaceMatch } from '../../utils/markdown';
export type OutputOptions = { export type OutputOptions = {
allowTextFormatting?: boolean; allowTextFormatting?: boolean;
allowMarkdown?: boolean; allowInlineMarkdown?: boolean;
allowBlockMarkdown?: boolean;
}; };
const textToCustomHtml = (node: Text, opts: OutputOptions): string => { 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 (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); 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 = ( export const toMatrixCustomHTML = (
node: Descendant | Descendant[], node: Descendant | Descendant[],
opts: OutputOptions opts: OutputOptions
): string => { ): 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; const isCodeLine = 'type' in n && n.type === BlockType.CodeLine;
if (isCodeLine) return toMatrixCustomHTML(n, {}); if (isCodeLine) return `${parsedMarkdown}${toMatrixCustomHTML(n, {})}`;
return toMatrixCustomHTML(n, opts);
return `${parsedMarkdown}${toMatrixCustomHTML(n, { ...opts, allowBlockMarkdown: false })}`;
}; };
if (Array.isArray(node)) return node.map(parseNode).join(''); if (Array.isArray(node)) return node.map(parseNode).join('');
if (Text.isText(node)) return textToCustomHtml(node, opts); if (Text.isText(node)) return textToCustomHtml(node, opts);

View file

@ -59,6 +59,9 @@ export const Reply = as<'div', ReplyProps>(
}; };
}, [replyEvent, mx, room, eventId]); }, [replyEvent, mx, room, eventId]);
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
const bodyJSX = body ? trimReplyFromBody(body) : fallbackBody;
return ( return (
<Box <Box
className={classNames(css.Reply, className)} className={classNames(css.Reply, className)}
@ -82,11 +85,7 @@ export const Reply = as<'div', ReplyProps>(
<Box grow="Yes" className={css.ReplyContent}> <Box grow="Yes" className={css.ReplyContent}>
{replyEvent !== undefined ? ( {replyEvent !== undefined ? (
<Text className={css.ReplyContentText} size="T300" truncate> <Text className={css.ReplyContentText} size="T300" truncate>
{replyEvent?.getContent().msgtype === 'm.bad.encrypted' ? ( {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
<MessageBadEncryptedContent />
) : (
(body && trimReplyFromBody(body)) ?? fallbackBody
)}
</Text> </Text>
) : ( ) : (
<LinePlaceholder <LinePlaceholder

View file

@ -244,7 +244,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
let customHtml = trimCustomHtml( let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, { toMatrixCustomHTML(editor.children, {
allowTextFormatting: true, allowTextFormatting: true,
allowMarkdown: isMarkdown, allowBlockMarkdown: isMarkdown,
allowInlineMarkdown: isMarkdown,
}) })
); );
let msgType = MsgType.Text; let msgType = MsgType.Text;

View file

@ -89,6 +89,7 @@ import {
getReactionContent, getReactionContent,
isMembershipChanged, isMembershipChanged,
reactionOrEditEvent, reactionOrEditEvent,
trimReplyFromBody,
} from '../../utils/room'; } from '../../utils/room';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/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(); editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
if (typeof body !== 'string') return null; if (typeof body !== 'string') return null;
const jumboEmoji = JUMBO_EMOJI_REG.test(body); const jumboEmoji = JUMBO_EMOJI_REG.test(trimReplyFromBody(body));
return ( return (
<Text <Text

View file

@ -77,7 +77,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const customHtml = trimCustomHtml( const customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, { toMatrixCustomHTML(editor.children, {
allowTextFormatting: true, allowTextFormatting: true,
allowMarkdown: isMarkdown, allowBlockMarkdown: isMarkdown,
allowInlineMarkdown: isMarkdown,
}) })
); );

View file

@ -81,5 +81,5 @@ export const ReactionsContainer = style({
}); });
export const ReactionsTooltipText = style({ export const ReactionsTooltipText = style({
wordBreak: 'break-all', wordBreak: 'break-word',
}); });

View 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>} content={<Text variant="b3">{`Use ${isMacOS() ? KeySymbol.Command : 'Ctrl'} + ENTER to send message and ENTER for newline.`}</Text>}
/> />
<SettingTile <SettingTile
title="Inline Markdown formatting" title="Markdown formatting"
options={( options={(
<Toggle <Toggle
isActive={isMarkdown} isActive={isMarkdown}
onToggle={() => setIsMarkdown(!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 <SettingTile
title="Hide membership events" title="Hide membership events"

View file

@ -1,6 +1,6 @@
/* eslint-disable jsx-a11y/alt-text */ /* eslint-disable jsx-a11y/alt-text */
import React, { ReactEventHandler, Suspense, lazy } from 'react'; import React, { ReactEventHandler, Suspense, lazy } from 'react';
import parse, { import {
Element, Element,
Text as DOMText, Text as DOMText,
HTMLReactParserOptions, HTMLReactParserOptions,
@ -17,12 +17,12 @@ import * as css from '../styles/CustomHtml.css';
import { getMxIdLocalPart, getRoomWithCanonicalAlias } from '../utils/matrix'; import { getMxIdLocalPart, getRoomWithCanonicalAlias } from '../utils/matrix';
import { getMemberDisplayName } from '../utils/room'; import { getMemberDisplayName } from '../utils/room';
import { EMOJI_PATTERN, URL_NEG_LB } from '../utils/regex'; import { EMOJI_PATTERN, URL_NEG_LB } from '../utils/regex';
import { sanitizeText } from '../utils/sanitize';
import { getHexcodeForEmoji, getShortcodeFor } from './emoji'; import { getHexcodeForEmoji, getShortcodeFor } from './emoji';
import { replaceMatch } from '../utils/markdown';
const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); 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 = { export const LINKIFY_OPTS: LinkifyOpts = {
attributes: { attributes: {
@ -35,25 +35,31 @@ export const LINKIFY_OPTS: LinkifyOpts = {
ignoreTags: ['span'], ignoreTags: ['span'],
}; };
const emojifyParserOptions: HTMLReactParserOptions = { const stringToEmojifyJSX = (text: string): (string | JSX.Element)[] => {
replace: (domNode) => { const match = text.match(EMOJI_REG);
if (domNode instanceof DOMText) { if (!match) return [text];
return <Linkify options={LINKIFY_OPTS}>{domNode.data}</Linkify>;
} const [emoji] = match;
return undefined;
}, return replaceMatch(
stringToEmojifyJSX,
text,
match,
<span className={css.EmoticonBase}>
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(emoji))}>
{emoji}
</span>
</span>
);
}; };
export const emojifyAndLinkify = (unsafeText: string, linkify?: boolean) => { export const emojifyAndLinkify = (text: string, linkify?: boolean) => {
const emojifyHtml = sanitizeText(unsafeText).replace( const emojifyJSX = stringToEmojifyJSX(text);
EMOJI_REG,
(emoji) =>
`<span class="${css.EmoticonBase}"><span class="${css.Emoticon()}" title="${getShortcodeFor(
getHexcodeForEmoji(emoji)
)}">${emoji}</span></span>`
);
return <>{parse(emojifyHtml, linkify ? emojifyParserOptions : undefined)}</>; if (linkify) {
return <Linkify options={LINKIFY_OPTS}>{emojifyJSX}</Linkify>;
}
return emojifyJSX;
}; };
export const getReactCustomHtmlParser = ( export const getReactCustomHtmlParser = (
@ -171,6 +177,8 @@ export const getReactCustomHtmlParser = (
if (typeof codeReact === 'string') { if (typeof codeReact === 'string') {
let lang = props.className; let lang = props.className;
if (lang === 'language-rs') lang = 'language-rust'; if (lang === 'language-rs') lang = 'language-rust';
else if (lang === 'language-js') lang = 'language-javascript';
else if (lang === 'language-ts') lang = 'language-typescript';
return ( return (
<ErrorBoundary fallback={<code {...props}>{codeReact}</code>}> <ErrorBoundary fallback={<code {...props}>{codeReact}</code>}>
<Suspense fallback={<code {...props}>{codeReact}</code>}> <Suspense fallback={<code {...props}>{codeReact}</code>}>

View file

@ -28,7 +28,7 @@ export interface Settings {
const defaultSettings: Settings = { const defaultSettings: Settings = {
themeIndex: 0, themeIndex: 0,
useSystemTheme: true, useSystemTheme: true,
isMarkdown: false, isMarkdown: true,
editorToolbar: false, editorToolbar: false,
twitterEmoji: false, twitterEmoji: false,

View file

@ -1,25 +1,46 @@
export type PlainMDParser = (text: string) => string;
export type MatchResult = RegExpMatchArray | RegExpExecArray; export type MatchResult = RegExpMatchArray | RegExpExecArray;
export type RuleMatch = (text: string) => MatchResult | null; export type RuleMatch = (text: string) => MatchResult | null;
export type MatchConverter = (parse: PlainMDParser, match: MatchResult) => string;
export type MDRule = { export const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
match: RuleMatch; text.slice(0, match.index);
html: MatchConverter; export const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string =>
}; text.slice((match.index ?? 0) + match[0].length);
export type MatchReplacer = ( export const replaceMatch = <C>(
parse: PlainMDParser, convertPart: (txt: string) => Array<string | C>,
text: string, text: string,
match: MatchResult, match: MatchResult,
content: string content: C
) => string; ): Array<string | C> => [
...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, text: string,
rules: MDRule[] rule: InlineMDRule
) => string | undefined;
export type InlineRulesRunner = (
parse: InlineMDParser,
text: string,
rules: InlineMDRule[]
) => string | undefined; ) => string | undefined;
const MIN_ANY = '(.+?)'; const MIN_ANY = '(.+?)';
@ -31,11 +52,11 @@ const BOLD_NEG_LA_1 = '(?!\\*)';
const BOLD_REG_1 = new RegExp( const BOLD_REG_1 = new RegExp(
`${URL_NEG_LB}${BOLD_PREFIX_1}${MIN_ANY}${BOLD_PREFIX_1}${BOLD_NEG_LA_1}` `${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), match: (text) => text.match(BOLD_REG_1),
html: (parse, match) => { html: (parse, match) => {
const [, g1] = match; const [, , g2] = match;
return `<strong data-md="${BOLD_MD_1}">${parse(g1)}</strong>`; 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( const ITALIC_REG_1 = new RegExp(
`${URL_NEG_LB}${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}` `${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), match: (text) => text.match(ITALIC_REG_1),
html: (parse, match) => { html: (parse, match) => {
const [, g1] = match; const [, , g2] = match;
return `<i data-md="${ITALIC_MD_1}">${parse(g1)}</i>`; 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( const ITALIC_REG_2 = new RegExp(
`${URL_NEG_LB}${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}` `${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), match: (text) => text.match(ITALIC_REG_2),
html: (parse, match) => { html: (parse, match) => {
const [, g1] = match; const [, , g2] = match;
return `<i data-md="${ITALIC_MD_2}">${parse(g1)}</i>`; 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( const UNDERLINE_REG_1 = new RegExp(
`${URL_NEG_LB}${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}` `${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), match: (text) => text.match(UNDERLINE_REG_1),
html: (parse, match) => { html: (parse, match) => {
const [, g1] = match; const [, , g2] = match;
return `<u data-md="${UNDERLINE_MD_1}">${parse(g1)}</u>`; 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( const STRIKE_REG_1 = new RegExp(
`${URL_NEG_LB}${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}` `${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), match: (text) => text.match(STRIKE_REG_1),
html: (parse, match) => { html: (parse, match) => {
const [, g1] = match; const [, , g2] = match;
return `<del data-md="${STRIKE_MD_1}">${parse(g1)}</del>`; return `<del data-md="${STRIKE_MD_1}">${parse(g2)}</del>`;
}, },
}; };
const CODE_MD_1 = '`'; const CODE_MD_1 = '`';
const CODE_PREFIX_1 = '`'; const CODE_PREFIX_1 = '`';
const CODE_NEG_LA_1 = '(?!`)'; const CODE_NEG_LA_1 = '(?!`)';
const CODE_REG_1 = new RegExp( const CODE_REG_1 = new RegExp(`${URL_NEG_LB}${CODE_PREFIX_1}(.+?)${CODE_PREFIX_1}${CODE_NEG_LA_1}`);
`${URL_NEG_LB}${CODE_PREFIX_1}${MIN_ANY}${CODE_PREFIX_1}${CODE_NEG_LA_1}` const CodeRule: InlineMDRule = {
);
const CodeRule: MDRule = {
match: (text) => text.match(CODE_REG_1), match: (text) => text.match(CODE_REG_1),
html: (parse, match) => { html: (parse, match) => {
const [, g1] = match; const [, , g2] = match;
return `<code data-md="${CODE_MD_1}">${g1}</code>`; return `<code data-md="${CODE_MD_1}">${g2}</code>`;
}, },
}; };
@ -115,18 +134,18 @@ const SPOILER_NEG_LA_1 = '(?!\\|)';
const SPOILER_REG_1 = new RegExp( const SPOILER_REG_1 = new RegExp(
`${URL_NEG_LB}${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}` `${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), match: (text) => text.match(SPOILER_REG_1),
html: (parse, match) => { html: (parse, match) => {
const [, g1] = match; const [, , g2] = match;
return `<span data-md="${SPOILER_MD_1}" data-mx-spoiler>${parse(g1)}</span>`; return `<span data-md="${SPOILER_MD_1}" data-mx-spoiler>${parse(g2)}</span>`;
}, },
}; };
const LINK_ALT = `\\[${MIN_ANY}\\]`; const LINK_ALT = `\\[${MIN_ANY}\\]`;
const LINK_URL = `\\((https?:\\/\\/.+?)\\)`; const LINK_URL = `\\((https?:\\/\\/.+?)\\)`;
const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`); const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`);
const LinkRule: MDRule = { const LinkRule: InlineMDRule = {
match: (text) => text.match(LINK_REG_1), match: (text) => text.match(LINK_REG_1),
html: (parse, match) => { html: (parse, match) => {
const [, g1, g2] = match; const [, g1, g2] = match;
@ -134,19 +153,11 @@ const LinkRule: MDRule = {
}, },
}; };
const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => const runInlineRule: InlineRuleRunner = (parse, text, rule) => {
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); const matchResult = rule.match(text);
if (matchResult) { if (matchResult) {
const content = rule.html(parse, matchResult); const content = rule.html(parse, matchResult);
return replaceMatch(parse, text, matchResult, content); return replaceMatch((txt) => [parse(txt)], text, matchResult, content).join('');
} }
return undefined; return undefined;
}; };
@ -155,10 +166,10 @@ const runRule: RuleRunner = (parse, text, rule) => {
* Runs multiple rules at the same time to better handle nested rules. * Runs multiple rules at the same time to better handle nested rules.
* Rules will be run in the order they appear. * 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)); const matchResults = rules.map((rule) => rule.match(text));
let targetRule: MDRule | undefined; let targetRule: InlineMDRule | undefined;
let targetResult: MatchResult | undefined; let targetResult: MatchResult | undefined;
for (let i = 0; i < matchResults.length; i += 1) { for (let i = 0; i < matchResults.length; i += 1) {
@ -176,7 +187,7 @@ const runRules: RulesRunner = (parse, text, rules) => {
if (targetRule && targetResult) { if (targetRule && targetResult) {
const content = targetRule.html(parse, targetResult); const content = targetRule.html(parse, targetResult);
return replaceMatch(parse, text, targetResult, content); return replaceMatch((txt) => [parse(txt)], text, targetResult, content).join('');
} }
return undefined; return undefined;
}; };
@ -191,11 +202,167 @@ const LeveledRules = [
LinkRule, LinkRule,
]; ];
export const parseInlineMD = (text: string): string => { export const parseInlineMD: InlineMDParser = (text) => {
if (text === '') return text;
let result: string | undefined; 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 `<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; return result ?? text;
}; };

View file

@ -256,7 +256,7 @@ export const getRoomAvatarUrl = (mx: MatrixClient, room: Room): string | undefin
}; };
export const trimReplyFromBody = (body: string): string => { 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; if (!match) return body;
return body.slice(match[0].length); return body.slice(match[0].length);
}; };

View file

@ -59,9 +59,18 @@ const permittedTagToAttributes = {
'data-md', 'data-md',
], ],
div: ['data-mx-maths'], 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'], a: ['name', 'target', 'href', 'rel', 'data-md'],
img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'], img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
ol: ['start'],
code: ['class', 'data-md'], code: ['class', 'data-md'],
strong: ['data-md'], strong: ['data-md'],
i: ['data-md'], i: ['data-md'],