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
This commit is contained in:
Ajay Bura 2023-10-18 13:15:30 +11:00 committed by GitHub
parent 4d0b6b93bc
commit 613e6d6503
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 620 additions and 131 deletions

View file

@ -0,0 +1,109 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
import { Editor } from 'slate';
import { Box, MenuItem, Text } from 'folds';
import { Room } from 'matrix-js-sdk';
import { Command, useCommands } from '../../hooks/useCommands';
import {
AutocompleteMenu,
AutocompleteQuery,
createCommandElement,
moveCursor,
replaceWithElement,
} from '../../components/editor';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useKeyDown } from '../../hooks/useKeyDown';
import { onTabPress } from '../../utils/keyboard';
type CommandAutoCompleteHandler = (commandName: string) => void;
type CommandAutocompleteProps = {
room: Room;
editor: Editor;
query: AutocompleteQuery<string>;
requestClose: () => void;
};
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
matchOptions: {
contain: true,
},
};
export function CommandAutocomplete({
room,
editor,
query,
requestClose,
}: CommandAutocompleteProps) {
const mx = useMatrixClient();
const commands = useCommands(mx, room);
const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]);
const [result, search, resetSearch] = useAsyncSearch(
commandNames,
useCallback((commandName: string) => commandName, []),
SEARCH_OPTIONS
);
const autoCompleteNames = result ? result.items : commandNames;
useEffect(() => {
if (query.text) search(query.text);
else resetSearch();
}, [query.text, search, resetSearch]);
const handleAutocomplete: CommandAutoCompleteHandler = (commandName) => {
const cmdEl = createCommandElement(commandName);
replaceWithElement(editor, query.range, cmdEl);
moveCursor(editor, true);
requestClose();
};
useKeyDown(window, (evt: KeyboardEvent) => {
onTabPress(evt, () => {
if (autoCompleteNames.length === 0) {
return;
}
const cmdName = autoCompleteNames[0];
handleAutocomplete(cmdName);
});
});
return autoCompleteNames.length === 0 ? null : (
<AutocompleteMenu
headerContent={
<Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
<Text size="L400">Commands</Text>
<Text size="T200" priority="300" truncate>
Begin your message with command
</Text>
</Box>
}
requestClose={requestClose}
>
{autoCompleteNames.map((commandName) => (
<MenuItem
key={commandName}
as="button"
radii="300"
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(commandName))
}
onClick={() => handleAutocomplete(commandName)}
>
<Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
<Box shrink="No">
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{`/${commandName}`}
</Text>
</Box>
<Text truncate priority="300" size="T200">
{commands[commandName].description}
</Text>
</Box>
</MenuItem>
))}
</AutocompleteMenu>
);
}

View file

@ -12,7 +12,7 @@ import { useAtom } from 'jotai';
import isHotkey from 'is-hotkey';
import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { Transforms, Range, Editor } from 'slate';
import { Transforms, Editor } from 'slate';
import {
Box,
Dialog,
@ -52,6 +52,8 @@ import {
customHtmlEqualsPlainText,
trimCustomHtml,
isEmptyEditor,
getBeginCommand,
trimCommand,
} from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
@ -92,8 +94,6 @@ import {
getImageMsgContent,
getVideoMsgContent,
} from './msgContent';
import navigation from '../../../client/state/navigation';
import cons from '../../../client/state/cons';
import { MessageReply } from '../../molecules/message/Message';
import colorMXID from '../../../util/colorMXID';
import {
@ -104,17 +104,22 @@ import {
} from '../../utils/room';
import { sanitizeText } from '../../utils/sanitize';
import { useScreenSize } from '../../hooks/useScreenSize';
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent';
interface RoomInputProps {
editor: Editor;
roomViewRef: RefObject<HTMLElement>;
roomId: string;
room: Room;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ editor, roomViewRef, roomId }, ref) => {
({ editor, roomViewRef, roomId, room }, ref) => {
const mx = useMatrixClient();
const room = mx.getRoom(roomId);
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const commands = useCommands(mx, room);
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
@ -176,36 +181,19 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}, [editor, msgDraft]);
useEffect(() => {
ReactEditor.focus(editor);
if (!mobileOrTablet()) ReactEditor.focus(editor);
return () => {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
} else {
roomIdToMsgDraftAtomFamily.remove(roomId);
}
resetEditor(editor);
resetEditorHistory(editor);
};
}, [roomId, editor, setMsgDraft]);
useEffect(() => {
const handleReplyTo = (
userId: string,
eventId: string,
body: string,
formattedBody: string
) => {
setReplyDraft({
userId,
eventId,
body,
formattedBody,
});
ReactEditor.focus(editor);
};
navigation.on(cons.events.navigation.REPLY_TO_CLICKED, handleReplyTo);
return () => {
navigation.removeListener(cons.events.navigation.REPLY_TO_CLICKED, handleReplyTo);
};
}, [setReplyDraft, editor]);
const handleRemoveUpload = useCallback(
(upload: TUploadContent | TUploadContent[]) => {
const uploads = Array.isArray(upload) ? upload : [upload];
@ -257,13 +245,38 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const submit = useCallback(() => {
uploadBoardHandlers.current?.handleSend();
const plainText = toPlainText(editor.children).trim();
const customHtml = trimCustomHtml(
const commandName = getBeginCommand(editor);
let plainText = toPlainText(editor.children).trim();
let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, {
allowTextFormatting: true,
allowMarkdown: isMarkdown,
})
);
let msgType = MsgType.Text;
if (commandName) {
plainText = trimCommand(commandName, plainText);
customHtml = trimCommand(commandName, customHtml);
}
if (commandName === Command.Me) {
msgType = MsgType.Emote;
} else if (commandName === Command.Notice) {
msgType = MsgType.Notice;
} else if (commandName === Command.Shrug) {
plainText = `${SHRUG} ${plainText}`;
customHtml = `${SHRUG} ${customHtml}`;
} else if (commandName) {
const commandContent = commands[commandName as Command];
if (commandContent) {
commandContent.exe(plainText);
}
resetEditor(editor);
resetEditorHistory(editor);
sendTypingStatus(false);
return;
}
if (plainText === '') return;
@ -283,7 +296,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
const content: IContent = {
msgtype: MsgType.Text,
msgtype: msgType,
body,
};
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
@ -302,11 +315,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
resetEditorHistory(editor);
setReplyDraft();
sendTypingStatus(false);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown]);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
if (isHotkey('enter', evt)) {
if (enterForNewline ? isHotkey('shift+enter', evt) : isHotkey('enter', evt)) {
evt.preventDefault();
submit();
}
@ -314,19 +327,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
evt.preventDefault();
setReplyDraft();
}
if (editor.selection && Range.isCollapsed(editor.selection)) {
if (isHotkey('arrowleft', evt)) {
evt.preventDefault();
Transforms.move(editor, { unit: 'offset', reverse: true });
}
if (isHotkey('arrowright', evt)) {
evt.preventDefault();
Transforms.move(editor, { unit: 'offset' });
}
}
},
[submit, editor, setReplyDraft]
[submit, setReplyDraft, enterForNewline]
);
const handleKeyUp: KeyboardEventHandler = useCallback(
@ -347,7 +349,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
[editor, sendTypingStatus]
);
const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []);
const handleCloseAutocomplete = useCallback(() => {
setAutocompleteQuery(undefined);
ReactEditor.focus(editor);
}, [editor]);
const handleEmoticonSelect = (key: string, shortcode: string) => {
editor.insertNode(createEmoticonElement(key, shortcode));
@ -452,6 +457,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.Command && (
<CommandAutocomplete
room={room}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
<CustomEditor
editableName="RoomInput"
editor={editor}
@ -523,7 +536,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab(undefined);
ReactEditor.focus(editor);
if (!mobileOrTablet()) ReactEditor.focus(editor);
}}
/>
}

