From ad814a8802c7ce4767e23dabbe8f67c18d64a727 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Tue, 20 Jun 2023 17:05:24 -0700 Subject: [PATCH] fix(chat): refactor chat input to not use slatejs. Closes #3094 --- .../ChatTextField/ChatTextField.module.scss | 21 +- .../ChatTextField/ChatTextField.stories.tsx | 3 +- .../chat/ChatTextField/ChatTextField.tsx | 367 +++++++++--------- web/package-lock.json | 111 +----- web/package.json | 3 +- 5 files changed, 210 insertions(+), 295 deletions(-) diff --git a/web/components/chat/ChatTextField/ChatTextField.module.scss b/web/components/chat/ChatTextField/ChatTextField.module.scss index 45ee52886..9e8e94c62 100644 --- a/web/components/chat/ChatTextField/ChatTextField.module.scss +++ b/web/components/chat/ChatTextField/ChatTextField.module.scss @@ -25,22 +25,30 @@ background-color: var(--theme-color-components-form-field-background); box-shadow: inset 0px 0px 2px 2px var(--theme-color-palette-3); } + + // Size of custom emoji. + img { + width: 20px; + height: 20px; + } } .maxCharacters { border-style: solid; - border-width: 1px; + border-width: 2px; border-color: red; } div[role='textbox'] { font-size: 13px; - font-weight: 400; + font-weight: 400; padding: 0.3rem; background-color: inherit; border-color: var(--theme-color-components-form-field-border); box-shadow: 0; transition: box-shadow 50ms ease-in-out; + max-height: 40px; // 2 lines of text + min-height: 30px; &:focus { outline: 1px solid var(--color-owncast-gray-500) !important; } @@ -48,13 +56,20 @@ margin: 0px; } } + + // Placeholder styling + :empty:before { + content: attr(placeholder); + display: block; + color: #aaa; + } } .emojiButton { border: none; background: none; cursor: pointer; - padding: 0 .25rem; + padding: 0 0.25rem; } .sendButton { diff --git a/web/components/chat/ChatTextField/ChatTextField.stories.tsx b/web/components/chat/ChatTextField/ChatTextField.stories.tsx index b89801b2c..5f1c7aea7 100644 --- a/web/components/chat/ChatTextField/ChatTextField.stories.tsx +++ b/web/components/chat/ChatTextField/ChatTextField.stories.tsx @@ -44,8 +44,7 @@ export default { component: ` - This is a element using \`contentEditable\` in order to support rendering emoji images inline. - Emoji button shows emoji picker. -- Should show one line by default, but grow to two lines as needed. -- The Send button should be hidden for desktop layouts and be shown for mobile layouts.`, +- Should show one line by default, but grow to two lines as needed.`, }, }, }, diff --git a/web/components/chat/ChatTextField/ChatTextField.tsx b/web/components/chat/ChatTextField/ChatTextField.tsx index ae78a7821..29725d819 100644 --- a/web/components/chat/ChatTextField/ChatTextField.tsx +++ b/web/components/chat/ChatTextField/ChatTextField.tsx @@ -1,16 +1,7 @@ import { Popover } from 'antd'; -import React, { FC, useMemo, useState } from 'react'; +import React, { FC, useReducer, useRef, useState } from 'react'; import { useRecoilValue } from 'recoil'; -import { Transforms, createEditor, BaseEditor, Text, Descendant, Editor } from 'slate'; -import { - Slate, - DefaultPlaceholder, - Editable, - withReact, - ReactEditor, - useSelected, - useFocused, -} from 'slate-react'; +import ContentEditable from 'react-contenteditable'; import dynamic from 'next/dynamic'; import classNames from 'classnames'; import WebsocketService from '../../../services/websocket-service'; @@ -32,102 +23,6 @@ const SmileOutlined = dynamic(() => import('@ant-design/icons/SmileOutlined'), { ssr: false, }); -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; - Element: CustomElement; - Text: CustomText; - } -} - -const Image = p => { - const { attributes, element, children } = p; - - const selected = useSelected(); - const focused = useFocused(); - return ( - - {element.alt} - {children} - - ); -}; - -const withImages = editor => { - const { isVoid } = editor; - - // eslint-disable-next-line no-param-reassign - editor.isVoid = element => (element.type === 'image' ? true : isVoid(element)); - // eslint-disable-next-line no-param-reassign - editor.isInline = element => element.type === 'image'; - - return editor; -}; - -const serialize = node => { - if (Text.isText(node)) { - const string = node.text; - return string; - } - - 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 `${node.alt}`; - default: - return children; - } -}; - -const getCharacterCount = node => { - if (Text.isText(node)) { - return node.text.length; - } - if (node.type === 'image') { - return 5; - } - - let count = 0; - node.children.forEach(child => { - count += getCharacterCount(child); - }); - - return count; -}; - export type ChatTextFieldProps = { defaultText?: string; enabled: boolean; @@ -136,18 +31,88 @@ export type ChatTextFieldProps = { const characterLimit = 300; +function getCaretPosition(node) { + const selection = window.getSelection(); + + if (selection.rangeCount === 0) { + return 0; + } + + const range = selection.getRangeAt(0); + const preCaretRange = range.cloneRange(); + const tempElement = document.createElement('div'); + + preCaretRange.selectNodeContents(node); + preCaretRange.setEnd(range.endContainer, range.endOffset); + tempElement.appendChild(preCaretRange.cloneContents()); + + return tempElement.innerHTML.length; +} + +function setCaretPosition(editableDiv, position) { + try { + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNode(editableDiv); + range.setStart(editableDiv.childNodes[0], position); + range.collapse(true); + + sel.removeAllRanges(); + sel.addRange(range); + } catch (e) { + console.debug(e); + } +} + +function convertToText(str = '') { + // Ensure string. + let value = String(str); + + // Convert encoding. + value = value.replace(/ /gi, ' '); + value = value.replace(/&/gi, '&'); + + // Replace `
`. + value = value.replace(/
/gi, '\n'); + + // Replace `
` (from Chrome). + value = value.replace(/
/gi, '\n'); + + // Replace `

