RocketChat/Rocket.Chat

View on GitHub
packages/livechat/src/components/Composer/index.tsx

Summary

Maintainability
F
3 days
Test Coverage
import type { ComponentChildren } from 'preact';
import { Component } from 'preact';
import type { CSSProperties } from 'preact/compat';

import { createClassName } from '../../helpers/createClassName';
import { parse } from '../../helpers/parse';
import styles from './styles.scss';

const findLastTextNode = (node: Node): Node | null => {
    if (node.nodeType === Node.TEXT_NODE) {
        return node;
    }
    const children = node.childNodes;
    for (let i = children.length - 1; i >= 0; i--) {
        const textNode = findLastTextNode(children[i]);
        if (textNode !== null) {
            return textNode;
        }
    }

    return null;
};

const replaceCaret = (el: Element) => {
    // Place the caret at the end of the element
    const target = findLastTextNode(el);
    // do not move caret if element was not focused
    const isTargetFocused = document.activeElement === el;
    if (!!target?.nodeValue && isTargetFocused) {
        const range = document.createRange();
        const sel = window.getSelection();
        range.setStart(target, target.nodeValue.length);
        range.collapse(true);
        sel?.removeAllRanges();
        sel?.addRange(range);
        if (el instanceof HTMLElement) {
            el.focus();
        }
    }
};

type ComposerProps = {
    className?: string;
    style?: CSSProperties;
    value?: string;
    onChange?: (value: string) => void;
    onSubmit?: (value: string) => void;
    onUpload?: (files: (File | null)[]) => void;
    handleEmojiClick?: () => void;
    placeholder?: string;
    pre?: ComponentChildren;
    post?: ComponentChildren;
    notifyEmojiSelect?: (cb: (emoji: string) => void) => void;
    limitTextLength?: number;
};

type ComposerState = {
    inputLock: boolean;
};

export class Composer extends Component<ComposerProps, ComposerState> {
    private el: HTMLElement | null = null;

    handleRef = (el: HTMLDivElement | null) => {
        this.el = el;
    };

    handleInput = (onChange?: (value: string) => void) => () => {
        if (this.state.inputLock) {
            return;
        }
        onChange?.(this.el?.innerText ?? '');
    };

    handleKeypress = (onSubmit?: (value: string) => void) => (event: KeyboardEvent) => {
        if (event.which === 13 && !event.shiftKey) {
            event.preventDefault();
            if (this.el) {
                onSubmit?.(this.el.innerText);
                this.el.innerText = '';
            }
        }
    };

