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:
parent
72bb5b42af
commit
b24f858369
15 changed files with 425 additions and 160 deletions
|
@ -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) => (
|
||||||
|
|
|
@ -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,
|
||||||
type: BlockType.CodeLine,
|
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: [
|
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 {
|
return [
|
||||||
type: BlockType.CodeBlock,
|
{
|
||||||
children,
|
type: BlockType.CodeBlock,
|
||||||
};
|
children: codeLines.map<CodeLineElement>((lineTxt) => ({
|
||||||
|
type: BlockType.CodeLine,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
text: lineTxt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
};
|
};
|
||||||
const parseListNode = (node: Element): OrderedListElement | UnorderedListElement => {
|
const parseListNode = (
|
||||||
const children: ListItemElement[] = [];
|
node: Element
|
||||||
|
): 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 => {
|
const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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(/^>/, '>');
|
||||||
|
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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -81,5 +81,5 @@ export const ReactionsContainer = style({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ReactionsTooltipText = style({
|
export const ReactionsTooltipText = style({
|
||||||
wordBreak: 'break-all',
|
wordBreak: 'break-word',
|
||||||
});
|
});
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>}>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
Loading…
Reference in a new issue