mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-17 20:50:29 +03:00
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:
parent
4d0b6b93bc
commit
613e6d6503
34 changed files with 620 additions and 131 deletions
109
src/app/organisms/room/CommandAutocomplete.tsx
Normal file
109
src/app/organisms/room/CommandAutocomplete.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ function RoomView({ room, eventId }) {
|
|||
<>
|
||||
{canMessage && (
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
roomViewRef={roomViewRef}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue