mirror of
				https://github.com/owncast/owncast.git
				synced 2025-11-04 05:17:27 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			299 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			299 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import { SendOutlined, SmileOutlined } from '@ant-design/icons';
 | 
						|
import { Popover } from 'antd';
 | 
						|
import React, { FC, useMemo, useState } from 'react';
 | 
						|
import { useRecoilValue } from 'recoil';
 | 
						|
import { Transforms, createEditor, BaseEditor, Text, Descendant, Editor, Node, Path } from 'slate';
 | 
						|
import { Slate, Editable, withReact, ReactEditor, useSelected, useFocused } from 'slate-react';
 | 
						|
import dynamic from 'next/dynamic';
 | 
						|
import classNames from 'classnames';
 | 
						|
import WebsocketService from '../../../services/websocket-service';
 | 
						|
import { websocketServiceAtom } from '../../stores/ClientConfigStore';
 | 
						|
import { MessageType } from '../../../interfaces/socket-events';
 | 
						|
import styles from './ChatTextField.module.scss';
 | 
						|
 | 
						|
// Lazy loaded components
 | 
						|
 | 
						|
const EmojiPicker = dynamic(() => import('./EmojiPicker').then(mod => mod.EmojiPicker));
 | 
						|
 | 
						|
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 (
 | 
						|
    <span {...attributes} contentEditable={false}>
 | 
						|
      <img
 | 
						|
        alt={element.alt}
 | 
						|
        src={element.src}
 | 
						|
        title={element.name}
 | 
						|
        style={{
 | 
						|
          display: 'inline',
 | 
						|
          maxWidth: '50px',
 | 
						|
          maxHeight: '20px',
 | 
						|
          boxShadow: `${selected && focused ? '0 0 0 3px #B4D5FF' : 'none'}`,
 | 
						|
        }}
 | 
						|
      />
 | 
						|
      {children}
 | 
						|
    </span>
 | 
						|
  );
 | 
						|
};
 | 
						|
 | 
						|
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 `<p>${children}</p>`;
 | 
						|
    case 'image':
 | 
						|
      return `<img src="${node.src}" alt="${node.alt}" title="${node.name}" class="emoji"/>`;
 | 
						|
    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;
 | 
						|
};
 | 
						|
 | 
						|
const characterLimit = 300;
 | 
						|
 | 
						|
