/* MIT License Copyright (c) Meta Platforms, Inc. and affiliates. This source code is licensed under the MIT license found in the LICENSE file in the /app/soapbox/features/compose/editor directory. */ 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, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_LOW, FORMAT_TEXT_COMMAND, LexicalEditor, SELECTION_CHANGE_COMMAND, } from 'lexical'; import { useCallback, useEffect, useRef, useState } from 'react'; import * as React from 'react'; import { createPortal } from 'react-dom'; import { Icon } from 'soapbox/components/ui'; import { getDOMRangeRect } from '../utils/get-dom-range-rect'; import { getSelectedNode } from '../utils/get-selected-node'; import { setFloatingElemPosition } from '../utils/set-floating-elem-position'; const TextFormatFloatingToolbar = ({ editor, anchorElem, isLink, isBold, isItalic, isUnderline, isCode, isStrikethrough, }: { editor: LexicalEditor; anchorElem: HTMLElement; isBold: boolean; isCode: boolean; isItalic: boolean; isLink: boolean; isStrikethrough: boolean; isUnderline: boolean; }): JSX.Element => { const popupCharStylesEditorRef = useRef(null); const insertLink = useCallback(() => { if (!isLink) { editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://'); } else { editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); } }, [editor, isLink]); 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); } }, [editor, anchorElem]); 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 (
{editor.isEditable() && ( <> )}
); }; const useFloatingTextFormatToolbar = ( editor: LexicalEditor, anchorElem: HTMLElement, ): 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 [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')); 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)); } else { setIsText(false); } }); }, [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 || isLink) { return null; } return createPortal( , anchorElem, ); }; const FloatingTextFormatToolbarPlugin = ({ anchorElem = document.body, }: { anchorElem?: HTMLElement; }): JSX.Element | null => { const [editor] = useLexicalComposerContext(); return useFloatingTextFormatToolbar(editor, anchorElem); }; export default FloatingTextFormatToolbarPlugin;