import type { FC } from '../../../lib/teact/teact'; import React, { memo, useCallback, useEffect, useRef, useState, } from '../../../lib/teact/teact'; import type { IAnchorPosition } from '../../../types'; import { ApiMessageEntityTypes } from '../../../api/types'; import { EDITABLE_INPUT_ID } from '../../../config'; import buildClassName from '../../../util/buildClassName'; import { ensureProtocol } from '../../../util/ensureProtocol'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; import getKeyFromEvent from '../../../util/getKeyFromEvent'; import { INPUT_CUSTOM_EMOJI_SELECTOR } from './helpers/customEmoji'; import useShowTransition from '../../../hooks/useShowTransition'; import useVirtualBackdrop from '../../../hooks/useVirtualBackdrop'; import useFlag from '../../../hooks/useFlag'; import useLang from '../../../hooks/useLang'; import Button from '../../ui/Button'; import './TextFormatter.scss'; export type OwnProps = { isOpen: boolean; anchorPosition?: IAnchorPosition; selectedRange?: Range; setSelectedRange: (range: Range) => void; onClose: () => void; }; interface ISelectedTextFormats { bold?: boolean; italic?: boolean; underline?: boolean; strikethrough?: boolean; monospace?: boolean; spoiler?: boolean; } const TEXT_FORMAT_BY_TAG_NAME: Record = { B: 'bold', STRONG: 'bold', I: 'italic', EM: 'italic', U: 'underline', DEL: 'strikethrough', CODE: 'monospace', SPAN: 'spoiler', }; const fragmentEl = document.createElement('div'); const TextFormatter: FC = ({ isOpen, anchorPosition, selectedRange, setSelectedRange, onClose, }) => { // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); // eslint-disable-next-line no-null/no-null const linkUrlInputRef = useRef(null); const { shouldRender, transitionClassNames } = useShowTransition(isOpen); const [isLinkControlOpen, openLinkControl, closeLinkControl] = useFlag(); const [linkUrl, setLinkUrl] = useState(''); const [isEditingLink, setIsEditingLink] = useState(false); const [inputClassName, setInputClassName] = useState(); const [selectedTextFormats, setSelectedTextFormats] = useState({}); useEffect(() => (isOpen ? captureEscKeyListener(onClose) : undefined), [isOpen, onClose]); useVirtualBackdrop( isOpen, containerRef, onClose, ); useEffect(() => { if (isLinkControlOpen) { linkUrlInputRef.current!.focus(); } else { setLinkUrl(''); setIsEditingLink(false); } }, [isLinkControlOpen]); useEffect(() => { if (!shouldRender) { closeLinkControl(); setSelectedTextFormats({}); setInputClassName(undefined); } }, [closeLinkControl, shouldRender]); useEffect(() => { if (!isOpen || !selectedRange) { return; } const selectedFormats: ISelectedTextFormats = {}; let { parentElement } = selectedRange.commonAncestorContainer; while (parentElement && parentElement.id !== EDITABLE_INPUT_ID) { const textFormat = TEXT_FORMAT_BY_TAG_NAME[parentElement.tagName]; if (textFormat) { selectedFormats[textFormat] = true; } parentElement = parentElement.parentElement; } setSelectedTextFormats(selectedFormats); }, [isOpen, selectedRange, openLinkControl]); const restoreSelection = useCallback(() => { if (!selectedRange) { return; } const selection = window.getSelection(); if (selection) { selection.removeAllRanges(); selection.addRange(selectedRange); } }, [selectedRange]); const updateSelectedRange = useCallback(() => { const selection = window.getSelection(); if (selection) { setSelectedRange(selection.getRangeAt(0)); } }, [setSelectedRange]); const getSelectedText = useCallback((shouldDropCustomEmoji?: boolean) => { if (!selectedRange) { return undefined; } fragmentEl.replaceChildren(selectedRange.cloneContents()); if (shouldDropCustomEmoji) { fragmentEl.querySelectorAll(INPUT_CUSTOM_EMOJI_SELECTOR).forEach((el) => { el.replaceWith(el.getAttribute('alt')!); }); } return fragmentEl.innerHTML; }, [selectedRange]); const getSelectedElement = useCallback(() => { if (!selectedRange) { return undefined; } return selectedRange.commonAncestorContainer.parentElement; }, [selectedRange]); function updateInputStyles() { const input = linkUrlInputRef.current; if (!input) { return; } const { offsetWidth, scrollWidth, scrollLeft } = input; if (scrollWidth <= offsetWidth) { setInputClassName(undefined); return; } let className = ''; if (scrollLeft < scrollWidth - offsetWidth) { className = 'mask-right'; } if (scrollLeft > 0) { className += ' mask-left'; } setInputClassName(className); } function handleLinkUrlChange(e: React.ChangeEvent) { setLinkUrl(e.target.value); updateInputStyles(); } function getFormatButtonClassName(key: keyof ISelectedTextFormats) { if (selectedTextFormats[key]) { return 'active'; } if (key === 'monospace' || key === 'strikethrough') { if (Object.keys(selectedTextFormats).some( (fKey) => fKey !== key && Boolean(selectedTextFormats[fKey as keyof ISelectedTextFormats]), )) { return 'disabled'; } } else if (selectedTextFormats.monospace || selectedTextFormats.strikethrough) { return 'disabled'; } return undefined; } const handleSpoilerText = useCallback(() => { if (selectedTextFormats.spoiler) { const element = getSelectedElement(); if ( !selectedRange || !element || element.dataset.entityType !== ApiMessageEntityTypes.Spoiler || !element.textContent ) { return; } element.replaceWith(element.textContent); setSelectedTextFormats((selectedFormats) => ({ ...selectedFormats, spoiler: false, })); return; } const text = getSelectedText(); document.execCommand( 'insertHTML', false, `${text}`, ); onClose(); }, [getSelectedElement, getSelectedText, onClose, selectedRange, selectedTextFormats.spoiler]); const handleBoldText = useCallback(() => { setSelectedTextFormats((selectedFormats) => { // Somehow re-applying 'bold' command to already bold text doesn't work document.execCommand(selectedFormats.bold ? 'removeFormat' : 'bold'); Object.keys(selectedFormats).forEach((key) => { if ((key === 'italic' || key === 'underline') && Boolean(selectedFormats[key])) { document.execCommand(key); } }); updateSelectedRange(); return { ...selectedFormats, bold: !selectedFormats.bold, }; }); }, [updateSelectedRange]); const handleItalicText = useCallback(() => { document.execCommand('italic'); updateSelectedRange(); setSelectedTextFormats((selectedFormats) => ({ ...selectedFormats, italic: !selectedFormats.italic, })); }, [updateSelectedRange]); const handleUnderlineText = useCallback(() => { document.execCommand('underline'); updateSelectedRange(); setSelectedTextFormats((selectedFormats) => ({ ...selectedFormats, underline: !selectedFormats.underline, })); }, [updateSelectedRange]); const handleStrikethroughText = useCallback(() => { if (selectedTextFormats.strikethrough) { const element = getSelectedElement(); if ( !selectedRange || !element || element.tagName !== 'DEL' || !element.textContent ) { return; } element.replaceWith(element.textContent); setSelectedTextFormats((selectedFormats) => ({ ...selectedFormats, strikethrough: false, })); return; } const text = getSelectedText(); document.execCommand('insertHTML', false, `${text}`); onClose(); }, [ getSelectedElement, getSelectedText, onClose, selectedRange, selectedTextFormats.strikethrough, ]); const handleMonospaceText = useCallback(() => { if (selectedTextFormats.monospace) { const element = getSelectedElement(); if ( !selectedRange || !element || element.tagName !== 'CODE' || !element.textContent ) { return; } element.replaceWith(element.textContent); setSelectedTextFormats((selectedFormats) => ({ ...selectedFormats, monospace: false, })); return; } const text = getSelectedText(true); document.execCommand('insertHTML', false, `${text}`); onClose(); }, [ getSelectedElement, getSelectedText, onClose, selectedRange, selectedTextFormats.monospace, ]); const handleLinkUrlConfirm = useCallback(() => { const formattedLinkUrl = (ensureProtocol(linkUrl) || '').split('%').map(encodeURI).join('%'); if (isEditingLink) { const element = getSelectedElement(); if (!element || element.tagName !== 'A') { return; } (element as HTMLAnchorElement).href = formattedLinkUrl; onClose(); return; } const text = getSelectedText(true); restoreSelection(); document.execCommand( 'insertHTML', false, `${text}`, ); onClose(); }, [getSelectedElement, getSelectedText, isEditingLink, linkUrl, onClose, restoreSelection]); const handleKeyDown = useCallback((e: KeyboardEvent) => { const HANDLERS_BY_KEY: Record = { k: openLinkControl, b: handleBoldText, u: handleUnderlineText, i: handleItalicText, m: handleMonospaceText, s: handleStrikethroughText, p: handleSpoilerText, }; const handler = HANDLERS_BY_KEY[getKeyFromEvent(e)]; if ( e.altKey || !(e.ctrlKey || e.metaKey) || !handler ) { return; } e.preventDefault(); e.stopPropagation(); handler(); }, [ openLinkControl, handleBoldText, handleUnderlineText, handleItalicText, handleMonospaceText, handleStrikethroughText, handleSpoilerText, ]); useEffect(() => { if (isOpen) { document.addEventListener('keydown', handleKeyDown); } return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, handleKeyDown]); const lang = useLang(); function handleContainerKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter' && isLinkControlOpen) { handleLinkUrlConfirm(); e.preventDefault(); } } if (!shouldRender) { return undefined; } const className = buildClassName( 'TextFormatter', transitionClassNames, isLinkControlOpen && 'link-control-shown', ); const linkUrlConfirmClassName = buildClassName( 'TextFormatter-link-url-confirm', Boolean(linkUrl.length) && 'shown', ); const style = anchorPosition ? `left: ${anchorPosition.x}px; top: ${anchorPosition.y}px;--text-formatter-left: ${anchorPosition.x}px;` : ''; return (
); }; export default memo(TextFormatter);