export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText }) => {
 | 
						|
  const [showEmojis, setShowEmojis] = useState(false);
 | 
						|
  const [characterCount, setCharacterCount] = useState(defaultText?.length);
 | 
						|
  const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom);
 | 
						|
  const editor = useMemo(() => withReact(withImages(createEditor())), []);
 | 
						|
 | 
						|
  const defaultEditorValue: Descendant[] = [
 | 
						|
    {
 | 
						|
      type: 'paragraph',
 | 
						|
      children: [{ text: defaultText || '' }],
 | 
						|
    },
 | 
						|
  ];
 | 
						|
 | 
						|
  const sendMessage = () => {
 | 
						|
    if (!websocketService) {
 | 
						|
      console.log('websocketService is not defined');
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    const message = serialize(editor);
 | 
						|
    websocketService.send({ type: MessageType.CHAT, body: message });
 | 
						|
 | 
						|
    // Clear the editor.
 | 
						|
    Transforms.delete(editor, {
 | 
						|
      at: {
 | 
						|
        anchor: Editor.start(editor, []),
 | 
						|
        focus: Editor.end(editor, []),
 | 
						|
      },
 | 
						|
    });
 | 
						|
    setCharacterCount(0);
 | 
						|
  };
 | 
						|
 | 
						|
  const createImageNode = (alt, src, name): ImageNode => ({
 | 
						|
    type: 'image',
 | 
						|
    alt,
 | 
						|
    src,
 | 
						|
    name,
 | 
						|
    children: [{ text: '' }],
 | 
						|
  });
 | 
						|
 | 
						|
  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 });
 | 
						|
    }
 | 
						|
  };
 | 
						|
 | 
						|
  // Native emoji
 | 
						|
  const onEmojiSelect = (emoji: string) => {
 | 
						|
    ReactEditor.focus(editor);
 | 
						|
    Transforms.insertText(editor, emoji);
 | 
						|
  };
 | 
						|
 | 
						|
  const onCustomEmojiSelect = (name: string, emoji: string) => {
 | 
						|
    ReactEditor.focus(editor);
 | 
						|
    insertImage(emoji, name);
 | 
						|
  };
 | 
						|
 | 
						|
  const onKeyDown = (e: React.KeyboardEvent) => {
 | 
						|
    const charCount = getCharacterCount(editor) + 1;
 | 
						|
 | 
						|
    // Send the message when hitting enter.
 | 
						|
    if (e.key === 'Enter') {
 | 
						|
      e.preventDefault();
 | 
						|
      sendMessage();
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Always allow backspace.
 | 
						|
    if (e.key === 'Backspace') {
 | 
						|
      setCharacterCount(charCount - 1);
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // Limit the number of characters.
 | 
						|
    if (charCount + 1 > characterLimit) {
 | 
						|
      e.preventDefault();
 | 
						|
    }
 | 
						|
 | 
						|
    setCharacterCount(charCount + 1);
 | 
						|
  };
 | 
						|
 | 
						|
  const onPaste = (e: React.ClipboardEvent) => {
 | 
						|
    const text = e.clipboardData.getData('text/plain');
 | 
						|
 | 
						|
    const { length } = text;
 | 
						|
    if (characterCount + length > characterLimit) {
 | 
						|
      e.preventDefault();
 | 
						|
    }
 | 
						|
  };
 | 
						|
 | 
						|
  const renderElement = p => {
 | 
						|
    switch (p.element.type) {
 | 
						|
      case 'image':
 | 
						|
        return <Image {...p} />;
 | 
						|
      default:
 | 
						|
        return <p {...p} />;
 | 
						|
    }
 | 
						|
  };
 | 
						|
 | 
						|
  return (
 | 
						|
    <div className={styles.root}>
 | 
						|
      <div
 | 
						|
        className={classNames(
 | 
						|
          styles.inputWrap,
 | 
						|
          characterCount >= characterLimit && styles.maxCharacters,
 | 
						|
        )}
 | 
						|
      >
 | 
						|
        <Slate editor={editor} value={defaultEditorValue}>
 | 
						|
          <Editable
 | 
						|
            className="chat-text-input"
 | 
						|
            onKeyDown={onKeyDown}
 | 
						|
            onPaste={onPaste}
 | 
						|
            renderElement={renderElement}
 | 
						|
            placeholder="Send a message to chat"
 | 
						|
            style={{ width: '100%' }}
 | 
						|
            role="textbox"
 | 
						|
            aria-label="Chat text input"
 | 
						|
            autoFocus
 | 
						|
          />
 | 
						|
          <Popover
 | 
						|
            content={
 | 
						|
              <EmojiPicker
 | 
						|
                onEmojiSelect={onEmojiSelect}
 | 
						|
                onCustomEmojiSelect={onCustomEmojiSelect}
 | 
						|
              />
 | 
						|
            }
 | 
						|
            trigger="click"
 | 
						|
            placement="topRight"
 | 
						|
            onOpenChange={open => setShowEmojis(open)}
 | 
						|
            open={showEmojis}
 | 
						|
          />
 | 
						|
        </Slate>
 | 
						|
 | 
						|
        <div style={{ display: 'flex', paddingLeft: '5px' }}>
 | 
						|
          <button
 | 
						|
            type="button"
 | 
						|
            className={styles.emojiButton}
 | 
						|
            title="Emoji picker button"
 | 
						|
            onClick={() => setShowEmojis(!showEmojis)}
 | 
						|
          >
 | 
						|
            <SmileOutlined />
 | 
						|
          </button>
 | 
						|
          <button
 | 
						|
            type="button"
 | 
						|
            className={styles.sendButton}
 | 
						|
            title="Send message Button"
 | 
						|
            onClick={sendMessage}
 | 
						|
          >
 | 
						|
            <SendOutlined />
 | 
						|
          </button>
 | 
						|
        </div>
 | 
						|
      </div>
 | 
						|
    </div>
 | 
						|
  );
 | 
						|
};
 |