diff --git a/web/components/chat/ChatTextField/ChatTextField.tsx b/web/components/chat/ChatTextField/ChatTextField.tsx index e90123337..065d1b504 100644 --- a/web/components/chat/ChatTextField/ChatTextField.tsx +++ b/web/components/chat/ChatTextField/ChatTextField.tsx @@ -1,12 +1,12 @@ import { Popover } from 'antd'; -import React, { FC, useEffect, useReducer, useRef, useState } from 'react'; +import React, { FC, useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; -import ContentEditable from 'react-contenteditable'; import sanitizeHtml from 'sanitize-html'; import GraphemeSplitter from 'grapheme-splitter'; import dynamic from 'next/dynamic'; import classNames from 'classnames'; +import ContentEditable from './ContentEditable'; import WebsocketService from '../../../services/websocket-service'; import { websocketServiceAtom } from '../../stores/ClientConfigStore'; import { MessageType } from '../../../interfaces/socket-events'; @@ -122,16 +122,15 @@ const getTextContent = node => { export const ChatTextField: FC = ({ defaultText, enabled, focusInput }) => { const [characterCount, setCharacterCount] = useState(defaultText?.length); const websocketService = useRecoilValue(websocketServiceAtom); - const text = useRef(defaultText || ''); - const contentEditable = React.createRef(); + const [contentEditable, setContentEditable] = useState(null); const [customEmoji, setCustomEmoji] = useState([]); - // 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 onRootRef = el => { + setContentEditable(el); + }; const getCharacterCount = () => { - const message = getTextContent(contentEditable.current); + const message = getTextContent(contentEditable); return graphemeSplitter.countGraphemes(message); }; @@ -141,35 +140,26 @@ export const ChatTextField: FC = ({ defaultText, enabled, fo return; } - const message = getTextContent(contentEditable.current); + const message = getTextContent(contentEditable); const count = graphemeSplitter.countGraphemes(message); if (count === 0 || count > characterLimit) return; websocketService.send({ type: MessageType.CHAT, body: message }); - - // Clear the input. - text.current = ''; - setCharacterCount(0); - forceUpdate(); + contentEditable.innerHTML = ''; }; const insertTextAtEnd = (textToInsert: string) => { - const output = text.current + textToInsert; - text.current = output; - - forceUpdate(); + contentEditable.innerHTML += textToInsert; }; // Native emoji const onEmojiSelect = (emoji: string) => { - setCharacterCount(getCharacterCount() + 1); insertTextAtEnd(emoji); }; // Custom emoji images const onCustomEmojiSelect = (name: string, emoji: string) => { const html = `:${name}:`; - setCharacterCount(getCharacterCount() + name.length + 2); insertTextAtEnd(html); }; @@ -180,8 +170,27 @@ export const ChatTextField: FC = ({ defaultText, enabled, fo } }; - const handleChange = evt => { - const sanitized = sanitizeHtml(evt.target.value, { + const onPaste = evt => { + evt.preventDefault(); + + const clip = evt.clipboardData; + const { types } = clip; + const contentTypes = ['text/html', 'text/plain']; + + let content; + + for (let i = 0; i < contentTypes.length; i += 1) { + const contentType = contentTypes[i]; + + if (types.includes(contentType)) { + content = clip.getData(contentType); + break; + } + } + + if (!content) return; + + const sanitized = sanitizeHtml(content, { allowedTags: ['b', 'i', 'em', 'strong', 'a', 'br', 'p', 'img'], allowedAttributes: { img: ['class', 'alt', 'title', 'src'], @@ -196,9 +205,22 @@ export const ChatTextField: FC = ({ defaultText, enabled, fo }, }); - if (text.current !== sanitized) text.current = sanitized; + // MDN lists this as deprecated, but it's the only way to save this paste + // into the browser's Undo buffer. Plus it handles all the selection + // deletion, caret positioning, etc automaticaly. + if (sanitized) document.execCommand('insertHTML', false, sanitized); + }; - setCharacterCount(getCharacterCount()); + const handleChange = () => { + const count = getCharacterCount(); + setCharacterCount(count); + + if (count === 0 && contentEditable.children.length === 1) { + /* if we have a single
element added by the browser, remove. */ + if (contentEditable.children[0].tagName.toLowerCase() === 'br') { + contentEditable.removeChild(contentEditable.children[0]); + } + } }; // Focus the input when the component mounts. @@ -241,15 +263,16 @@ export const ChatTextField: FC = ({ defaultText, enabled, fo > {enabled && (
diff --git a/web/components/chat/ChatTextField/ContentEditable.tsx b/web/components/chat/ChatTextField/ContentEditable.tsx new file mode 100644 index 000000000..fa522d7eb --- /dev/null +++ b/web/components/chat/ChatTextField/ContentEditable.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; + +export interface ContentEditableProps extends React.HTMLAttributes { + onRootRef?: Function; + onContentChange?: Function; + tagName?: string; + html: string; + disabled: boolean; +} + +export default class ContentEditable extends React.Component { + private root: HTMLElement; + + private mutationObserver: MutationObserver; + + private innerHTMLBuffer: string; + + public componentDidMount() { + this.mutationObserver = new MutationObserver(this.onContentChange); + this.mutationObserver.observe(this.root, { + childList: true, + subtree: true, + characterData: true, + }); + } + + private onContentChange = (mutations: MutationRecord[]) => { + mutations.forEach(() => { + const { innerHTML } = this.root; + + if (this.innerHTMLBuffer === undefined || this.innerHTMLBuffer !== innerHTML) { + this.innerHTMLBuffer = innerHTML; + + if (this.props.onContentChange) { + this.props.onContentChange({ + target: { + value: innerHTML, + }, + }); + } + } + }); + }; + + private onRootRef = (elt: HTMLElement) => { + this.root = elt; + if (this.props.onRootRef) { + this.props.onRootRef(this.root); + } + }; + + public render() { + const { tagName, html, ...newProps } = this.props; + + delete newProps.onRootRef; + + return React.createElement(tagName || 'div', { + ...newProps, + ref: this.onRootRef, + contentEditable: !this.props.disabled, + dangerouslySetInnerHTML: { __html: html }, + }); + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 7ba6ca763..bf3e04da4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -36,7 +36,6 @@ "postcss-flexbugs-fixes": "5.0.2", "react": "18.2.0", "react-chartjs-2": "^5.2.0", - "react-contenteditable": "^3.3.7", "react-dom": "18.2.0", "react-error-boundary": "^4.0.0", "react-hotkeys-hook": "4.4.1", @@ -38218,18 +38217,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, - "node_modules/react-contenteditable": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz", - "integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "prop-types": "^15.7.1" - }, - "peerDependencies": { - "react": ">=16.3" - } - }, "node_modules/react-docgen": { "version": "5.4.3", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.3.tgz", @@ -74380,15 +74367,6 @@ "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", "requires": {} }, - "react-contenteditable": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz", - "integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==", - "requires": { - "fast-deep-equal": "^3.1.3", - "prop-types": "^15.7.1" - } - }, "react-docgen": { "version": "5.4.3", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.3.tgz", diff --git a/web/package.json b/web/package.json index 6f88ba642..b20af1c7f 100644 --- a/web/package.json +++ b/web/package.json @@ -41,7 +41,6 @@ "postcss-flexbugs-fixes": "5.0.2", "react": "18.2.0", "react-chartjs-2": "^5.2.0", - "react-contenteditable": "^3.3.7", "react-dom": "18.2.0", "react-error-boundary": "^4.0.0", "react-hotkeys-hook": "4.4.1",