chore: floating link editor [skip e2e]
This commit is contained in:
@@ -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
|
||||||
@@ -43,14 +43,14 @@ import { InsertRemoteImageDialog } from '../RemoteImagePlugin/RemoteImagePlugin'
|
|||||||
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
|
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
|
||||||
import { Toolbar, ToolbarItem, useToolbarStore } from '@ariakit/react'
|
import { Toolbar, ToolbarItem, useToolbarStore } from '@ariakit/react'
|
||||||
import { PasswordBlock } from '../Blocks/Password'
|
import { PasswordBlock } from '../Blocks/Password'
|
||||||
import LinkEditor from './ToolbarLinkEditor'
|
import { URL_REGEX } from '@/Constants/Constants'
|
||||||
import { FOCUSABLE_BUT_NOT_TABBABLE, URL_REGEX } from '@/Constants/Constants'
|
import { $isLinkTextNode } from './ToolbarLinkTextEditor'
|
||||||
import LinkTextEditor, { $isLinkTextNode } from './ToolbarLinkTextEditor'
|
|
||||||
import Popover from '@/Components/Popover/Popover'
|
import Popover from '@/Components/Popover/Popover'
|
||||||
import LexicalTableOfContents from '@lexical/react/LexicalTableOfContents'
|
import LexicalTableOfContents from '@lexical/react/LexicalTableOfContents'
|
||||||
import Menu from '@/Components/Menu/Menu'
|
import Menu from '@/Components/Menu/Menu'
|
||||||
import MenuItem from '@/Components/Menu/MenuItem'
|
import MenuItem from '@/Components/Menu/MenuItem'
|
||||||
import { remToPx } from '@/Utils'
|
import { remToPx } from '@/Utils'
|
||||||
|
import FloatingLinkEditor from './FloatingLinkEditor'
|
||||||
|
|
||||||
const TOGGLE_LINK_AND_EDIT_COMMAND = createCommand<string | null>('TOGGLE_LINK_AND_EDIT_COMMAND')
|
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 [isAutoLink, setIsAutoLink] = useState(false)
|
||||||
const [isLinkText, setIsLinkText] = useState(false)
|
const [isLinkText, setIsLinkText] = useState(false)
|
||||||
const [isLinkEditMode, setIsLinkEditMode] = useState(false)
|
const [isLinkEditMode, setIsLinkEditMode] = useState(false)
|
||||||
const [isLinkTextEditMode, setIsLinkTextEditMode] = useState(false)
|
|
||||||
const [linkText, setLinkText] = useState<string>('')
|
const [linkText, setLinkText] = useState<string>('')
|
||||||
const [linkUrl, setLinkUrl] = useState<string>('')
|
const [linkUrl, setLinkUrl] = useState<string>('')
|
||||||
|
|
||||||
@@ -403,41 +402,17 @@ const ToolbarPlugin = () => {
|
|||||||
id="super-mobile-toolbar"
|
id="super-mobile-toolbar"
|
||||||
ref={containerRef}
|
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 && (
|
{isLink && (
|
||||||
<>
|
<FloatingLinkEditor
|
||||||
<div
|
linkUrl={linkUrl}
|
||||||
className="border-t border-border px-1 py-1 focus:shadow-none focus:outline-none md:border-0 md:px-0 md:py-0"
|
linkText={linkText}
|
||||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
isEditMode={isLinkEditMode}
|
||||||
>
|
setEditMode={setIsLinkEditMode}
|
||||||
<LinkEditor
|
editor={editor}
|
||||||
linkUrl={linkUrl}
|
isAutoLink={isAutoLink}
|
||||||
isEditMode={isLinkEditMode}
|
isLinkText={isLinkText}
|
||||||
setEditMode={setIsLinkEditMode}
|
isMobile={isMobile}
|
||||||
isAutoLink={isAutoLink}
|
/>
|
||||||
editor={editor}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
role="presentation"
|
|
||||||
className="my-1 hidden h-px bg-border translucent-ui:bg-[--popover-border-color] md:block"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full flex-shrink-0 border-t border-border md:border-0">
|
<div className="flex w-full flex-shrink-0 border-t border-border md:border-0">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
|
|||||||
@@ -51,3 +51,33 @@ export const useAutoElementRect = (
|
|||||||
|
|
||||||
return rect
|
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])
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user