    handlePaste = (onUpload?: (files: (File | null)[]) => void) => async (event: ClipboardEvent) => {
        if (!event.clipboardData?.items) {
            return;
        }

        event.preventDefault();

        const items = Array.from(event.clipboardData.items);

        const files = items.filter((item) => item.kind === 'file' && /^image\//.test(item.type)).map((item) => item.getAsFile());
        if (files.length) {
            onUpload?.(files);
            return;
        }

        const texts = await Promise.all(
            items
                .filter((item) => item.kind === 'string' && /^text\/plain/.test(item.type))
                .map((item) => new Promise<string>((resolve) => item.getAsString(resolve))),
        );

        texts.forEach((text) => this.pasteText(parse(text)));
    };

    handleDrop = (onUpload?: (files: (File | null)[]) => void) => async (event: DragEvent) => {
        if (!event.dataTransfer?.items) {
            return;
        }

        event.preventDefault();

        const items = Array.from(event.dataTransfer.items);

        const files = items.filter((item) => item.kind === 'file' && /^image\//.test(item.type)).map((item) => item.getAsFile());
        if (files.length) {
            onUpload?.(files);
            return;
        }

        const texts = await Promise.all(
            items
                .filter((item) => item.kind === 'string' && /^text\/plain/.test(item.type))
                .map((item) => new Promise<string>((resolve) => item.getAsString(resolve))),
        );
        texts.forEach((text) => this.pasteText(parse(text)));
    };

    handleClick = () => {
        const { handleEmojiClick } = this.props;
        handleEmojiClick?.();
    };

    pasteText = (plainText: string) => {
        this.el?.focus();

        if (document.queryCommandSupported('insertText')) {
            document.execCommand('insertText', false, plainText);
            return;
        }

        const range = document.getSelection()?.getRangeAt(0);
        if (!range) {
            return;
        }

        range.deleteContents();
        const textNode = document.createTextNode(plainText);
        range.insertNode(textNode);
        range.selectNodeContents(textNode);
        range.collapse(false);

        const selection = window.getSelection();
        selection?.removeAllRanges();
        selection?.addRange(range);
    };

    private value: string | undefined;

    constructor(props: ComposerProps) {
        super(props);
        this.state = {
            inputLock: false,
        };
        this.value = this.props.value;
        this.handleNotifyEmojiSelect = this.handleNotifyEmojiSelect.bind(this);

        if (typeof this.props.notifyEmojiSelect === 'function') {
            this.props.notifyEmojiSelect(this.handleNotifyEmojiSelect);
        }
    }

    // we only update composer if value length changed from 0 to 1 or 1 to 0
    // everything else is managed by this.el
    shouldComponentUpdate({ value: nextValue = '' }: ComposerProps) {
        const { value = '', limitTextLength } = this.props;

        const nextValueEmpty = !nextValue || nextValue.length === 0;
        const valueEmpty = !value || value.length === 0;

        if (nextValueEmpty !== valueEmpty) {
            return true;
        }

        if (nextValue.length === limitTextLength || value.length === limitTextLength) {
            return true;
        }

        return false;
    }

    componentDidUpdate() {
        const { el } = this;
        if (!el) {
            return;
        }

        if (this.props.value !== el.innerHTML) {
            this.value = this.props.value ?? '';
            el.innerHTML = this.value;
        }
        replaceCaret(el);
    }

    handleNotifyEmojiSelect(emoji: string) {
        if (!this.el) {
            throw new Error('Composer: handleNotifyEmojiSelect called but el is not defined');
        }

        const { onChange } = this.props;
        const caretPosition = this.getCaretPosition(this.el);
        const oldText = this.el?.innerText ?? '';
        const newText = `${oldText.slice(0, caretPosition)}${emoji}&nbsp;${oldText.slice(caretPosition)}`;
        this.el.innerHTML = newText;
        this.moveCursorToEndAndFocus(caretPosition + emoji.length + 1);
        onChange?.(this.el.innerText);
    }

    moveCursorToEndAndFocus(endIndex: number) {
        if (!this.el) {
            throw new Error('Composer: moveCursorToEndAndFocus called but el is not defined');
        }

        const setPos = document.createRange();
        const set = window.getSelection();
        setPos.setStart(this.el.childNodes[0], endIndex);
        setPos.collapse(true);
        set?.removeAllRanges();
        set?.addRange(setPos);
    }

    getCaretPosition(element: HTMLElement) {
        const doc = element.ownerDocument || element.document;
        const win = doc.defaultView || doc.parentWindow;

        if (!win) {
            throw new Error('Composer: getCaretPosition called but win is not defined');
        }

        if (typeof win.getSelection !== 'undefined' && (win.getSelection()?.rangeCount ?? 0) > 0) {
            const range = win.getSelection()?.getRangeAt(0) as Range;
            const preCaretRange = range.cloneRange();
            preCaretRange.selectNodeContents(element);
            preCaretRange.setEnd(range.endContainer, range.endOffset);
            return preCaretRange.toString().length;
        }

        if (doc.selection && doc.selection.type !== 'Control') {
            const textRange = doc.selection.createRange?.();
            const preCaretTextRange = doc.body.createTextRange?.();
            preCaretTextRange?.moveToElementText?.(element);
            preCaretTextRange?.setEndPoint?.('EndToEnd', textRange as Range);
            return preCaretTextRange?.text?.length ?? 0;
        }

        return 0;
    }

    handleInputLock(locked: boolean) {
        this.setState({ inputLock: locked });
        return 0;
    }

    render = ({ pre, post, value, placeholder, onChange, onSubmit, onUpload, className, style }: ComposerProps) => (
        <div className={createClassName(styles, 'composer', {}, [className])} style={style}>
            {pre}
            <div
                ref={this.handleRef}
                contentEditable
                data-placeholder={placeholder}
                data-qa='livechat-composer'
                onInput={this.handleInput(onChange)}
                onKeyPress={this.handleKeypress(onSubmit)}
                onPaste={this.handlePaste(onUpload)}
                onDrop={this.handleDrop(onUpload)}
                onClick={this.handleClick}
                onCompositionStart={() => {
                    this.handleInputLock(true);
                }}
                onCompositionEnd={() => {
                    this.handleInputLock(false);
                    onChange?.(this.el?.innerText ?? '');
                }}
                className={createClassName(styles, 'composer__input')}
            >
                {value}
            </div>
            {post}
        </div>
    );
}

export { ComposerAction } from './ComposerAction';
export { ComposerActions } from './ComposerActions';