chore: floating link editor [skip e2e]

This commit is contained in:
Aman Harwara
2023-10-24 19:53:06 +05:30
parent 51f0d401b6
commit ccc9d705a5
3 changed files with 306 additions and 38 deletions

View File

@@ -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<HTMLDivElement>(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 (
<Portal>
<div
ref={linkEditorRef}
className="absolute left-0 top-0 z-modal rounded-lg border border-border bg-contrast px-2 py-1 shadow-sm shadow-contrast translucent-ui:border-[--popover-border-color] translucent-ui:bg-[--popover-background-color] translucent-ui:[backdrop-filter:var(--popover-backdrop-filter)] md:opacity-0"
>
{isEditMode ? (
<div className="flex flex-col gap-2 py-1">
{isLinkText && (
<div className="flex items-center gap-1.5">
<Icon type="plain-text" className="flex-shrink-0" />
<input
value={editedLinkText}
onChange={(event) => {
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]"
/>
</div>
)}
<div className="flex items-center gap-1.5">
<Icon type="link" className="flex-shrink-0" />
<input
ref={focusInput}
value={editedLinkUrl}
onChange={(event) => {
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]"
/>
</div>
<div className="flex items-center justify-end gap-1.5">
<StyledTooltip showOnMobile showOnHover label="Cancel editing">
<Button
onClick={() => {
setEditMode(false)
editor.focus()
}}
onMouseDown={(event) => event.preventDefault()}
>
Cancel
</Button>
</StyledTooltip>
<StyledTooltip showOnMobile showOnHover label="Save link">
<Button primary onClick={handleSubmission} onMouseDown={(event) => event.preventDefault()}>
Apply
</Button>
</StyledTooltip>
</div>
</div>
) : (
<div className="flex items-center gap-1">
<a
className={classNames(
'mr-1 flex flex-grow items-center gap-2 overflow-hidden whitespace-nowrap underline',
isAutoLink && 'py-2.5',
)}
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
>
<Icon type="open-in" className="ml-1 flex-shrink-0" />
<div className="max-w-[35ch] overflow-hidden text-ellipsis">{linkUrl}</div>
</a>
<StyledTooltip showOnMobile showOnHover label="Copy link">
<button
className="flex select-none items-center justify-center rounded p-2 enabled:hover:bg-default disabled:opacity-50 md:border md:border-transparent enabled:hover:md:translucent-ui:border-[--popover-border-color]"
onClick={() => {
navigator.clipboard.writeText(linkUrl).catch(console.error)
}}
onMouseDown={(event) => event.preventDefault()}
>
<Icon type="copy" size="medium" />
</button>
</StyledTooltip>
{!isAutoLink && (
<>
<StyledTooltip showOnMobile showOnHover label="Edit link">
<button
className="flex select-none items-center justify-center rounded p-2 enabled:hover:bg-default disabled:opacity-50 md:border md:border-transparent enabled:hover:md:translucent-ui:border-[--popover-border-color]"
onClick={() => {
setEditedLinkUrl(linkUrl)
setEditMode(true)
}}
onMouseDown={(event) => event.preventDefault()}
>
<Icon type="pencil-filled" size="medium" />
</button>
</StyledTooltip>
<StyledTooltip showOnMobile showOnHover label="Remove link">
<button
className="flex select-none items-center justify-center rounded p-2 enabled:hover:bg-default disabled:opacity-50 md:border md:border-transparent enabled:hover:md:translucent-ui:border-[--popover-border-color]"
onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}}
onMouseDown={(event) => event.preventDefault()}
>
<Icon type="trash-filled" size="medium" />
</button>
</StyledTooltip>
</>
)}
</div>
)}
</div>
</Portal>
)
}
export default FloatingLinkEditor

View File

@@ -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<string | null>('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<string>('')
const [linkUrl, setLinkUrl] = useState<string>('')
@@ -403,41 +402,17 @@ const ToolbarPlugin = () => {
id="super-mobile-toolbar"
ref={containerRef}
>
{isLinkText && !isAutoLink && (
<>
<div className="border-t border-border px-1 py-1 md:border-0 md:px-0 md:py-0">
<LinkTextEditor
linkText={linkText}
editor={editor}
isEditMode={isLinkTextEditMode}
setEditMode={setIsLinkTextEditMode}
/>
</div>
<div
role="presentation"
className="my-1 hidden h-px bg-border translucent-ui:bg-[--popover-border-color] md:block"
/>
</>
)}
{isLink && (
<>
<div
className="border-t border-border px-1 py-1 focus:shadow-none focus:outline-none md:border-0 md:px-0 md:py-0"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
<LinkEditor
linkUrl={linkUrl}
isEditMode={isLinkEditMode}
setEditMode={setIsLinkEditMode}
isAutoLink={isAutoLink}
editor={editor}
/>
</div>
<div
role="presentation"
className="my-1 hidden h-px bg-border translucent-ui:bg-[--popover-border-color] md:block"
/>
</>
<FloatingLinkEditor
linkUrl={linkUrl}
linkText={linkText}
isEditMode={isLinkEditMode}
setEditMode={setIsLinkEditMode}
editor={editor}
isAutoLink={isAutoLink}
isLinkText={isLinkText}
isMobile={isMobile}
/>
)}
<div className="flex w-full flex-shrink-0 border-t border-border md:border-0">
<Toolbar

View File

@@ -51,3 +51,33 @@ export const useAutoElementRect = (
return rect
}
export const useElementResize = (element: HTMLElement | null | undefined, callback: () => 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])
}