import EditIcon from '@mui/icons-material/Edit'; import { Box, IconButton, Link, Paper, Popper } from '@mui/material'; import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { mergeRegister } from '@lexical/utils'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { $getSelection, $isRangeSelection, $isTextNode, type LexicalEditor } from 'lexical'; import { dec, equals, gt, isNil, replace } from 'ramda'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import InputField from '../../InputField/Text'; import { editLinkModeAtom, isInsertingLinkAtom, linkValueAtom } from '../atoms'; import { labelEditLink, labelInputLink, labelSavedLink } from '../translatedLabels'; import { getDOMRangeRect } from '../utils/getDOMRangeRect'; import { getSelectedNode } from '../utils/getSelectedNode'; interface FloatingLinkEditorPluginProps { editable: boolean; openLinkInNewTab: boolean; } interface UseFloatingLinkEditorProps extends FloatingLinkEditorPluginProps { editor: LexicalEditor; } interface FloatingLinkEditorProps { editor: LexicalEditor; openLinkInNewTab: boolean; } interface TooltipPosition { x: number; y: number; } const FloatingLinkEditor = ({ editor, openLinkInNewTab }: FloatingLinkEditorProps): JSX.Element | null => { const nativeSelection = window.getSelection(); const rootElement = editor.getRootElement(); const { t } = useTranslation(); const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); const [editedUrl, setEditedUrl] = useState(''); const [editMode, setEditMode] = useAtom(editLinkModeAtom); const [linkUrl, _setLinkUrl] = useAtom(linkValueAtom); const rangeRect = getDOMRangeRect(nativeSelection, rootElement); const acceptOrCancelNewLinkValue = useCallback( (event: React.KeyboardEvent): void => { const { value } = event.target as HTMLInputElement; event.preventDefault(); if (event.key === 'Enter') { if (value !== '') { editor.dispatchCommand(TOGGLE_LINK_COMMAND, { target: openLinkInNewTab ? '_blank' : undefined, url: value }); } setEditMode(false); } if (event.key === 'Escape') { setEditMode(false); } }, [setEditMode, editor.dispatchCommand, openLinkInNewTab] ); const enterInEditMode = useCallback(() => { setEditedUrl(linkUrl); setEditMode(true); }, [linkUrl, setEditMode]); const changeValue = useCallback( (event: React.ChangeEvent): void => { const { value } = event.target; const matched = value.match(/https?:\/\//g); if (matched && gt(matched.length, 1)) { setEditedUrl( replace(matched.join(''), matched[dec(matched.length)], value) ); return; } setEditedUrl(value); }, [] ); useEffect(() => { const isPositioned = rangeRect && (!equals(rangeRect.x, 0) || !equals(rangeRect.y, 0)); if (!isPositioned || !nativeSelection) { return; } const nodeX = rangeRect.x; const nodeY = rangeRect.y; const nodeHeight = rangeRect.height; setTooltipPosition({ x: nodeX, y: nodeY + nodeHeight }); }, [rangeRect?.x, rangeRect?.y, rangeRect, nativeSelection]); if (isNil(rangeRect)) { return null; } const xOffset = tooltipPosition.x - (rootElement?.getBoundingClientRect()?.x || 0); const rootElementY = rootElement?.getBoundingClientRect()?.y || 0; const yOffset = tooltipPosition.y - rootElementY + 30; return ( {editMode ? ( { const { value } = event.target; event.preventDefault(); if (value !== '') { editor.dispatchCommand(TOGGLE_LINK_COMMAND, value); } setEditMode(false); }} onChange={changeValue} onKeyUp={acceptOrCancelNewLinkValue} size="small" value={editedUrl} /> ) : ( {linkUrl} )} ); }; const useFloatingTextFormatToolbar = ({ editor, editable, openLinkInNewTab }: UseFloatingLinkEditorProps): JSX.Element | null => { const [isText, setIsText] = useState(false); const isInsertingLink = useAtomValue(isInsertingLinkAtom); const editLinkMode = useAtomValue(editLinkModeAtom); const setLinkUrl = useSetAtom(linkValueAtom); const updatePopup = useCallback(() => { editor.getEditorState().read(() => { 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)) && !editLinkMode ) { setIsText(false); return; } if (!$isRangeSelection(selection) || editLinkMode) { return; } const node = getSelectedNode(selection); const parent = node.getParent(); if ($isLinkNode(parent)) { setLinkUrl(parent.getURL()); } else if ($isLinkNode(node)) { setLinkUrl(node.getURL()); } else { setLinkUrl(''); } if (selection.getTextContent() !== '') { setIsText($isTextNode(node) || $isLinkNode(node)); } else { setIsText(false); } }); }, [editor, editLinkMode, setLinkUrl]); useEffect(() => { document.addEventListener('selectionchange', updatePopup); return (): void => { document.removeEventListener('selectionchange', updatePopup); }; }, [updatePopup]); useEffect(() => { return mergeRegister( editor.registerUpdateListener(() => { updatePopup(); }), editor.registerRootListener(() => { if (editor.getRootElement() === null) { setIsText(false); } }) ); }, [editor, updatePopup]); if (!editable || !isText || !isInsertingLink) { return null; } return ( ); }; const FloatingActionsToolbarPlugin = ({ editable, openLinkInNewTab }: FloatingLinkEditorPluginProps): JSX.Element | null => { const [editor] = useLexicalComposerContext(); return useFloatingTextFormatToolbar({ editable, editor, openLinkInNewTab }); }; export default FloatingActionsToolbarPlugin;