diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugin/FloatingLinkEditor.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugin/FloatingLinkEditor.tsx new file mode 100644 index 000000000..316208d89 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugin/FloatingLinkEditor.tsx @@ -0,0 +1,263 @@ +import { getPositionedPopoverStyles } from '@/Components/Popover/GetPositionedPopoverStyles' +import { + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_LOW, + LexicalEditor, + SELECTION_CHANGE_COMMAND, +} from 'lexical' +import { useCallback, useEffect, useRef, useState } from 'react' +import { getDOMRangeRect } from '../../Lexical/Utils/getDOMRangeRect' +import { classNames } from '@standardnotes/snjs' +import Icon from '@/Components/Icon/Icon' +import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip' +import { TOGGLE_LINK_COMMAND } from '@lexical/link' +import Portal from '@/Components/Portal/Portal' +import { mergeRegister } from '@lexical/utils' +import { KeyboardKey } from '@standardnotes/ui-services' +import Button from '@/Components/Button/Button' +import { sanitizeUrl } from '../../Lexical/Utils/sanitizeUrl' +import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode' +import { $isLinkTextNode } from './ToolbarLinkTextEditor' +import { useElementResize } from '@/Hooks/useElementRect' + +const FloatingLinkEditor = ({ + linkUrl, + linkText, + isEditMode, + setEditMode, + editor, + isAutoLink, + isLinkText, + isMobile, +}: { + linkUrl: string + linkText: string + isEditMode: boolean + setEditMode: (isEditMode: boolean) => void + editor: LexicalEditor + isLinkText: boolean + isAutoLink: boolean + isMobile: boolean +}) => { + const [editedLinkUrl, setEditedLinkUrl] = useState(() => linkUrl) + useEffect(() => { + setEditedLinkUrl(linkUrl) + }, [linkUrl]) + const [editedLinkText, setEditedLinkText] = useState(() => linkText) + useEffect(() => { + setEditedLinkText(linkText) + }, [linkText]) + + const linkEditorRef = useRef(null) + + const updateLinkEditorPosition = useCallback(() => { + if (isMobile) { + return + } + + const linkEditorElement = linkEditorRef.current + + if (!linkEditorElement) { + setTimeout(updateLinkEditorPosition) + return + } + + const nativeSelection = window.getSelection() + const rootElement = editor.getRootElement() + + if (nativeSelection !== null && rootElement !== null && rootElement.contains(nativeSelection.anchorNode)) { + const rangeRect = getDOMRangeRect(nativeSelection, rootElement) + const linkEditorRect = linkEditorElement.getBoundingClientRect() + const rootElementRect = rootElement.getBoundingClientRect() + + const calculatedStyles = getPositionedPopoverStyles({ + align: 'start', + side: 'top', + anchorRect: rangeRect, + popoverRect: linkEditorRect, + documentRect: rootElementRect, + offset: 12, + maxHeightFunction: () => 'none', + }) + if (calculatedStyles) { + Object.entries(calculatedStyles).forEach(([key, value]) => { + linkEditorElement.style.setProperty(key, value) + }) + linkEditorElement.style.opacity = '1' + } + } + }, [editor, isMobile]) + + useElementResize(linkEditorRef.current, updateLinkEditorPosition) + + useEffect(() => { + updateLinkEditorPosition() + + return mergeRegister( + editor.registerUpdateListener(() => { + updateLinkEditorPosition() + }), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload) => { + updateLinkEditorPosition() + return false + }, + COMMAND_PRIORITY_LOW, + ), + ) + }, [editor, updateLinkEditorPosition]) + + const focusInput = useCallback((input: HTMLInputElement | null) => { + if (input) { + input.focus() + } + }, []) + + const handleSubmission = () => { + if (editedLinkUrl !== '') { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(editedLinkUrl)) + } + editor.update(() => { + const selection = $getSelection() + if (!$isRangeSelection(selection)) { + return + } + const node = getSelectedNode(selection) + if (!$isLinkTextNode(node, selection)) { + return + } + node.setTextContent(editedLinkText) + }) + setEditMode(false) + } + + return ( + +
+ {isEditMode ? ( +
+ {isLinkText && ( +
+ + { + setEditedLinkText(event.target.value) + }} + onKeyDown={(event) => { + if (event.key === KeyboardKey.Enter) { + event.preventDefault() + handleSubmission() + } else if (event.key === KeyboardKey.Escape) { + event.preventDefault() + setEditMode(false) + } + }} + className="flex-grow rounded-sm bg-contrast p-1 text-text sm:min-w-[20ch]" + /> +
+ )} +
+ + { + setEditedLinkUrl(event.target.value) + }} + onKeyDown={(event) => { + if (event.key === KeyboardKey.Enter) { + event.preventDefault() + handleSubmission() + } else if (event.key === KeyboardKey.Escape) { + event.preventDefault() + setEditMode(false) + } + }} + className="flex-grow rounded-sm bg-contrast p-1 text-text sm:min-w-[40ch]" + /> +
+
+ + + + + + +
+
+ ) : ( +
+ + +
{linkUrl}
+
+ + + + {!isAutoLink && ( + <> + + + + + + + + )} +
+ )} +
+
+ ) +} + +export default FloatingLinkEditor diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugin/ToolbarPlugin.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugin/ToolbarPlugin.tsx index 6e5a8935c..d2a782aeb 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugin/ToolbarPlugin.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ToolbarPlugin/ToolbarPlugin.tsx @@ -43,14 +43,14 @@ import { InsertRemoteImageDialog } from '../RemoteImagePlugin/RemoteImagePlugin' import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip' import { Toolbar, ToolbarItem, useToolbarStore } from '@ariakit/react' import { PasswordBlock } from '../Blocks/Password' -import LinkEditor from './ToolbarLinkEditor' -import { FOCUSABLE_BUT_NOT_TABBABLE, URL_REGEX } from '@/Constants/Constants' -import LinkTextEditor, { $isLinkTextNode } from './ToolbarLinkTextEditor' +import { URL_REGEX } from '@/Constants/Constants' +import { $isLinkTextNode } from './ToolbarLinkTextEditor' import Popover from '@/Components/Popover/Popover' import LexicalTableOfContents from '@lexical/react/LexicalTableOfContents' import Menu from '@/Components/Menu/Menu' import MenuItem from '@/Components/Menu/MenuItem' import { remToPx } from '@/Utils' +import FloatingLinkEditor from './FloatingLinkEditor' const TOGGLE_LINK_AND_EDIT_COMMAND = createCommand('TOGGLE_LINK_AND_EDIT_COMMAND') @@ -143,7 +143,6 @@ const ToolbarPlugin = () => { const [isAutoLink, setIsAutoLink] = useState(false) const [isLinkText, setIsLinkText] = useState(false) const [isLinkEditMode, setIsLinkEditMode] = useState(false) - const [isLinkTextEditMode, setIsLinkTextEditMode] = useState(false) const [linkText, setLinkText] = useState('') const [linkUrl, setLinkUrl] = useState('') @@ -403,41 +402,17 @@ const ToolbarPlugin = () => { id="super-mobile-toolbar" ref={containerRef} > - {isLinkText && !isAutoLink && ( - <> -
- -
-
- - )} {isLink && ( - <> -
- -
-
- + )}
void) => { + useEffect(() => { + let windowResizeDebounceTimeout: number + let windowResizeHandler: () => void + + if (element) { + const resizeObserver = new ResizeObserver(() => { + callback() + }) + resizeObserver.observe(element) + + windowResizeHandler = () => { + window.clearTimeout(windowResizeDebounceTimeout) + + window.setTimeout(() => { + callback() + }, DebounceTimeInMs) + } + window.addEventListener('resize', windowResizeHandler) + + return () => { + resizeObserver.unobserve(element) + window.removeEventListener('resize', windowResizeHandler) + } + } else { + return + } + }, [element, callback]) +}