simpler chatbox (#3146)

This commit is contained in:
John Regan 2023-07-09 16:42:03 -04:00 committed by GitHub
parent aeed7a678d
commit c132d82645
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 115 additions and 51 deletions

View file

@ -1,12 +1,12 @@
import { Popover } from 'antd'; 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 { useRecoilValue } from 'recoil';
import ContentEditable from 'react-contenteditable';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import GraphemeSplitter from 'grapheme-splitter'; import GraphemeSplitter from 'grapheme-splitter';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import classNames from 'classnames'; import classNames from 'classnames';
import ContentEditable from './ContentEditable';
import WebsocketService from '../../../services/websocket-service'; import WebsocketService from '../../../services/websocket-service';
import { websocketServiceAtom } from '../../stores/ClientConfigStore'; import { websocketServiceAtom } from '../../stores/ClientConfigStore';
import { MessageType } from '../../../interfaces/socket-events'; import { MessageType } from '../../../interfaces/socket-events';
@ -122,16 +122,15 @@ const getTextContent = node => {
export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, focusInput }) => { export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, focusInput }) => {
const [characterCount, setCharacterCount] = useState(defaultText?.length); const [characterCount, setCharacterCount] = useState(defaultText?.length);
const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom); const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom);
const text = useRef(defaultText || ''); const [contentEditable, setContentEditable] = useState(null);
const contentEditable = React.createRef<HTMLElement>();
const [customEmoji, setCustomEmoji] = useState([]); const [customEmoji, setCustomEmoji] = useState([]);
// This is a bit of a hack to force the component to re-render when the text changes. const onRootRef = el => {
// By default when updating a ref the component doesn't re-render. setContentEditable(el);
const [, forceUpdate] = useReducer(x => x + 1, 0); };
const getCharacterCount = () => { const getCharacterCount = () => {
const message = getTextContent(contentEditable.current); const message = getTextContent(contentEditable);
return graphemeSplitter.countGraphemes(message); return graphemeSplitter.countGraphemes(message);
}; };
@ -141,35 +140,26 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
return; return;
} }
const message = getTextContent(contentEditable.current); const message = getTextContent(contentEditable);
const count = graphemeSplitter.countGraphemes(message); const count = graphemeSplitter.countGraphemes(message);
if (count === 0 || count > characterLimit) return; if (count === 0 || count > characterLimit) return;
websocketService.send({ type: MessageType.CHAT, body: message }); websocketService.send({ type: MessageType.CHAT, body: message });
contentEditable.innerHTML = '';
// Clear the input.
text.current = '';
setCharacterCount(0);
forceUpdate();
}; };
const insertTextAtEnd = (textToInsert: string) => { const insertTextAtEnd = (textToInsert: string) => {
const output = text.current + textToInsert; contentEditable.innerHTML += textToInsert;
text.current = output;
forceUpdate();
}; };
// Native emoji // Native emoji
const onEmojiSelect = (emoji: string) => { const onEmojiSelect = (emoji: string) => {
setCharacterCount(getCharacterCount() + 1);
insertTextAtEnd(emoji); insertTextAtEnd(emoji);
}; };
// Custom emoji images // Custom emoji images
const onCustomEmojiSelect = (name: string, emoji: string) => { const onCustomEmojiSelect = (name: string, emoji: string) => {
const html = `<img src="${emoji}" alt=":${name}:" title=":${name}:" class="emoji" />`; const html = `<img src="${emoji}" alt=":${name}:" title=":${name}:" class="emoji" />`;
setCharacterCount(getCharacterCount() + name.length + 2);
insertTextAtEnd(html); insertTextAtEnd(html);
}; };
@ -180,8 +170,27 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
} }
}; };
const handleChange = evt => { const onPaste = evt => {
const sanitized = sanitizeHtml(evt.target.value, { 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'], allowedTags: ['b', 'i', 'em', 'strong', 'a', 'br', 'p', 'img'],
allowedAttributes: { allowedAttributes: {
img: ['class', 'alt', 'title', 'src'], img: ['class', 'alt', 'title', 'src'],
@ -196,9 +205,22 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ 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 <br> 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. // Focus the input when the component mounts.
@ -241,15 +263,16 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
> >
<ContentEditable <ContentEditable
id="chat-input-content-editable" id="chat-input-content-editable"
html={text.current} html={defaultText || ''}
placeholder={enabled ? 'Send a message to chat' : 'Chat is disabled'} placeholder={enabled ? 'Send a message to chat' : 'Chat is disabled'}
disabled={!enabled} disabled={!enabled}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onChange={handleChange} onContentChange={handleChange}
style={{ width: '100%' }} onPaste={onPaste}
onRootRef={onRootRef}
style={{ whiteSpace: 'pre-wrap', width: '100%' }}
role="textbox" role="textbox"
aria-label="Chat text input" aria-label="Chat text input"
innerRef={contentEditable}
/> />
{enabled && ( {enabled && (
<div style={{ display: 'flex', paddingLeft: '5px' }}> <div style={{ display: 'flex', paddingLeft: '5px' }}>

View file

@ -0,0 +1,64 @@
import * as React from 'react';
export interface ContentEditableProps extends React.HTMLAttributes<HTMLElement> {
onRootRef?: Function;
onContentChange?: Function;
tagName?: string;
html: string;
disabled: boolean;
}
export default class ContentEditable extends React.Component<ContentEditableProps> {
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 },
});
}
}

22
web/package-lock.json generated
View file

@ -36,7 +36,6 @@
"postcss-flexbugs-fixes": "5.0.2", "postcss-flexbugs-fixes": "5.0.2",
"react": "18.2.0", "react": "18.2.0",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-contenteditable": "^3.3.7",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-error-boundary": "^4.0.0", "react-error-boundary": "^4.0.0",
"react-hotkeys-hook": "4.4.1", "react-hotkeys-hook": "4.4.1",
@ -38218,18 +38217,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0" "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": { "node_modules/react-docgen": {
"version": "5.4.3", "version": "5.4.3",
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.3.tgz", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.3.tgz",
@ -74380,15 +74367,6 @@
"integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==",
"requires": {} "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": { "react-docgen": {
"version": "5.4.3", "version": "5.4.3",
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.3.tgz", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.3.tgz",

View file

@ -41,7 +41,6 @@
"postcss-flexbugs-fixes": "5.0.2", "postcss-flexbugs-fixes": "5.0.2",
"react": "18.2.0", "react": "18.2.0",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-contenteditable": "^3.3.7",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-error-boundary": "^4.0.0", "react-error-boundary": "^4.0.0",
"react-hotkeys-hook": "4.4.1", "react-hotkeys-hook": "4.4.1",