From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 18 Oct 2023 02:15:30 +0000 (+1100) Subject: Editor Commands (#1450) X-Git-Tag: v3.0.0~15 X-Git-Url: https://git.wafflesoft.org/?a=commitdiff_plain;h=613e6d6503383d886b2d56925fd482069005937c;p=rainny.git Editor Commands (#1450) * add commands hook * add commands in editor * add command auto complete menu * add commands in room input * remove old reply code from room input * fix video component css * do not auto focus input on android or ios * fix crash on enable block after selection * fix circular deps in editor * fix autocomplete return focus move editor cursor * remove unwanted keydown from room input * fix emoji alignment in editor * test ipad user agent * refactor isAndroidOrIOS to mobileOrTablet * update slate & slate-react * downgrade slate-react to 0.98.4 0.99.0 has breaking changes with ReactEditor.focus * add sql to readable ext mimetype * fix empty editor formatting gets saved as draft * add option to use enter for newline * remove empty msg draft from atom family * prevent msg ctx menu from open on text selection --- diff --git a/package-lock.json b/package-lock.json index 70c90a9..1ef2fd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,9 +56,9 @@ "react-modal": "3.16.1", "react-range": "1.8.14", "sanitize-html": "2.8.0", - "slate": "0.90.0", + "slate": "0.94.1", "slate-history": "0.93.0", - "slate-react": "0.90.0", + "slate-react": "0.98.4", "tippy.js": "6.3.7", "twemoji": "14.0.2", "ua-parser-js": "1.0.35" @@ -5766,9 +5766,9 @@ } }, "node_modules/slate": { - "version": "0.90.0", - "resolved": "https://registry.npmjs.org/slate/-/slate-0.90.0.tgz", - "integrity": "sha512-dv8idv0JjYyHiAJcVKf5yWKPDMTDi+PSZyfjsnquEI8VB5nmTVGjeJab06lc3o69O7aN05ROwO9/OY8mU1IUPA==", + "version": "0.94.1", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.94.1.tgz", + "integrity": "sha512-GH/yizXr1ceBoZ9P9uebIaHe3dC/g6Plpf9nlUwnvoyf6V1UOYrRwkabtOCd3ZfIGxomY4P7lfgLr7FPH8/BKA==", "dependencies": { "immer": "^9.0.6", "is-plain-object": "^5.0.0", @@ -5787,9 +5787,9 @@ } }, "node_modules/slate-react": { - "version": "0.90.0", - "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.90.0.tgz", - "integrity": "sha512-z6pGd6jjU5VazLxlDi6zL3a6yaPBPJ+A2VyIlE/h/rvDywaLYGvk0xcrA9NrK71Dr47HK5ZN2zFEZNleh6wlPA==", + "version": "0.98.4", + "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.98.4.tgz", + "integrity": "sha512-8Of3v9hFuX8rIRc86LuuBhU9t8ps+9ARKL4yyhCrKQYZ93Ep/LFA3GvPGvtf3zYuVadZ8tkhRH8tbHOGNAndLw==", "dependencies": { "@juggle/resize-observer": "^3.4.0", "@types/is-hotkey": "^0.1.1", diff --git a/package.json b/package.json index 7467126..f7bce7c 100644 --- a/package.json +++ b/package.json @@ -66,9 +66,9 @@ "react-modal": "3.16.1", "react-range": "1.8.14", "sanitize-html": "2.8.0", - "slate": "0.90.0", + "slate": "0.94.1", "slate-history": "0.93.0", - "slate-react": "0.90.0", + "slate-react": "0.98.4", "tippy.js": "6.3.7", "twemoji": "14.0.2", "ua-parser-js": "1.0.35" diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 9ec8cfa..edce743 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -26,7 +26,7 @@ export const EditorTextarea = style([ { flexGrow: 1, height: '100%', - padding: `${toRem(13)} 0`, + padding: `${toRem(13)} ${toRem(1)}`, selectors: { [`${EditorTextareaScroll}:first-child &`]: { paddingLeft: toRem(13), @@ -34,6 +34,9 @@ export const EditorTextarea = style([ [`${EditorTextareaScroll}:last-child &`]: { paddingRight: toRem(13), }, + '&:focus': { + outline: 'none', + }, }, }, ]); diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 62b4134..044d083 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -18,7 +18,8 @@ import { RenderPlaceholderProps, } from 'slate-react'; import { withHistory } from 'slate-history'; -import { BlockType, RenderElement, RenderLeaf } from './Elements'; +import { BlockType } from './types'; +import { RenderElement, RenderLeaf } from './Elements'; import { CustomElement } from './slate'; import * as css from './Editor.css'; import { toggleKeyboardShortcut } from './keyboard'; @@ -34,8 +35,9 @@ const withInline = (editor: Editor): Editor => { const { isInline } = editor; editor.isInline = (element) => - [BlockType.Mention, BlockType.Emoticon, BlockType.Link].includes(element.type) || - isInline(element); + [BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes( + element.type + ) || isInline(element); return editor; }; @@ -44,7 +46,8 @@ const withVoid = (editor: Editor): Editor => { const { isVoid } = editor; editor.isVoid = (element) => - [BlockType.Mention, BlockType.Emoticon].includes(element.type) || isVoid(element); + [BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) || + isVoid(element); return editor; }; @@ -122,7 +125,7 @@ export const CustomEditor = forwardRef( return (
- + {top} {before && ( diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx index 2df8099..c4767ab 100644 --- a/src/app/components/editor/Elements.tsx +++ b/src/app/components/editor/Elements.tsx @@ -1,34 +1,18 @@ import { Scroll, Text } from 'folds'; import React from 'react'; -import { RenderElementProps, RenderLeafProps, useFocused, useSelected } from 'slate-react'; +import { + RenderElementProps, + RenderLeafProps, + useFocused, + useSelected, + useSlate, +} from 'slate-react'; import * as css from '../../styles/CustomHtml.css'; -import { EmoticonElement, LinkElement, MentionElement } from './slate'; +import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate'; import { useMatrixClient } from '../../hooks/useMatrixClient'; - -export enum MarkType { - Bold = 'bold', - Italic = 'italic', - Underline = 'underline', - StrikeThrough = 'strikeThrough', - Code = 'code', - Spoiler = 'spoiler', -} - -export enum BlockType { - Paragraph = 'paragraph', - Heading = 'heading', - CodeLine = 'code-line', - CodeBlock = 'code-block', - QuoteLine = 'quote-line', - BlockQuote = 'block-quote', - ListItem = 'list-item', - OrderedList = 'ordered-list', - UnorderedList = 'unordered-list', - Mention = 'mention', - Emoticon = 'emoticon', - Link = 'link', -} +import { getBeginCommand } from './utils'; +import { BlockType } from './types'; // Put this at the start and end of an inline component to work around this Chromium bug: // https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 @@ -62,6 +46,29 @@ function RenderMentionElement({ ); } +function RenderCommandElement({ + attributes, + element, + children, +}: { element: CommandElement } & RenderElementProps) { + const selected = useSelected(); + const focused = useFocused(); + const editor = useSlate(); + + return ( + + {`/${element.command}`} + {children} + + ); +} function RenderEmoticonElement({ attributes, @@ -200,6 +207,12 @@ export function RenderElement({ attributes, element, children }: RenderElementPr {children} ); + case BlockType.Command: + return ( + + {children} + + ); default: return ( diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx index 72e2c38..342dd10 100644 --- a/src/app/components/editor/Toolbar.tsx +++ b/src/app/components/editor/Toolbar.tsx @@ -25,9 +25,9 @@ import { removeAllMark, toggleBlock, toggleMark, -} from './common'; +} from './utils'; import * as css from './Editor.css'; -import { BlockType, MarkType } from './Elements'; +import { BlockType, MarkType } from './types'; import { HeadingLevel } from './slate'; import { isMacOS } from '../../utils/user-agent'; import { KeySymbol } from '../../utils/key-symbol'; diff --git a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx index d89cda0..e7c8df3 100644 --- a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx +++ b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx @@ -19,6 +19,7 @@ export function AutocompleteMenu({ headerContent, requestClose, children }: Auto focusTrapOptions={{ initialFocus: false, onDeactivate: () => requestClose(), + returnFocusOnDeactivate: false, clickOutsideDeactivates: true, allowOutsideClick: true, isKeyForward: (evt: KeyboardEvent) => isHotkey('arrowdown', evt), diff --git a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx index 2e55600..bc98667 100644 --- a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx @@ -12,7 +12,7 @@ import { useAsyncSearch, } from '../../../hooks/useAsyncSearch'; import { onTabPress } from '../../../utils/keyboard'; -import { createEmoticonElement, moveCursor, replaceWithElement } from '../common'; +import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRelevantImagePacks } from '../../../hooks/useImagePacks'; import { IEmoji, emojis } from '../../../plugins/emoji'; diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index baa217c..31acd2c 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -3,7 +3,7 @@ import { Editor } from 'slate'; import { Avatar, AvatarFallback, AvatarImage, Icon, Icons, MenuItem, Text, color } from 'folds'; import { MatrixClient } from 'matrix-js-sdk'; -import { createMentionElement, moveCursor, replaceWithElement } from '../common'; +import { createMentionElement, moveCursor, replaceWithElement } from '../utils'; import { getRoomAvatarUrl, joinRuleToIconSrc } from '../../../utils/room'; import { roomIdByActivity } from '../../../../util/sort'; import initMatrix from '../../../../client/initMatrix'; diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx index 00ecb01..a99274a 100644 --- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx @@ -13,7 +13,7 @@ import { useAsyncSearch, } from '../../../hooks/useAsyncSearch'; import { onTabPress } from '../../../utils/keyboard'; -import { createMentionElement, moveCursor, replaceWithElement } from '../common'; +import { createMentionElement, moveCursor, replaceWithElement } from '../utils'; import { useKeyDown } from '../../../hooks/useKeyDown'; import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix'; diff --git a/src/app/components/editor/autocomplete/autocompleteQuery.ts b/src/app/components/editor/autocomplete/autocompleteQuery.ts index 96dabc5..1baa44a 100644 --- a/src/app/components/editor/autocomplete/autocompleteQuery.ts +++ b/src/app/components/editor/autocomplete/autocompleteQuery.ts @@ -4,11 +4,13 @@ export enum AutocompletePrefix { RoomMention = '#', UserMention = '@', Emoticon = ':', + Command = '/', } export const AUTOCOMPLETE_PREFIXES: readonly AutocompletePrefix[] = [ AutocompletePrefix.RoomMention, AutocompletePrefix.UserMention, AutocompletePrefix.Emoticon, + AutocompletePrefix.Command, ]; export type AutocompleteQuery = { diff --git a/src/app/components/editor/common.ts b/src/app/components/editor/common.ts deleted file mode 100644 index 68717b3..0000000 --- a/src/app/components/editor/common.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { BasePoint, BaseRange, Editor, Element, Point, Range, Transforms } from 'slate'; -import { BlockType, MarkType } from './Elements'; -import { EmoticonElement, FormattedText, HeadingLevel, LinkElement, MentionElement } from './slate'; - -const ALL_MARK_TYPE: MarkType[] = [ - MarkType.Bold, - MarkType.Code, - MarkType.Italic, - MarkType.Spoiler, - MarkType.StrikeThrough, - MarkType.Underline, -]; - -export const isMarkActive = (editor: Editor, format: MarkType) => { - const marks = Editor.marks(editor); - return marks ? marks[format] === true : false; -}; - -export const isAnyMarkActive = (editor: Editor) => { - const marks = Editor.marks(editor); - return marks && !!ALL_MARK_TYPE.find((type) => marks[type] === true); -}; - -export const toggleMark = (editor: Editor, format: MarkType) => { - const isActive = isMarkActive(editor, format); - - if (isActive) { - Editor.removeMark(editor, format); - } else { - Editor.addMark(editor, format, true); - } -}; - -export const removeAllMark = (editor: Editor) => { - ALL_MARK_TYPE.forEach((mark) => { - if (isMarkActive(editor, mark)) Editor.removeMark(editor, mark); - }); -}; - -export const isBlockActive = (editor: Editor, format: BlockType) => { - const [match] = Editor.nodes(editor, { - match: (node) => Element.isElement(node) && node.type === format, - }); - - return !!match; -}; - -type BlockOption = { level: HeadingLevel }; -const NESTED_BLOCK = [ - BlockType.OrderedList, - BlockType.UnorderedList, - BlockType.BlockQuote, - BlockType.CodeBlock, -]; - -export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => { - const isActive = isBlockActive(editor, format); - - Transforms.unwrapNodes(editor, { - match: (node) => Element.isElement(node) && NESTED_BLOCK.includes(node.type), - split: true, - }); - - if (isActive) { - Transforms.setNodes(editor, { - type: BlockType.Paragraph, - }); - return; - } - - if (format === BlockType.OrderedList || format === BlockType.UnorderedList) { - Transforms.setNodes(editor, { - type: BlockType.ListItem, - }); - const block = { - type: format, - children: [], - }; - Transforms.wrapNodes(editor, block); - return; - } - if (format === BlockType.CodeBlock) { - Transforms.setNodes(editor, { - type: BlockType.CodeLine, - }); - const block = { - type: format, - children: [], - }; - Transforms.wrapNodes(editor, block); - return; - } - - if (format === BlockType.BlockQuote) { - Transforms.setNodes(editor, { - type: BlockType.QuoteLine, - }); - const block = { - type: format, - children: [], - }; - Transforms.wrapNodes(editor, block); - return; - } - - if (format === BlockType.Heading) { - Transforms.setNodes(editor, { - type: format, - level: option?.level ?? 1, - }); - } - - Transforms.setNodes(editor, { - type: format, - }); -}; - -export const resetEditor = (editor: Editor) => { - Transforms.delete(editor, { - at: { - anchor: Editor.start(editor, []), - focus: Editor.end(editor, []), - }, - }); - - toggleBlock(editor, BlockType.Paragraph); - removeAllMark(editor); -}; - -export const resetEditorHistory = (editor: Editor) => { - // eslint-disable-next-line no-param-reassign - editor.history = { - undos: [], - redos: [], - }; -}; - -export const createMentionElement = ( - id: string, - name: string, - highlight: boolean -): MentionElement => ({ - type: BlockType.Mention, - id, - highlight, - name, - children: [{ text: '' }], -}); - -export const createEmoticonElement = (key: string, shortcode: string): EmoticonElement => ({ - type: BlockType.Emoticon, - key, - shortcode, - children: [{ text: '' }], -}); - -export const createLinkElement = ( - href: string, - children: string | FormattedText[] -): LinkElement => ({ - type: BlockType.Link, - href, - children: typeof children === 'string' ? [{ text: children }] : children, -}); - -export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => { - Transforms.select(editor, selectRange); - Transforms.insertNodes(editor, element); -}; - -export const moveCursor = (editor: Editor, withSpace?: boolean) => { - // without timeout move cursor doesn't works properly. - setTimeout(() => { - Transforms.move(editor); - if (withSpace) editor.insertText(' '); - }, 100); -}; - -interface PointUntilCharOptions { - match: (char: string) => boolean; - reverse?: boolean; -} -export const getPointUntilChar = ( - editor: Editor, - cursorPoint: BasePoint, - options: PointUntilCharOptions -): BasePoint | undefined => { - let targetPoint: BasePoint | undefined; - let prevPoint: BasePoint | undefined; - let char: string | undefined; - - const pointItr = Editor.positions(editor, { - at: { - anchor: Editor.start(editor, []), - focus: Editor.point(editor, cursorPoint, { edge: 'start' }), - }, - unit: 'character', - reverse: options.reverse, - }); - - // eslint-disable-next-line no-restricted-syntax - for (const point of pointItr) { - if (!Point.equals(point, cursorPoint) && prevPoint) { - char = Editor.string(editor, { anchor: point, focus: prevPoint }); - - if (options.match(char)) break; - targetPoint = point; - } - prevPoint = point; - } - return targetPoint; -}; - -export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => { - const { selection } = editor; - if (!selection || !Range.isCollapsed(selection)) return undefined; - const [cursorPoint] = Range.edges(selection); - const worldStartPoint = getPointUntilChar(editor, cursorPoint, { - reverse: true, - match: (char) => char === ' ', - }); - 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; -}; diff --git a/src/app/components/editor/index.ts b/src/app/components/editor/index.ts index 7c63ce6..aae0137 100644 --- a/src/app/components/editor/index.ts +++ b/src/app/components/editor/index.ts @@ -1,8 +1,9 @@ export * from './autocomplete'; -export * from './common'; +export * from './utils'; export * from './Editor'; export * from './Elements'; export * from './keyboard'; export * from './output'; export * from './Toolbar'; export * from './input'; +export * from './types'; diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts index 39db0e1..37aa724 100644 --- a/src/app/components/editor/input.ts +++ b/src/app/components/editor/input.ts @@ -4,7 +4,7 @@ import parse from 'html-dom-parser'; import { ChildNode, Element, isText, isTag } from 'domhandler'; import { sanitizeCustomHtml } from '../../utils/sanitize'; -import { BlockType, MarkType } from './Elements'; +import { BlockType, MarkType } from './types'; import { BlockQuoteElement, CodeBlockElement, @@ -21,7 +21,7 @@ import { UnorderedListElement, } from './slate'; import { parseMatrixToUrl } from '../../utils/matrix'; -import { createEmoticonElement, createMentionElement } from './common'; +import { createEmoticonElement, createMentionElement } from './utils'; const markNodeToType: Record = { b: MarkType.Bold, diff --git a/src/app/components/editor/keyboard.ts b/src/app/components/editor/keyboard.ts index b6e1c3f..b6d4d69 100644 --- a/src/app/components/editor/keyboard.ts +++ b/src/app/components/editor/keyboard.ts @@ -1,8 +1,8 @@ import { isHotkey } from 'is-hotkey'; import { KeyboardEvent } from 'react'; import { Editor } from 'slate'; -import { isAnyMarkActive, isBlockActive, removeAllMark, toggleBlock, toggleMark } from './common'; -import { BlockType, MarkType } from './Elements'; +import { isAnyMarkActive, isBlockActive, removeAllMark, toggleBlock, toggleMark } from './utils'; +import { BlockType, MarkType } from './types'; export const INLINE_HOTKEYS: Record = { 'mod+b': MarkType.Bold, diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index 89a5f7c..307ef8a 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -1,7 +1,7 @@ import { Descendant, Text } from 'slate'; import { sanitizeText } from '../../utils/sanitize'; -import { BlockType } from './Elements'; +import { BlockType } from './types'; import { CustomElement } from './slate'; import { parseInlineMD } from '../../utils/markdown'; @@ -57,6 +57,8 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => { : node.key; case BlockType.Link: return `${node.children}`; + case BlockType.Command: + return `/${node.command}`; default: return children; } @@ -104,6 +106,8 @@ const elementToPlainText = (node: CustomElement, children: string): string => { return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key; case BlockType.Link: return `[${node.children}](${node.href})`; + case BlockType.Command: + return `/${node.command}`; default: return children; } @@ -129,4 +133,12 @@ export const toPlainText = (node: Descendant | Descendant[]): string => { export const customHtmlEqualsPlainText = (customHtml: string, plain: string): boolean => customHtml.replace(//g, '\n') === sanitizeText(plain); -export const trimCustomHtml = (customHtml: string) => customHtml.replace(/$/g, ''); +export const trimCustomHtml = (customHtml: string) => customHtml.replace(/$/g, '').trim(); + +export const trimCommand = (cmdName: string, str: string) => { + const cmdRegX = new RegExp(`^(\\s+)?(\\/${cmdName})([^\\S\n]+)?`); + + const match = str.match(cmdRegX); + if (!match) return str; + return str.slice(match[0].length); +}; diff --git a/src/app/components/editor/slate.d.ts b/src/app/components/editor/slate.d.ts index ee046a0..1b08ae8 100644 --- a/src/app/components/editor/slate.d.ts +++ b/src/app/components/editor/slate.d.ts @@ -1,7 +1,7 @@ import { BaseEditor } from 'slate'; import { ReactEditor } from 'slate-react'; import { HistoryEditor } from 'slate-history'; -import { BlockType } from './Elements'; +import { BlockType } from './types'; export type HeadingLevel = 1 | 2 | 3; @@ -39,8 +39,13 @@ export type EmoticonElement = { shortcode: string; children: Text[]; }; +export type CommandElement = { + type: BlockType.Command; + command: string; + children: Text[]; +}; -export type InlineElement = Text | LinkElement | MentionElement | EmoticonElement; +export type InlineElement = Text | LinkElement | MentionElement | EmoticonElement | CommandElement; export type ParagraphElement = { type: BlockType.Paragraph; @@ -84,6 +89,7 @@ export type CustomElement = | LinkElement | MentionElement | EmoticonElement + | CommandElement | ParagraphElement | HeadingElement | CodeLineElement diff --git a/src/app/components/editor/types.ts b/src/app/components/editor/types.ts new file mode 100644 index 0000000..9a108ec --- /dev/null +++ b/src/app/components/editor/types.ts @@ -0,0 +1,24 @@ +export enum MarkType { + Bold = 'bold', + Italic = 'italic', + Underline = 'underline', + StrikeThrough = 'strikeThrough', + Code = 'code', + Spoiler = 'spoiler', +} + +export enum BlockType { + Paragraph = 'paragraph', + Heading = 'heading', + CodeLine = 'code-line', + CodeBlock = 'code-block', + QuoteLine = 'quote-line', + BlockQuote = 'block-quote', + ListItem = 'list-item', + OrderedList = 'ordered-list', + UnorderedList = 'unordered-list', + Mention = 'mention', + Emoticon = 'emoticon', + Link = 'link', + Command = 'command', +} diff --git a/src/app/components/editor/utils.ts b/src/app/components/editor/utils.ts new file mode 100644 index 0000000..9bdfde1 --- /dev/null +++ b/src/app/components/editor/utils.ts @@ -0,0 +1,261 @@ +import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate'; +import { BlockType, MarkType } from './types'; +import { + CommandElement, + EmoticonElement, + FormattedText, + HeadingLevel, + LinkElement, + MentionElement, +} from './slate'; + +const ALL_MARK_TYPE: MarkType[] = [ + MarkType.Bold, + MarkType.Code, + MarkType.Italic, + MarkType.Spoiler, + MarkType.StrikeThrough, + MarkType.Underline, +]; + +export const isMarkActive = (editor: Editor, format: MarkType) => { + const marks = Editor.marks(editor); + return marks ? marks[format] === true : false; +}; + +export const isAnyMarkActive = (editor: Editor) => { + const marks = Editor.marks(editor); + return marks && !!ALL_MARK_TYPE.find((type) => marks[type] === true); +}; + +export const toggleMark = (editor: Editor, format: MarkType) => { + const isActive = isMarkActive(editor, format); + + if (isActive) { + Editor.removeMark(editor, format); + } else { + Editor.addMark(editor, format, true); + } +}; + +export const removeAllMark = (editor: Editor) => { + ALL_MARK_TYPE.forEach((mark) => { + if (isMarkActive(editor, mark)) Editor.removeMark(editor, mark); + }); +}; + +export const isBlockActive = (editor: Editor, format: BlockType) => { + const [match] = Editor.nodes(editor, { + match: (node) => Element.isElement(node) && node.type === format, + }); + + return !!match; +}; + +type BlockOption = { level: HeadingLevel }; +const NESTED_BLOCK = [ + BlockType.OrderedList, + BlockType.UnorderedList, + BlockType.BlockQuote, + BlockType.CodeBlock, +]; + +export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => { + Transforms.collapse(editor, { + edge: 'end', + }); + const isActive = isBlockActive(editor, format); + + Transforms.unwrapNodes(editor, { + match: (node) => Element.isElement(node) && NESTED_BLOCK.includes(node.type), + split: true, + }); + + if (isActive) { + Transforms.setNodes(editor, { + type: BlockType.Paragraph, + }); + return; + } + + if (format === BlockType.OrderedList || format === BlockType.UnorderedList) { + Transforms.setNodes(editor, { + type: BlockType.ListItem, + }); + const block = { + type: format, + children: [], + }; + Transforms.wrapNodes(editor, block); + return; + } + if (format === BlockType.CodeBlock) { + Transforms.setNodes(editor, { + type: BlockType.CodeLine, + }); + const block = { + type: format, + children: [], + }; + Transforms.wrapNodes(editor, block); + return; + } + + if (format === BlockType.BlockQuote) { + Transforms.setNodes(editor, { + type: BlockType.QuoteLine, + }); + const block = { + type: format, + children: [], + }; + Transforms.wrapNodes(editor, block); + return; + } + + if (format === BlockType.Heading) { + Transforms.setNodes(editor, { + type: format, + level: option?.level ?? 1, + }); + } + + Transforms.setNodes(editor, { + type: format, + }); +}; + +export const resetEditor = (editor: Editor) => { + Transforms.delete(editor, { + at: { + anchor: Editor.start(editor, []), + focus: Editor.end(editor, []), + }, + }); + + toggleBlock(editor, BlockType.Paragraph); + removeAllMark(editor); +}; + +export const resetEditorHistory = (editor: Editor) => { + // eslint-disable-next-line no-param-reassign + editor.history = { + undos: [], + redos: [], + }; +}; + +export const createMentionElement = ( + id: string, + name: string, + highlight: boolean +): MentionElement => ({ + type: BlockType.Mention, + id, + highlight, + name, + children: [{ text: '' }], +}); + +export const createEmoticonElement = (key: string, shortcode: string): EmoticonElement => ({ + type: BlockType.Emoticon, + key, + shortcode, + children: [{ text: '' }], +}); + +export const createLinkElement = ( + href: string, + children: string | FormattedText[] +): LinkElement => ({ + type: BlockType.Link, + href, + children: typeof children === 'string' ? [{ text: children }] : children, +}); + +export const createCommandElement = (command: string): CommandElement => ({ + type: BlockType.Command, + command, + children: [{ text: '' }], +}); + +export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => { + Transforms.select(editor, selectRange); + Transforms.insertNodes(editor, element); + Transforms.collapse(editor, { + edge: 'end', + }); +}; + +export const moveCursor = (editor: Editor, withSpace?: boolean) => { + Transforms.move(editor); + if (withSpace) editor.insertText(' '); +}; + +interface PointUntilCharOptions { + match: (char: string) => boolean; + reverse?: boolean; +} +export const getPointUntilChar = ( + editor: Editor, + cursorPoint: BasePoint, + options: PointUntilCharOptions +): BasePoint | undefined => { + let targetPoint: BasePoint | undefined; + let prevPoint: BasePoint | undefined; + let char: string | undefined; + + const pointItr = Editor.positions(editor, { + at: { + anchor: Editor.start(editor, []), + focus: Editor.point(editor, cursorPoint, { edge: 'start' }), + }, + unit: 'character', + reverse: options.reverse, + }); + + // eslint-disable-next-line no-restricted-syntax + for (const point of pointItr) { + if (!Point.equals(point, cursorPoint) && prevPoint) { + char = Editor.string(editor, { anchor: point, focus: prevPoint }); + + if (options.match(char)) break; + targetPoint = point; + } + prevPoint = point; + } + return targetPoint; +}; + +export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => { + const { selection } = editor; + if (!selection || !Range.isCollapsed(selection)) return undefined; + const [cursorPoint] = Range.edges(selection); + const worldStartPoint = getPointUntilChar(editor, cursorPoint, { + reverse: true, + match: (char) => char === ' ', + }); + 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; +}; + +export const getBeginCommand = (editor: Editor): string | undefined => { + const lineBlock = editor.children[0]; + if (!Element.isElement(lineBlock)) return undefined; + if (lineBlock.type !== BlockType.Paragraph) return undefined; + + const [firstInline, secondInline] = lineBlock.children; + const isEmptyText = Text.isText(firstInline) && firstInline.text.trim() === ''; + if (!isEmptyText) return undefined; + if (Element.isElement(secondInline) && secondInline.type === BlockType.Command) + return secondInline.command; + return undefined; +}; diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 81730e3..067ebe3 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -47,6 +47,7 @@ import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearc import { useDebounce } from '../../hooks/useDebounce'; import { useThrottle } from '../../hooks/useThrottle'; import { addRecentEmoji } from '../../plugins/recent-emoji'; +import { mobileOrTablet } from '../../utils/user-agent'; const RECENT_GROUP_ID = 'recent_group'; const SEARCH_GROUP_ID = 'search_group'; @@ -782,7 +783,7 @@ export function EmojiBoard({ maxLength={50} after={} onChange={handleOnChange} - autoFocus + autoFocus={!mobileOrTablet()} /> diff --git a/src/app/components/media/Video.tsx b/src/app/components/media/Video.tsx index ab13c5b..03108c3 100644 --- a/src/app/components/media/Video.tsx +++ b/src/app/components/media/Video.tsx @@ -5,6 +5,6 @@ import * as css from './media.css'; export const Video = forwardRef>( ({ className, ...props }, ref) => ( // eslint-disable-next-line jsx-a11y/media-has-caption -