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 }}>
<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) => (

View 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,
};
};
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({
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<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,
children: [
{
text: lineTxt,
},
],
})
);
return {
type: BlockType.CodeBlock,
children,
};
})),
},
];
};
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;
}

View 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.

View 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);

View 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

View 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;

View 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

View 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,
})
);

View file

@ -81,5 +81,5 @@ export const ReactionsContainer = 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>}
/>
<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"

View 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;
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) => {
const emojifyHtml = sanitizeText(unsafeText).replace(
EMOJI_REG,
(emoji) =>
`<span class="${css.EmoticonBase}"><span class="${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>}>

View file

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

View 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)),
];
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 `<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 = 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;
};

View 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);
};

View 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'],