From 121c9415f14957f6fd4b50ad00f53b63b4cddaa4 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Tue, 6 Sep 2022 17:52:02 -0700 Subject: [PATCH] Wire up emoji+custom emote selector to text input. Closes #1887 --- .../ChatTextField/ChatTextField.stories.tsx | 36 +++- .../chat/ChatTextField/ChatTextField.tsx | 199 +++++++++++------- .../chat/ChatTextField/EmojiPicker.tsx | 24 ++- .../ChatUserMessage.module.scss | 1 + web/styles/globals.scss | 6 + 5 files changed, 182 insertions(+), 84 deletions(-) diff --git a/web/components/chat/ChatTextField/ChatTextField.stories.tsx b/web/components/chat/ChatTextField/ChatTextField.stories.tsx index fee8a1e30..2df1bf414 100644 --- a/web/components/chat/ChatTextField/ChatTextField.stories.tsx +++ b/web/components/chat/ChatTextField/ChatTextField.stories.tsx @@ -2,15 +2,41 @@ import React from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; import { RecoilRoot } from 'recoil'; import ChatTextField from './ChatTextField'; -import Mock from '../../../stories/assets/mocks/chatinput-mock.png'; +import Mockup from '../../../stories/assets/mocks/chatinput-mock.png'; + +const mockResponse = JSON.parse( + `[{"name":"Reaper-gg.png","url":"https://ui-avatars.com/api/?background=0D8ABC&color=fff&name=OC"},{"name":"Reaper-hi.png","url":"https://ui-avatars.com/api/?background=0D8ABC&color=fff&name=XX"},{"name":"Reaper-hype.png","url":"https://ui-avatars.com/api/?background=0D8ABC&color=fff&name=TX"},{"name":"Reaper-lol.png","url":"https://ui-avatars.com/api/?background=0D8ABC&color=fff&name=CA"},{"name":"Reaper-love.png","url":"https://ui-avatars.com/api/?background=0D8ABC&color=fff&name=OK"}]`, +); +const mocks = { + mocks: [ + { + // The "matcher" determines if this + // mock should respond to the current + // call to fetch(). + matcher: { + name: 'response', + url: 'glob:/api/emoji', + }, + // If the "matcher" matches the current + // fetch() call, the fetch response is + // built using this "response". + response: { + status: 200, + body: mockResponse, + }, + }, + ], +}; export default { title: 'owncast/Chat/Input text field', component: ChatTextField, parameters: { + fetchMock: mocks, + design: { type: 'image', - url: Mock, + url: Mockup, }, docs: { description: { @@ -24,14 +50,12 @@ export default { }, } as ComponentMeta; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const Template: ComponentStory = args => ( +const Template: ComponentStory = () => ( - + ); -// eslint-disable-next-line @typescript-eslint/no-unused-vars export const Example = Template.bind({}); export const LongerMessage = Template.bind({}); diff --git a/web/components/chat/ChatTextField/ChatTextField.tsx b/web/components/chat/ChatTextField/ChatTextField.tsx index 7799067e3..3acf9a1c3 100644 --- a/web/components/chat/ChatTextField/ChatTextField.tsx +++ b/web/components/chat/ChatTextField/ChatTextField.tsx @@ -1,18 +1,30 @@ import { SendOutlined, SmileOutlined } from '@ant-design/icons'; import { Button, Popover } from 'antd'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useRecoilValue } from 'recoil'; -import { Transforms, createEditor, BaseEditor, Text } from 'slate'; -import { Slate, Editable, withReact, ReactEditor } from 'slate-react'; +import { Editor, Node, Path, Transforms, createEditor, BaseEditor, Text, Descendant } from 'slate'; +import { Slate, Editable, withReact, ReactEditor, useSelected, useFocused } from 'slate-react'; import EmojiPicker from './EmojiPicker'; import WebsocketService from '../../../services/websocket-service'; import { websocketServiceAtom } from '../../stores/ClientConfigStore'; import { MessageType } from '../../../interfaces/socket-events'; -import s from './ChatTextField.module.scss'; +import style from './ChatTextField.module.scss'; -type CustomElement = { type: 'paragraph' | 'span'; children: CustomText[] }; +type CustomElement = { type: 'paragraph' | 'span'; children: CustomText[] } | ImageNode; type CustomText = { text: string }; +type EmptyText = { + text: string; +}; + +type ImageNode = { + type: 'image'; + alt: string; + src: string; + name: string; + children: EmptyText[]; +}; + declare module 'slate' { interface CustomTypes { Editor: BaseEditor & ReactEditor; @@ -21,26 +33,27 @@ declare module 'slate' { } } -interface Props { - value?: string; -} +const Image = p => { + const { attributes, element, children } = p; -// eslint-disable-next-line react/prop-types -const Image = ({ element }) => ( - emoji -); - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const insertImage = (editor, url) => { - // const text = { text: '' }; - // const image: ImageElement = { type: 'image', url, children: [text] }; - // Transforms.insertNodes(editor, image); + const selected = useSelected(); + const focused = useFocused(); + return ( + + {element.alt} + {children} + + ); }; const withImages = editor => { @@ -54,54 +67,40 @@ const withImages = editor => { return editor; }; -export type EmptyText = { - text: string; -}; - -// type ImageElement = { -// type: 'image'; -// url: string; -// children: EmptyText[]; -// }; - -const Element = (props: any) => { - const { attributes, children, element } = props; - - switch (element.type) { - case 'image': - return ; - default: - return

