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/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/common.ts b/src/app/components/editor/utils.ts similarity index 82% rename from src/app/components/editor/common.ts rename to src/app/components/editor/utils.ts index 68717b3..9bdfde1 100644 --- a/src/app/components/editor/common.ts +++ b/src/app/components/editor/utils.ts @@ -1,6 +1,13 @@ -import { BasePoint, BaseRange, Editor, Element, Point, Range, Transforms } from 'slate'; -import { BlockType, MarkType } from './Elements'; -import { EmoticonElement, FormattedText, HeadingLevel, LinkElement, MentionElement } from './slate'; +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, @@ -54,6 +61,9 @@ const NESTED_BLOCK = [ ]; export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => { + Transforms.collapse(editor, { + edge: 'end', + }); const isActive = isBlockActive(editor, format); Transforms.unwrapNodes(editor, { @@ -163,17 +173,23 @@ export const createLinkElement = ( 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) => { - // without timeout move cursor doesn't works properly. - setTimeout(() => { - Transforms.move(editor); - if (withSpace) editor.insertText(' '); - }, 100); + Transforms.move(editor); + if (withSpace) editor.insertText(' '); }; interface PointUntilCharOptions { @@ -230,3 +246,16 @@ export const isEmptyEditor = (editor: Editor): boolean => { } 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 -