View file

@ -81,6 +81,7 @@ function RoomView({ room, eventId }) {
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
roomViewRef={roomViewRef}

View file

@ -696,7 +696,7 @@ export const Message = as<'div', MessageProps>(
const hideOptions = () => setHover(false);
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
if (evt.altKey) return;
if (evt.altKey || !window.getSelection()?.isCollapsed) return;
const tag = (evt.target as any).tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
evt.preventDefault();
@ -965,7 +965,7 @@ export const Event = as<'div', EventProps>(
const hideOptions = () => setHover(false);
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
if (evt.altKey) return;
if (evt.altKey || !window.getSelection()?.isCollapsed) return;
const tag = (evt.target as any).tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
evt.preventDefault();

View file

@ -32,6 +32,7 @@ import { EmojiBoard } from '../../../components/emoji-board';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room';
import { mobileOrTablet } from '../../../utils/user-agent';
type MessageEditorProps = {
roomId: string;
@ -44,6 +45,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
const mx = useMatrixClient();
const editor = useEditor();
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [toolbar, setToolbar] = useState(globalToolbar);
@ -118,7 +120,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
if (isHotkey('enter', evt)) {
if (enterForNewline ? isHotkey('shift+enter', evt) : isHotkey('enter', evt)) {
evt.preventDefault();
handleSave();
}
@ -127,7 +129,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
onCancel();
}
},
[onCancel, handleSave]
[onCancel, handleSave, enterForNewline]
);
const handleKeyUp: KeyboardEventHandler = useCallback(
@ -146,7 +148,10 @@ export const MessageEditor = as<'div', MessageEditorProps>(
[editor]
);
const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []);
const handleCloseAutocomplete = useCallback(() => {
ReactEditor.focus(editor);
setAutocompleteQuery(undefined);
}, [editor]);
const handleEmoticonSelect = (key: string, shortcode: string) => {
editor.insertNode(createEmoticonElement(key, shortcode));
@ -167,7 +172,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
});
editor.insertFragment(initialValue);
ReactEditor.focus(editor);
if (!mobileOrTablet()) ReactEditor.focus(editor);
}, [editor, getPrevBodyAndFormattedBody]);
useEffect(() => {
@ -258,7 +263,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
onCustomEmojiSelect={handleEmoticonSelect}
requestClose={() => {
setEmojiBoard(false);
ReactEditor.focus(editor);
if (!mobileOrTablet()) ReactEditor.focus(editor);
}}
/>
}

View file

@ -49,6 +49,7 @@ import { settingsAtom } from '../../state/settings';
function AppearanceSection() {
const [, updateState] = useState({});
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [useSystemEmoji, setUseSystemEmoji] = useSetting(settingsAtom, 'useSystemEmoji');
@ -138,6 +139,16 @@ function AppearanceSection() {
/>
}
/>
<SettingTile
title="Use ENTER for Newline"
options={(
<Toggle
isActive={enterForNewline}
onToggle={() => setEnterForNewline(!enterForNewline) }
/>
)}
content={<Text variant="b3">Use SHIFT + ENTER to send message and ENTER for newline.</Text>}
/>
<SettingTile
title="Inline Markdown formatting"
options={(