2022-10-29 15:18:42 +02:00

517 lines
14 KiB
TypeScript

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<string, keyof ISelectedTextFormats> = {
B: 'bold',
STRONG: 'bold',
I: 'italic',
EM: 'italic',
U: 'underline',
DEL: 'strikethrough',
CODE: 'monospace',
SPAN: 'spoiler',
};
const fragmentEl = document.createElement('div');
const TextFormatter: FC<OwnProps> = ({
isOpen,
anchorPosition,
selectedRange,
setSelectedRange,
onClose,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const linkUrlInputRef = useRef<HTMLInputElement>(null);
const { shouldRender, transitionClassNames } = useShowTransition(isOpen);
const [isLinkControlOpen, openLinkControl, closeLinkControl] = useFlag();
const [linkUrl, setLinkUrl] = useState('');
const [isEditingLink, setIsEditingLink] = useState(false);
const [inputClassName, setInputClassName] = useState<string | undefined>();
const [selectedTextFormats, setSelectedTextFormats] = useState<ISelectedTextFormats>({});
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<HTMLInputElement>) {
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, `<span class="spoiler" data-entity-type="${ApiMessageEntityTypes.Spoiler}">${text}</span>`,
);
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, `<del>${text}</del>`);
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, `<code class="text-entity-code" dir="auto">${text}</code>`);
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,
`<a href=${formattedLinkUrl} class="text-entity-link" dir="auto">${text}</a>`,
);
onClose();
}, [getSelectedElement, getSelectedText, isEditingLink, linkUrl, onClose, restoreSelection]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const HANDLERS_BY_KEY: Record<string, AnyToVoidFunction> = {
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<HTMLDivElement>) {
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 (
<div
ref={containerRef}
className={className}
style={style}
onKeyDown={handleContainerKeyDown}
>
<div className="TextFormatter-buttons">
<Button
color="translucent"
ariaLabel="Spoiler text"
className={getFormatButtonClassName('spoiler')}
onClick={handleSpoilerText}
>
<i className="icon-eye-closed" />
</Button>
<div className="TextFormatter-divider" />
<Button
color="translucent"
ariaLabel="Bold text"
className={getFormatButtonClassName('bold')}
onClick={handleBoldText}
>
<i className="icon-bold" />
</Button>
<Button
color="translucent"
ariaLabel="Italic text"
className={getFormatButtonClassName('italic')}
onClick={handleItalicText}
>
<i className="icon-italic" />
</Button>
<Button
color="translucent"
ariaLabel="Underlined text"
className={getFormatButtonClassName('underline')}
onClick={handleUnderlineText}
>
<i className="icon-underlined" />
</Button>
<Button
color="translucent"
ariaLabel="Strikethrough text"
className={getFormatButtonClassName('strikethrough')}
onClick={handleStrikethroughText}
>
<i className="icon-strikethrough" />
</Button>
<Button
color="translucent"
ariaLabel="Monospace text"
className={getFormatButtonClassName('monospace')}
onClick={handleMonospaceText}
>
<i className="icon-monospace" />
</Button>
<div className="TextFormatter-divider" />
<Button color="translucent" ariaLabel={lang('TextFormat.AddLinkTitle')} onClick={openLinkControl}>
<i className="icon-link" />
</Button>
</div>
<div className="TextFormatter-link-control">
<div className="TextFormatter-buttons">
<Button color="translucent" ariaLabel={lang('Cancel')} onClick={closeLinkControl}>
<i className="icon-arrow-left" />
</Button>
<div className="TextFormatter-divider" />
<div
className={buildClassName('TextFormatter-link-url-input-wrapper', inputClassName)}
>
<input
ref={linkUrlInputRef}
className="TextFormatter-link-url-input"
type="text"
value={linkUrl}
placeholder="Enter URL..."
autoComplete="off"
inputMode="url"
dir="auto"
onChange={handleLinkUrlChange}
onScroll={updateInputStyles}
/>
</div>
<div className={linkUrlConfirmClassName}>
<div className="TextFormatter-divider" />
<Button
color="translucent"
ariaLabel={lang('Save')}
className="color-primary"
onClick={handleLinkUrlConfirm}
>
<i className="icon-check" />
</Button>
</div>
</div>
</div>
</div>
);
};
export default memo(TextFormatter);