mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-12 18:20:28 +03:00
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:
parent
152576e85d
commit
f5bcc9b851
18 changed files with 957 additions and 108 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,3 +5,4 @@ export * from './Elements';
|
|||
export * from './keyboard';
|
||||
export * from './output';
|
||||
export * from './Toolbar';
|
||||
export * from './input';
|
||||
|
|
|
|||
327
src/app/components/editor/input.ts
Normal file
327
src/app/components/editor/input.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
19
src/app/components/editor/slate.d.ts
vendored
19
src/app/components/editor/slate.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue