Edit option (#1447)

* add func to parse html to editor input

* add  plain to html input function

* re-construct markdown

* fix missing return

* fix falsy condition

* fix reading href instead of src of emoji

* add message editor - WIP

* fix plain to editor input func

* add save edit message functionality

* show edited event source code

* focus message input on after editing message

* use del tag for strike-through instead of s

* prevent autocomplete from re-opening after esc

* scroll out of view msg editor in view

* handle up arrow edit

* handle scroll to message editor without effect

* revert prev commit: effect run after editor render

* ignore relation event from editable

* allow data-md tag for del and em in sanitize html

* prevent edit without changes

* ignore previous reply when replying to msg

* fix up arrow edit not working sometime
This commit is contained in:
Ajay Bura 2023-10-14 16:08:43 +11:00 committed by GitHub
parent 152576e85d
commit f5bcc9b851
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 957 additions and 108 deletions

View file

@ -50,12 +50,13 @@ const withVoid = (editor: Editor): Editor => {
};
export const useEditor = (): Editor => {
const [editor] = useState(withInline(withVoid(withReact(withHistory(createEditor())))));
const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor())))));
return editor;
};
export type EditorChangeHandler = (value: Descendant[]) => void;
type CustomEditorProps = {
editableName?: string;
top?: ReactNode;
bottom?: ReactNode;
before?: ReactNode;
@ -71,6 +72,7 @@ type CustomEditorProps = {
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
(
{
editableName,
top,
bottom,
before,
@ -137,6 +139,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
hideTrack
>
<Editable
data-editable-name={editableName}
className={css.EditorTextarea}
placeholder={placeholder}
renderPlaceholder={renderPlaceholder}

View file

@ -221,3 +221,12 @@ export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => {
});
return worldStartPoint && Editor.range(editor, worldStartPoint, cursorPoint);
};
export const isEmptyEditor = (editor: Editor): boolean => {
const firstChildren = editor.children[0];
if (firstChildren && Element.isElement(firstChildren)) {
const isEmpty = editor.children.length === 1 && Editor.isEmpty(editor, firstChildren);
return isEmpty;
}
return false;
};

View file

@ -5,3 +5,4 @@ export * from './Elements';
export * from './keyboard';
export * from './output';
export * from './Toolbar';
export * from './input';

View file

@ -0,0 +1,327 @@
/* eslint-disable no-param-reassign */
import { Descendant, Text } from 'slate';
import parse from 'html-dom-parser';
import { ChildNode, Element, isText, isTag } from 'domhandler';
import { sanitizeCustomHtml } from '../../utils/sanitize';
import { BlockType, MarkType } from './Elements';
import {
BlockQuoteElement,
CodeBlockElement,
CodeLineElement,
EmoticonElement,
HeadingElement,
HeadingLevel,
InlineElement,
ListItemElement,
MentionElement,
OrderedListElement,
ParagraphElement,
QuoteLineElement,
UnorderedListElement,
} from './slate';
import { parseMatrixToUrl } from '../../utils/matrix';
import { createEmoticonElement, createMentionElement } from './common';
const markNodeToType: Record<string, MarkType> = {
b: MarkType.Bold,
strong: MarkType.Bold,
i: MarkType.Italic,
em: MarkType.Italic,
u: MarkType.Underline,
s: MarkType.StrikeThrough,
del: MarkType.StrikeThrough,
code: MarkType.Code,
span: MarkType.Spoiler,
};
const elementToTextMark = (node: Element): MarkType | undefined => {
const markType = markNodeToType[node.name];
if (!markType) return undefined;
if (markType === MarkType.Spoiler && node.attribs['data-mx-spoiler'] === undefined) {
return undefined;
}
if (
markType === MarkType.Code &&
node.parent &&
'name' in node.parent &&
node.parent.name === 'pre'
) {
return undefined;
}
return markType;
};
const parseNodeText = (node: ChildNode): string => {
if (isText(node)) {
return node.data;
}
if (isTag(node)) {
return node.children.map((child) => parseNodeText(child)).join('');
}
return '';
};
const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | undefined => {
if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) {
const { src, alt } = node.attribs;
if (!src) return undefined;
return createEmoticonElement(src, alt || 'Unknown Emoji');
}
if (node.name === 'a') {
const { href } = node.attribs;
if (typeof href !== 'string') return undefined;
const [mxId] = parseMatrixToUrl(href);
if (mxId) {
return createMentionElement(mxId, mxId, false);
}
}
return undefined;
};
const parseInlineNodes = (node: ChildNode): InlineElement[] => {
if (isText(node)) {
return [{ text: node.data }];
}
if (isTag(node)) {
const markType = elementToTextMark(node);
if (markType) {
const children = node.children.flatMap(parseInlineNodes);
if (node.attribs['data-md'] !== undefined) {
children.unshift({ text: node.attribs['data-md'] });
children.push({ text: node.attribs['data-md'] });
} else {
children.forEach((child) => {
if (Text.isText(child)) {
child[markType] = true;
}
});
}
return children;
}
const inlineNode = elementToInlineNode(node);
if (inlineNode) return [inlineNode];
if (node.name === 'a') {
const children = node.childNodes.flatMap(parseInlineNodes);
children.unshift({ text: '[' });
children.push({ text: `](${node.attribs.href})` });
return children;
}
return node.childNodes.flatMap(parseInlineNodes);
}
return [];
};
const parseBlockquoteNode = (node: Element): BlockQuoteElement => {
const children: QuoteLineElement[] = [];
let lineHolder: InlineElement[] = [];
const appendLine = () => {
if (lineHolder.length === 0) return;
children.push({
type: BlockType.QuoteLine,
children: lineHolder,
});
lineHolder = [];
};
node.children.forEach((child) => {
if (isText(child)) {
lineHolder.push({ text: child.data });
return;
}
if (isTag(child)) {
if (child.name === 'br') {
appendLine();
return;
}
if (child.name === 'p') {
appendLine();
children.push({
type: BlockType.QuoteLine,
children: child.children.flatMap((c) => parseInlineNodes(c)),
});
return;
}
parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
}
});
appendLine();
return {
type: BlockType.BlockQuote,
children,
};
};
const parseCodeBlockNode = (node: Element): CodeBlockElement => {
const children: CodeLineElement[] = [];
const code = parseNodeText(node).trim();
code.split('\n').forEach((lineTxt) =>
children.push({
type: BlockType.CodeLine,
children: [
{
text: lineTxt,
},
],
})
);
return {
type: BlockType.CodeBlock,
children,
};
};
const parseListNode = (node: Element): OrderedListElement | UnorderedListElement => {
const children: ListItemElement[] = [];
let lineHolder: InlineElement[] = [];
const appendLine = () => {
if (lineHolder.length === 0) return;
children.push({
type: BlockType.ListItem,
children: lineHolder,
});
lineHolder = [];
};
node.children.forEach((child) => {
if (isText(child)) {
lineHolder.push({ text: child.data });
return;
}
if (isTag(child)) {
if (child.name === 'br') {
appendLine();
return;
}
if (child.name === 'li') {
appendLine();
children.push({
type: BlockType.ListItem,
children: child.children.flatMap((c) => parseInlineNodes(c)),
});
return;
}
parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
}
});
appendLine();
return {
type: node.name === 'ol' ? BlockType.OrderedList : BlockType.UnorderedList,
children,
};
};
const parseHeadingNode = (node: Element): HeadingElement => {
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);
return {
type: BlockType.Heading,
level: (level <= 3 ? level : 3) as HeadingLevel,
children,
};
};
export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
const children: Descendant[] = [];
let lineHolder: InlineElement[] = [];
const appendLine = () => {
if (lineHolder.length === 0) return;
children.push({
type: BlockType.Paragraph,
children: lineHolder,
});
lineHolder = [];
};
domNodes.forEach((node) => {
if (isText(node)) {
lineHolder.push({ text: node.data });
return;
}
if (isTag(node)) {
if (node.name === 'br') {
appendLine();
return;
}
if (node.name === 'p') {
appendLine();
children.push({
type: BlockType.Paragraph,
children: node.children.flatMap((child) => parseInlineNodes(child)),
});
return;
}
if (node.name === 'blockquote') {
appendLine();
children.push(parseBlockquoteNode(node));
return;
}
if (node.name === 'pre') {
appendLine();
children.push(parseCodeBlockNode(node));
return;
}
if (node.name === 'ol' || node.name === 'ul') {
appendLine();
children.push(parseListNode(node));
return;
}
if (node.name.match(/^h[123456]$/)) {
appendLine();
children.push(parseHeadingNode(node));
return;
}
parseInlineNodes(node).forEach((inlineNode) => lineHolder.push(inlineNode));
}
});
appendLine();
return children;
};
export const htmlToEditorInput = (unsafeHtml: string): Descendant[] => {
const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
const domNodes = parse(sanitizedHtml);
const editorNodes = domToEditorInput(domNodes);
return editorNodes;
};
export const plainToEditorInput = (text: string): Descendant[] => {
const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
const paragraphNode: ParagraphElement = {
type: BlockType.Paragraph,
children: [
{
text: lineText,
},
],
};
return paragraphNode;
});
return editorNodes;
};