{children}

; - } -}; - const serialize = node => { if (Text.isText(node)) { const string = node.text; - // if (node.bold) { - // string = `${string}`; - // } return string; } - const children = node.children.map(n => serialize(n)).join(''); + let children; + if (node.children.length === 0) { + children = [{ text: '' }]; + } else { + children = node.children?.map(n => serialize(n)).join(''); + } switch (node.type) { case 'paragraph': return `

${children}

`; case 'image': - return `emoji`; + return `${node.alt}`; default: return children; } }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export default function ChatTextField(props: Props) { - // const { value: originalValue } = props; +export default function ChatTextField() { const [showEmojis, setShowEmojis] = useState(false); const websocketService = useRecoilValue(websocketServiceAtom); - const [editor] = useState(() => withImages(withReact(createEditor()))); + const editor = useMemo(() => withReact(withImages(createEditor())), []); + + const defaultEditorValue: Descendant[] = [ + { + type: 'paragraph', + children: [{ text: '' }], + }, + ]; const sendMessage = () => { if (!websocketService) { @@ -110,23 +109,62 @@ export default function ChatTextField(props: Props) { } const message = serialize(editor); - websocketService.send({ type: MessageType.CHAT, body: message }); // Clear the editor. - Transforms.select(editor, [0, editor.children.length - 1]); - Transforms.delete(editor); + Transforms.delete(editor, { + at: { + anchor: Editor.start(editor, []), + focus: Editor.end(editor, []), + }, + }); }; - const handleChange = () => {}; + const createImageNode = (alt, src, name): ImageNode => ({ + type: 'image', + alt, + src, + name, + children: [{ text: '' }], + }); - const handleEmojiSelect = (e: any) => { + const insertImage = (url, name) => { + if (!url) return; + + const { selection } = editor; + const image = createImageNode(name, url, name); + + Transforms.insertNodes(editor, image, { select: true }); + + if (selection) { + const [parentNode, parentPath] = Editor.parent(editor, selection.focus?.path); + + if (editor.isVoid(parentNode) || Node.string(parentNode).length) { + // Insert the new image node after the void node or a node with content + Transforms.insertNodes(editor, image, { + at: Path.next(parentPath), + select: true, + }); + } else { + // If the node is empty, replace it instead + // Transforms.removeNodes(editor, { at: parentPath }); + Transforms.insertNodes(editor, image, { at: parentPath, select: true }); + Editor.normalize(editor, { force: true }); + } + } else { + // Insert the new image node at the bottom of the Editor when selection + // is falsey + Transforms.insertNodes(editor, image, { select: true }); + } + }; + + const onEmojiSelect = (e: any) => { ReactEditor.focus(editor); if (e.url) { // Custom emoji const { url } = e; - insertImage(editor, url); + insertImage(url, url); } else { // Native emoji const { emoji } = e; @@ -134,6 +172,12 @@ export default function ChatTextField(props: Props) { } }; + const onCustomEmojiSelect = (e: any) => { + ReactEditor.focus(editor); + const { url } = e; + insertImage(url, url); + }; + const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); @@ -141,23 +185,34 @@ export default function ChatTextField(props: Props) { } }; + const renderElement = p => { + switch (p.element.type) { + case 'image': + return ; + default: + return

; + } + }; + return (

-
- +
+ } + renderElement={renderElement} placeholder="Chat message goes here..." style={{ width: '100%' }} + // onChange={change => setValue(change.value)} autoFocus /> } + content={ + + } trigger="click" onVisibleChange={visible => setShowEmojis(visible)} visible={showEmojis} @@ -166,14 +221,14 @@ export default function ChatTextField(props: Props) {
); } - -ChatTextField.defaultProps = { - value: '', -}; diff --git a/web/components/chat/ChatTextField/EmojiPicker.tsx b/web/components/chat/ChatTextField/EmojiPicker.tsx index 371f8cd94..115b71bd8 100644 --- a/web/components/chat/ChatTextField/EmojiPicker.tsx +++ b/web/components/chat/ChatTextField/EmojiPicker.tsx @@ -5,12 +5,13 @@ const CUSTOM_EMOJI_URL = '/api/emoji'; interface Props { // eslint-disable-next-line react/no-unused-prop-types onEmojiSelect: (emoji: string) => void; + onCustomEmojiSelect: (emoji: string) => void; } // eslint-disable-next-line @typescript-eslint/no-unused-vars export default function EmojiPicker(props: Props) { const [customEmoji, setCustomEmoji] = useState([]); - const { onEmojiSelect } = props; + const { onEmojiSelect, onCustomEmojiSelect } = props; const ref = useRef(); const getCustomEmoji = async () => { @@ -30,10 +31,25 @@ export default function EmojiPicker(props: Props) { // Recreate the emoji picker when the custom emoji changes. useEffect(() => { - const picker = createPicker({ rootElement: ref.current, custom: customEmoji }); + const e = customEmoji.map(emoji => ({ + emoji: emoji.name, + label: emoji.name, + url: emoji.url, + })); + + const picker = createPicker({ + rootElement: ref.current, + custom: e, + initialCategory: 'custom', + showPreview: false, + showRecents: true, + }); picker.addEventListener('emoji:select', event => { - console.log('Emoji selected:', event.emoji); - onEmojiSelect(event); + if (event.url) { + onCustomEmojiSelect(event); + } else { + onEmojiSelect(event); + } }); }, [customEmoji]); diff --git a/web/components/chat/ChatUserMessage/ChatUserMessage.module.scss b/web/components/chat/ChatUserMessage/ChatUserMessage.module.scss index dc5618282..f5c69db5c 100644 --- a/web/components/chat/ChatUserMessage/ChatUserMessage.module.scss +++ b/web/components/chat/ChatUserMessage/ChatUserMessage.module.scss @@ -12,6 +12,7 @@ font-family: var(--theme-text-display-font-family); font-weight: bold; } + .message { color: var(--theme-color-components-chat-text); diff --git a/web/styles/globals.scss b/web/styles/globals.scss index 7fd4bbce7..4236304c9 100644 --- a/web/styles/globals.scss +++ b/web/styles/globals.scss @@ -100,3 +100,9 @@ body { overflow: hidden; } } + +.emoji { + height: 30px; + margin-left: 5px; + margin-right: 5px; +}