You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Global-sales/src/components/LexicalEditor/plugins/FloatingTextFormatToolbarPl...

401 lines
12 KiB
TypeScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import './index.css';
import {$isCodeHighlightNode} from '@lexical/code';
import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {mergeRegister} from '@lexical/utils';
import {
$getSelection,
$isParagraphNode,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_LOW,
FORMAT_TEXT_COMMAND,
LexicalEditor,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import {Dispatch, useCallback, useEffect, useRef, useState} from 'react';
import * as React from 'react';
import {createPortal} from 'react-dom';
import {getDOMRangeRect} from '../../utils/getDOMRangeRect';
import {getSelectedNode} from '../../utils/getSelectedNode';
import {setFloatingElemPosition} from '../../utils/setFloatingElemPosition';
import {INSERT_INLINE_COMMAND} from '../CommentPlugin';
function TextFormatFloatingToolbar({
editor,
anchorElem,
isLink,
isBold,
isItalic,
isUnderline,
isCode,
isStrikethrough,
isSubscript,
isSuperscript,
setIsLinkEditMode,
}: {
editor: LexicalEditor;
anchorElem: HTMLElement;
isBold: boolean;
isCode: boolean;
isItalic: boolean;
isLink: boolean;
isStrikethrough: boolean;
isSubscript: boolean;
isSuperscript: boolean;
isUnderline: boolean;
setIsLinkEditMode: Dispatch<boolean>;
}): JSX.Element {
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null);
const insertLink = useCallback(() => {
if (!isLink) {
setIsLinkEditMode(true);
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://');
} else {
setIsLinkEditMode(false);
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}
}, [editor, isLink, setIsLinkEditMode]);
const insertComment = () => {
editor.dispatchCommand(INSERT_INLINE_COMMAND, undefined);
};
function mouseMoveListener(e: MouseEvent) {
if (
popupCharStylesEditorRef?.current &&
(e.buttons === 1 || e.buttons === 3)
) {
if (popupCharStylesEditorRef.current.style.pointerEvents !== 'none') {
const x = e.clientX;
const y = e.clientY;
const elementUnderMouse = document.elementFromPoint(x, y);
if (!popupCharStylesEditorRef.current.contains(elementUnderMouse)) {
// Mouse is not over the target element => not a normal click, but probably a drag
popupCharStylesEditorRef.current.style.pointerEvents = 'none';
}
}
}
}
function mouseUpListener(e: MouseEvent) {
if (popupCharStylesEditorRef?.current) {
if (popupCharStylesEditorRef.current.style.pointerEvents !== 'auto') {
popupCharStylesEditorRef.current.style.pointerEvents = 'auto';
}
}
}
useEffect(() => {
if (popupCharStylesEditorRef?.current) {
document.addEventListener('mousemove', mouseMoveListener);
document.addEventListener('mouseup', mouseUpListener);
return () => {
document.removeEventListener('mousemove', mouseMoveListener);
document.removeEventListener('mouseup', mouseUpListener);
};
}
}, [popupCharStylesEditorRef]);
const $updateTextFormatFloatingToolbar = useCallback(() => {
const selection = $getSelection();
const popupCharStylesEditorElem = popupCharStylesEditorRef.current;
const nativeSelection = window.getSelection();
if (popupCharStylesEditorElem === null) {
return;
}
const rootElement = editor.getRootElement();
if (
selection !== null &&
nativeSelection !== null &&
!nativeSelection.isCollapsed &&
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
const rangeRect = getDOMRangeRect(nativeSelection, rootElement);
setFloatingElemPosition(
rangeRect,
popupCharStylesEditorElem,
anchorElem,
isLink,
);
}
}, [editor, anchorElem, isLink]);
useEffect(() => {
const scrollerElem = anchorElem.parentElement;
const update = () => {
editor.getEditorState().read(() => {
$updateTextFormatFloatingToolbar();
});
};
window.addEventListener('resize', update);
if (scrollerElem) {
scrollerElem.addEventListener('scroll', update);
}
return () => {
window.removeEventListener('resize', update);
if (scrollerElem) {
scrollerElem.removeEventListener('scroll', update);
}
};
}, [editor, $updateTextFormatFloatingToolbar, anchorElem]);
useEffect(() => {
editor.getEditorState().read(() => {
$updateTextFormatFloatingToolbar();
});
return mergeRegister(
editor.registerUpdateListener(({editorState}) => {
editorState.read(() => {
$updateTextFormatFloatingToolbar();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
$updateTextFormatFloatingToolbar();
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, $updateTextFormatFloatingToolbar]);
return (
<div ref={popupCharStylesEditorRef} className="floating-text-format-popup">
{editor.isEditable() && (
<>
<button
type="button"
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
}}
className={'popup-item spaced ' + (isBold ? 'active' : '')}
aria-label="Format text as bold">
<i className="format bold" />
</button>
<button
type="button"
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
}}
className={'popup-item spaced ' + (isItalic ? 'active' : '')}
aria-label="Format text as italics">
<i className="format italic" />
</button>
<button
type="button"
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
}}
className={'popup-item spaced ' + (isUnderline ? 'active' : '')}
aria-label="Format text to underlined">
<i className="format underline" />
</button>
<button
type="button"
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
}}
className={'popup-item spaced ' + (isStrikethrough ? 'active' : '')}
aria-label="Format text with a strikethrough">
<i className="format strikethrough" />
</button>
<button
type="button"
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript');
}}
className={'popup-item spaced ' + (isSubscript ? 'active' : '')}
title="Subscript"
aria-label="Format Subscript">
<i className="format subscript" />
</button>
<button
type="button"
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript');
}}
className={'popup-item spaced ' + (isSuperscript ? 'active' : '')}
title="Superscript"
aria-label="Format Superscript">
<i className="format superscript" />
</button>
<button
type="button"
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
}}
className={'popup-item spaced ' + (isCode ? 'active' : '')}
aria-label="Insert code block">
<i className="format code" />
</button>
<button
type="button"
onClick={insertLink}
className={'popup-item spaced ' + (isLink ? 'active' : '')}
aria-label="Insert link">
<i className="format link" />
</button>
</>
)}
<button
type="button"
onClick={insertComment}
className={'popup-item spaced insert-comment'}
aria-label="Insert comment">
<i className="format add-comment" />
</button>
</div>
);
}
function useFloatingTextFormatToolbar(
editor: LexicalEditor,
anchorElem: HTMLElement,
setIsLinkEditMode: Dispatch<boolean>,
): JSX.Element | null {
const [isText, setIsText] = useState(false);
const [isLink, setIsLink] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isSubscript, setIsSubscript] = useState(false);
const [isSuperscript, setIsSuperscript] = useState(false);
const [isCode, setIsCode] = useState(false);
const updatePopup = useCallback(() => {
editor.getEditorState().read(() => {
// Should not to pop up the floating toolbar when using IME input
if (editor.isComposing()) {
return;
}
const selection = $getSelection();
const nativeSelection = window.getSelection();
const rootElement = editor.getRootElement();
if (
nativeSelection !== null &&
(!$isRangeSelection(selection) ||
rootElement === null ||
!rootElement.contains(nativeSelection.anchorNode))
) {
setIsText(false);
return;
}
if (!$isRangeSelection(selection)) {
return;
}
const node = getSelectedNode(selection);
// Update text format
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
setIsSubscript(selection.hasFormat('subscript'));
setIsSuperscript(selection.hasFormat('superscript'));
setIsCode(selection.hasFormat('code'));
// Update links
const parent = node.getParent();
if ($isLinkNode(parent) || $isLinkNode(node)) {
setIsLink(true);
} else {
setIsLink(false);
}
if (
!$isCodeHighlightNode(selection.anchor.getNode()) &&
selection.getTextContent() !== ''
) {
setIsText($isTextNode(node) || $isParagraphNode(node));
} else {
setIsText(false);
}
const rawTextContent = selection.getTextContent().replace(/\n/g, '');
if (!selection.isCollapsed() && rawTextContent === '') {
setIsText(false);
return;
}
});
}, [editor]);
useEffect(() => {
document.addEventListener('selectionchange', updatePopup);
return () => {
document.removeEventListener('selectionchange', updatePopup);
};
}, [updatePopup]);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(() => {
updatePopup();
}),
editor.registerRootListener(() => {
if (editor.getRootElement() === null) {
setIsText(false);
}
}),
);
}, [editor, updatePopup]);
if (!isText) {
return null;
}
return createPortal(
<TextFormatFloatingToolbar
editor={editor}
anchorElem={anchorElem}
isLink={isLink}
isBold={isBold}
isItalic={isItalic}
isStrikethrough={isStrikethrough}
isSubscript={isSubscript}
isSuperscript={isSuperscript}
isUnderline={isUnderline}
isCode={isCode}
setIsLinkEditMode={setIsLinkEditMode}
/>,
anchorElem,
);
}
export default function FloatingTextFormatToolbarPlugin({
anchorElem = document.body,
setIsLinkEditMode,
}: {
anchorElem?: HTMLElement;
setIsLinkEditMode: Dispatch<boolean>;
}): JSX.Element | null {
const [editor] = useLexicalComposerContext();
return useFloatingTextFormatToolbar(editor, anchorElem, setIsLinkEditMode);
}