View file

@ -1,7 +1,8 @@
import { Descendant, Text } from 'slate';
import { sanitizeText } from '../../utils/sanitize';
import { BlockType } from './Elements';
import { CustomElement, FormattedText } from './slate';
import { CustomElement } from './slate';
import { parseInlineMD } from '../../utils/markdown';
export type OutputOptions = {
@ -9,13 +10,13 @@ export type OutputOptions = {
allowMarkdown?: boolean;
};
const textToCustomHtml = (node: FormattedText, opts: OutputOptions): string => {
const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
let string = sanitizeText(node.text);
if (opts.allowTextFormatting) {
if (node.bold) string = `<strong>${string}</strong>`;
if (node.italic) string = `<i>${string}</i>`;
if (node.underline) string = `<u>${string}</u>`;
if (node.strikeThrough) string = `<s>${string}</s>`;
if (node.strikeThrough) string = `<del>${string}</del>`;
if (node.code) string = `<code>${string}</code>`;
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
}
@ -47,6 +48,7 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
return `<ol>${children}</ol>`;
case BlockType.UnorderedList:
return `<ul>${children}</ul>`;
case BlockType.Mention:
return `<a href="https://matrix.to/#/${node.id}">${node.name}</a>`;
case BlockType.Emoticon:

View file

@ -23,13 +23,9 @@ export type FormattedText = Text & {
export type LinkElement = {
type: BlockType.Link;
href: string;
children: FormattedText[];
};
export type SpoilerElement = {
type: 'spoiler';
alert?: string;
children: FormattedText[];
children: Text[];
};
export type MentionElement = {
type: BlockType.Mention;
id: string;
@ -44,14 +40,16 @@ export type EmoticonElement = {
children: Text[];
};
export type InlineElement = Text | LinkElement | MentionElement | EmoticonElement;
export type ParagraphElement = {
type: BlockType.Paragraph;
children: FormattedText[];
children: InlineElement[];
};
export type HeadingElement = {
type: BlockType.Heading;
level: HeadingLevel;
children: FormattedText[];
children: InlineElement[];
};
export type CodeLineElement = {
type: BlockType.CodeLine;
@ -63,7 +61,7 @@ export type CodeBlockElement = {
};
export type QuoteLineElement = {
type: BlockType.QuoteLine;
children: FormattedText[];
children: InlineElement[];
};
export type BlockQuoteElement = {
type: BlockType.BlockQuote;
@ -71,7 +69,7 @@ export type BlockQuoteElement = {
};
export type ListItemElement = {
type: BlockType.ListItem;
children: FormattedText[];
children: InlineElement[];
};
export type OrderedListElement = {
type: BlockType.OrderedList;
@ -84,7 +82,6 @@ export type UnorderedListElement = {
export type CustomElement =
| LinkElement
// | SpoilerElement
| MentionElement
| EmoticonElement
| ParagraphElement