mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-12 02:00:28 +03:00
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
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue