2024-09-06 15:43:12 +02:00

515 lines
14 KiB
TypeScript

import type { FC } from '../../../lib/teact/teact';
import React, {
memo, 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 captureEscKeyListener from '../../../util/captureEscKeyListener';
import { ensureProtocol } from '../../../util/ensureProtocol';
import getKeyFromEvent from '../../../util/getKeyFromEvent';
import stopEvent from '../../../util/stopEvent';
import { INPUT_CUSTOM_EMOJI_SELECTOR } from './helpers/customEmoji';
import useFlag from '../../../hooks/useFlag';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import useShowTransitionDeprecated from '../../../hooks/useShowTransitionDeprecated';
import useVirtualBackdrop from '../../../hooks/useVirtualBackdrop';
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 } = useShowTransitionDeprecated(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,
true,
);
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 = useLastCallback(() => {
if (!selectedRange) {
return;
}
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(selectedRange);
}
});
const updateSelectedRange = useLastCallback(() => {
const selection = window.getSelection();
if (selection) {
setSelectedRange(selection.getRangeAt(0));
}
});
const getSelectedText = useLastCallback((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;
});
const getSelectedElement = useLastCallback(() => {
if (!selectedRange) {
return undefined;
}
return selectedRange.commonAncestorContainer.parentElement;
});
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 = useLastCallback(() => {
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();
});
const handleBoldText = useLastCallback(() => {
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,
};
});
});
const handleItalicText = useLastCallback(() => {
document.execCommand('italic');
updateSelectedRange();
setSelectedTextFormats((selectedFormats) => ({
...selectedFormats,
italic: !selectedFormats.italic,
}));
});
const handleUnderlineText = useLastCallback(() => {
document.execCommand('underline');
updateSelectedRange();
setSelectedTextFormats((selectedFormats) => ({
...selectedFormats,
underline: !selectedFormats.underline,
}));
});
const handleStrikethroughText = useLastCallback(() => {
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();
});
const handleMonospaceText = useLastCallback(() => {
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();
});
const handleLinkUrlConfirm = useLastCallback(() => {
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();
});
const handleKeyDown = useLastCallback((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();
});
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleKeyDown]);
const lang = useOldLang();
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}
// Prevents focus loss when clicking on the toolbar
onMouseDown={stopEvent}
>
<div className="TextFormatter-buttons">
<Button
color="translucent"
ariaLabel="Spoiler text"
className={getFormatButtonClassName('spoiler')}
onClick={handleSpoilerText}
>
<i className="icon icon-eye-closed" />
</Button>
<div className="TextFormatter-divider" />
<Button
color="translucent"
ariaLabel="Bold text"
className={getFormatButtonClassName('bold')}
onClick={handleBoldText}
>
<i className="icon icon-bold" />
</Button>
<Button
color="translucent"
ariaLabel="Italic text"
className={getFormatButtonClassName('italic')}
onClick={handleItalicText}
>
<i className="icon icon-italic" />
</Button>
<Button
color="translucent"
ariaLabel="Underlined text"
className={getFormatButtonClassName('underline')}
onClick={handleUnderlineText}
>
<i className="icon icon-underlined" />
</Button>
<Button
color="translucent"
ariaLabel="Strikethrough text"
className={getFormatButtonClassName('strikethrough')}
onClick={handleStrikethroughText}
>
<i className="icon icon-strikethrough" />
</Button>
<Button
color="translucent"
ariaLabel="Monospace text"
className={getFormatButtonClassName('monospace')}
onClick={handleMonospaceText}
>
<i className="icon icon-monospace" />
</Button>
<div className="TextFormatter-divider" />
<Button color="translucent" ariaLabel={lang('TextFormat.AddLinkTitle')} onClick={openLinkControl}>
<i className="icon icon-link" />
</Button>
</div>
<div className="TextFormatter-link-control">
<div className="TextFormatter-buttons">
<Button color="translucent" ariaLabel={lang('Cancel')} onClick={closeLinkControl}>
<i className="icon 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 icon-check" />
</Button>
</div>
</div>
</div>
</div>
);
};
export default memo(TextFormatter);