` (from IE). + value = value.replace(/

/gi, '\n'); + + // Cleanup the emoji titles. + value = value.replace(/\u200C{2}/gi, ''); + + // Trim each line. + value = value + .split('\n') + .map((line = '') => line.trim()) + .join('\n'); + + // No more than 2x newline, per "paragraph". + value = value.replace(/\n\n+/g, '\n\n'); + + // Clean up spaces. + value = value.replace(/[ ]+/g, ' '); + value = value.trim(); + + // Expose string. + return value; +} + export const ChatTextField: FC = ({ defaultText, enabled, focusInput }) => { const [showEmojis, setShowEmojis] = useState(false); const [characterCount, setCharacterCount] = useState(defaultText?.length); const websocketService = useRecoilValue(websocketServiceAtom); - const editor = useMemo(() => withReact(withImages(createEditor())), []); + const text = useRef(defaultText || ''); + const [savedCursorLocation, setSavedCursorLocation] = useState(0); - const defaultEditorValue: Descendant[] = [ - { - type: 'paragraph', - children: [{ text: defaultText || '' }], - }, - ]; + // This is a bit of a hack to force the component to re-render when the text changes. + // By default when updating a ref the component doesn't re-render. + const [, forceUpdate] = useReducer(x => x + 1, 0); + + const getCharacterCount = () => text.current.length; const sendMessage = () => { if (!websocketService) { @@ -155,51 +120,71 @@ export const ChatTextField: FC = ({ defaultText, enabled, fo return; } - let message = serialize(editor); + let message = text.current; // Strip the opening and closing

tags. message = message.replace(/^

|<\/p>$/g, ''); websocketService.send({ type: MessageType.CHAT, body: message }); - // Clear the editor. - Transforms.delete(editor, { - at: { - anchor: Editor.start(editor, []), - focus: Editor.end(editor, []), - }, - }); + // Clear the input. + text.current = ''; setCharacterCount(0); + forceUpdate(); }; - const createImageNode = (alt, src, name): ImageNode => ({ - type: 'image', - alt, - src, - name, - children: [{ text: '' }], - }); + const insertTextAtCursor = (textToInsert: string) => { + const output = [ + text.current.slice(0, savedCursorLocation), + textToInsert, + text.current.slice(savedCursorLocation), + ].join(''); + text.current = output; + forceUpdate(); + }; - const insertImage = (url, name) => { - if (!url) return; + const convertOnPaste = (event: React.ClipboardEvent) => { + // Prevent paste. + event.preventDefault(); - const image = createImageNode(name, url, name); + // Set later. + let value = ''; - Transforms.insertNodes(editor, image); - Editor.normalize(editor, { force: true }); + // Does method exist? + const hasEventClipboard = !!( + event.clipboardData && + typeof event.clipboardData === 'object' && + typeof event.clipboardData.getData === 'function' + ); + + // Get clipboard data? + if (hasEventClipboard) { + value = event.clipboardData.getData('text/plain'); + } + + // Insert into temp `