refactor: link editor [skip e2e]

This commit is contained in:
Aman Harwara
2024-04-01 19:33:12 +05:30
parent f4ab9b0d97
commit 6cef772212
8 changed files with 472 additions and 582 deletions

View File

@@ -110,6 +110,7 @@
"dependencies": {
"@ariakit/react": "^0.3.9",
"@lexical/headless": "0.13.1",
"@lexical/link": "0.13.1",
"@lexical/list": "0.13.1",
"@lexical/rich-text": "0.13.1",
"@lexical/utils": "0.13.1",

View File

@@ -1,285 +0,0 @@
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 { 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'
import { createPortal } from 'react-dom'
import { ElementIds } from '@/Constants/ElementIDs'
import { getAdjustedStylesForNonPortalPopover } from '@/Components/Popover/Utils/getAdjustedStylesForNonPortal'
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 rangeRect = useRef<DOMRect>()
const updateLinkEditorPosition = useCallback(() => {
if (isMobile) {
return
}
const nativeSelection = window.getSelection()
const rootElement = editor.getRootElement()
if (nativeSelection !== null && rootElement !== null) {
if (rootElement.contains(nativeSelection.anchorNode)) {
rangeRect.current = getDOMRangeRect(nativeSelection, rootElement)
}
}
const linkEditorElement = linkEditorRef.current
if (!linkEditorElement) {
setTimeout(updateLinkEditorPosition)
return
}
if (!rootElement) {
return
}
const linkEditorRect = linkEditorElement.getBoundingClientRect()
const rootElementRect = rootElement.getBoundingClientRect()
const calculatedStyles = getPositionedPopoverStyles({
align: 'center',
side: 'top',
anchorRect: rangeRect.current,
popoverRect: linkEditorRect,
documentRect: rootElementRect,
offset: 12,
maxHeightFunction: () => 'none',
})
if (calculatedStyles) {
const adjustedStyles = getAdjustedStylesForNonPortalPopover(linkEditorElement, calculatedStyles)
Object.entries(adjustedStyles).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)
}
useEffect(() => {
setTimeout(updateLinkEditorPosition)
}, [isEditMode, updateLinkEditorPosition])
return createPortal(
<div
id="super-link-editor"
className="absolute bottom-12 left-1/2 z-modal w-[calc(100%_-_1rem)] -translate-x-1/2 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:bottom-[unset] md:left-0 md:top-0 md:w-auto md:translate-x-0 md:opacity-0"
ref={linkEditorRef}
>
{isEditMode ? (
<div
className="flex flex-col gap-2 py-1"
onBlur={(event) => {
if (!linkEditorRef.current?.contains(event.relatedTarget as Node)) {
setEditMode(false)
}
}}
>
{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>,
document.getElementById(ElementIds.SuperEditor) ?? document.body,
)
}
export default FloatingLinkEditor

View File

@@ -0,0 +1,261 @@
import { getPositionedPopoverStyles } from '@/Components/Popover/GetPositionedPopoverStyles'
import {
$isTextNode,
COMMAND_PRIORITY_LOW,
LexicalEditor,
RangeSelection,
SELECTION_CHANGE_COMMAND,
TextNode,
} 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 { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
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 { useElementResize } from '@/Hooks/useElementRect'
import { createPortal } from 'react-dom'
import { ElementIds } from '@/Constants/ElementIDs'
import { getAdjustedStylesForNonPortalPopover } from '@/Components/Popover/Utils/getAdjustedStylesForNonPortal'
export const $isLinkTextNode = (
node: ReturnType<typeof getSelectedNode>,
selection: RangeSelection,
): node is TextNode => {
const parent = node.getParent()
return (
$isLinkNode(parent) &&
parent.getChildrenSize() === 1 &&
$isTextNode(node) &&
parent.getFirstChild() === node &&
selection.anchor.getNode() === selection.focus.getNode()
)
}
const LinkEditor = ({
editor,
setIsEditingLink,
isMobile,
linkNode,
linkTextNode,
}: {
editor: LexicalEditor
setIsEditingLink: (isEditMode: boolean) => void
isMobile: boolean
linkNode: LinkNode | null
linkTextNode: TextNode | null
}) => {
const [url, setURL] = useState('')
const [text, setText] = useState('')
useEffect(() => {
editor.getEditorState().read(() => {
if (linkNode) {
setURL(linkNode.getURL())
}
if (linkTextNode) {
setText(linkTextNode.getTextContent())
}
})
}, [editor, linkNode, linkTextNode])
const linkInputRef = useRef<HTMLInputElement>(null)
const linkEditorRef = useRef<HTMLDivElement>(null)
const rangeRect = useRef<DOMRect>()
const positionUpdateRAF = useRef<number>()
const updateLinkEditorPosition = useCallback(() => {
if (positionUpdateRAF.current) {
cancelAnimationFrame(positionUpdateRAF.current)
}
positionUpdateRAF.current = requestAnimationFrame(() => {
if (isMobile) {
linkInputRef.current?.focus()
return
}
const nativeSelection = window.getSelection()
const rootElement = editor.getRootElement()
if (nativeSelection !== null && rootElement !== null) {
if (rootElement.contains(nativeSelection.anchorNode)) {
rangeRect.current = getDOMRangeRect(nativeSelection, rootElement)
}
}
const linkEditorElement = linkEditorRef.current
if (!linkEditorElement) {
setTimeout(updateLinkEditorPosition)
return
}
if (!rootElement) {
return
}
if (!rangeRect.current) {
return
}
const linkEditorRect = linkEditorElement.getBoundingClientRect()
const rootElementRect = rootElement.getBoundingClientRect()
const calculatedStyles = getPositionedPopoverStyles({
align: 'center',
side: 'top',
anchorRect: rangeRect.current,
popoverRect: linkEditorRect,
documentRect: rootElementRect,
offset: 12,
maxHeightFunction: () => 'none',
})
if (calculatedStyles) {
const adjustedStyles = getAdjustedStylesForNonPortalPopover(linkEditorElement, calculatedStyles)
Object.entries(adjustedStyles).forEach(([key, value]) => {
linkEditorElement.style.setProperty(key, value)
})
linkEditorElement.style.display = 'block'
linkInputRef.current?.focus()
}
})
}, [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 handleSubmission = () => {
if (url !== '') {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(url))
}
if (linkTextNode !== null && text !== '') {
editor.update(
() => {
linkTextNode.setTextContent(text)
},
{
discrete: true,
},
)
}
setIsEditingLink(false)
}
useEffect(() => {
const linkEditor = linkEditorRef.current
if (!linkEditor) {
return
}
const handleFocusOut = (event: FocusEvent) => {
if (!linkEditor.contains(event.relatedTarget as Node)) {
setIsEditingLink(false)
}
}
linkEditor.addEventListener('focusout', handleFocusOut)
return () => {
linkEditor.removeEventListener('focusout', handleFocusOut)
}
}, [setIsEditingLink])
return createPortal(
<div
className={classNames(
'absolute z-dropdown-menu rounded-lg border border-border bg-contrast px-2 py-1 shadow-sm shadow-contrast',
isMobile
? 'bottom-12 left-1/2 w-[calc(100%_-_1rem)] -translate-x-1/2'
: 'left-0 top-0 hidden w-auto translate-x-0 translucent-ui:border-[--popover-border-color] translucent-ui:bg-[--popover-background-color] translucent-ui:[backdrop-filter:var(--popover-backdrop-filter)]',
)}
ref={linkEditorRef}
>
<div className="flex flex-col gap-2 py-1">
{linkTextNode && (
<div className="flex items-center gap-1.5">
<Icon type="plain-text" className="flex-shrink-0" />
<input
value={text}
onChange={(event) => {
setText(event.target.value)
}}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Enter) {
event.preventDefault()
handleSubmission()
} else if (event.key === KeyboardKey.Escape) {
event.preventDefault()
setIsEditingLink(false)
}
}}
className="flex-grow rounded-sm border border-border bg-contrast p-1 text-text sm:min-w-[20ch] translucent-ui:md:border-0"
/>
</div>
)}
<div className="flex items-center gap-1.5">
<Icon type="link" className="flex-shrink-0" />
<input
ref={linkInputRef}
value={url}
onChange={(event) => {
setURL(event.target.value)
}}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Enter) {
event.preventDefault()
handleSubmission()
} else if (event.key === KeyboardKey.Escape) {
event.preventDefault()
setIsEditingLink(false)
}
}}
className="flex-grow rounded-sm border border-border bg-contrast p-1 text-text sm:min-w-[40ch] translucent-ui:md:border-0"
/>
</div>
<div className="flex items-center justify-end gap-1.5">
<StyledTooltip showOnMobile showOnHover label="Cancel editing">
<Button
onClick={() => {
setIsEditingLink(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>,
document.getElementById(ElementIds.SuperEditor) ?? document.body,
)
}
export default LinkEditor

View File

@@ -0,0 +1,172 @@
import Icon from '@/Components/Icon/Icon'
import { getPositionedPopoverStyles } from '@/Components/Popover/GetPositionedPopoverStyles'
import { getAdjustedStylesForNonPortalPopover } from '@/Components/Popover/Utils/getAdjustedStylesForNonPortal'
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
import { useElementResize } from '@/Hooks/useElementRect'
import { $isAutoLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import { mergeRegister } from '@lexical/utils'
import { classNames } from '@standardnotes/snjs'
import { COMMAND_PRIORITY_LOW, LexicalEditor, SELECTION_CHANGE_COMMAND } from 'lexical'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { getDOMRangeRect } from '../../Lexical/Utils/getDOMRangeRect'
import { ElementIds } from '@/Constants/ElementIDs'
import { createPortal } from 'react-dom'
type Props = {
linkNode: LinkNode
editor: LexicalEditor
isMobile: boolean
setIsEditingLink: (isEditingLink: boolean) => void
}
const LinkViewer = ({ isMobile, editor, linkNode, setIsEditingLink }: Props) => {
const linkViewerRef = useRef<HTMLDivElement>(null)
const [linkUrl, isAutoLink] = useMemo(() => {
let linkUrl = ''
let isAutoLink = false
editor.getEditorState().read(() => {
linkUrl = linkNode.getURL()
isAutoLink = $isAutoLinkNode(linkNode)
})
return [linkUrl, isAutoLink]
}, [editor, linkNode])
const rangeRect = useRef<DOMRect>()
const updateLinkEditorPosition = useCallback(() => {
if (isMobile) {
return
}
const nativeSelection = window.getSelection()
const rootElement = editor.getRootElement()
if (nativeSelection !== null && rootElement !== null) {
if (rootElement.contains(nativeSelection.anchorNode)) {
rangeRect.current = getDOMRangeRect(nativeSelection, rootElement)
}
}
const linkEditorElement = linkViewerRef.current
if (!linkEditorElement) {
setTimeout(updateLinkEditorPosition)
return
}
if (!rootElement) {
return
}
const linkEditorRect = linkEditorElement.getBoundingClientRect()
const rootElementRect = rootElement.getBoundingClientRect()
const calculatedStyles = getPositionedPopoverStyles({
align: 'center',
side: 'top',
anchorRect: rangeRect.current,
popoverRect: linkEditorRect,
documentRect: rootElementRect,
offset: 12,
maxHeightFunction: () => 'none',
})
if (calculatedStyles) {
const adjustedStyles = getAdjustedStylesForNonPortalPopover(linkEditorElement, calculatedStyles)
Object.entries(adjustedStyles).forEach(([key, value]) => {
linkEditorElement.style.setProperty(key, value)
})
linkEditorElement.style.opacity = '1'
}
}, [editor, isMobile])
useElementResize(linkViewerRef.current, updateLinkEditorPosition)
useEffect(() => {
updateLinkEditorPosition()
return mergeRegister(
editor.registerUpdateListener(() => {
updateLinkEditorPosition()
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload) => {
updateLinkEditorPosition()
return false
},
COMMAND_PRIORITY_LOW,
),
)
}, [editor, updateLinkEditorPosition])
if (!linkUrl) {
return null
}
return createPortal(
<div
className={classNames(
'absolute z-dropdown-menu rounded-lg border border-border bg-contrast px-2 py-1 shadow-sm shadow-contrast',
isMobile
? 'bottom-12 left-1/2 w-[calc(100%_-_1rem)] -translate-x-1/2'
: 'left-0 top-0 w-auto translate-x-0 opacity-0 translucent-ui:border-[--popover-border-color] translucent-ui:bg-[--popover-background-color] translucent-ui:[backdrop-filter:var(--popover-backdrop-filter)]',
)}
ref={linkViewerRef}
>
<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={() => {
setIsEditingLink(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>,
document.getElementById(ElementIds.SuperEditor) ?? document.body,
)
}
export default LinkViewer

View File

@@ -1,131 +0,0 @@
import Icon from '@/Components/Icon/Icon'
import { KeyboardKey } from '@standardnotes/ui-services'
import { sanitizeUrl } from '../../Lexical/Utils/sanitizeUrl'
import { TOGGLE_LINK_COMMAND } from '@lexical/link'
import { useCallback, useState, useRef, useEffect } from 'react'
import { LexicalEditor } from 'lexical'
import { classNames } from '@standardnotes/snjs'
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
type Props = {
linkUrl: string
isEditMode: boolean
setEditMode: (isEditMode: boolean) => void
editor: LexicalEditor
isAutoLink: boolean
}
const LinkEditor = ({ linkUrl, isEditMode, setEditMode, editor, isAutoLink }: Props) => {
const [editedLinkUrl, setEditedLinkUrl] = useState('')
const editModeContainer = useRef<HTMLDivElement>(null)
const handleLinkSubmission = () => {
if (editedLinkUrl !== '') {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(editedLinkUrl))
}
setEditMode(false)
}
const focusInput = useCallback((input: HTMLInputElement | null) => {
if (input) {
input.focus()
}
}, [])
useEffect(() => {
setEditedLinkUrl(linkUrl)
}, [linkUrl])
return isEditMode ? (
<div className="flex items-center gap-2" ref={editModeContainer}>
<input
id="link-input"
ref={focusInput}
value={editedLinkUrl}
onChange={(event) => {
setEditedLinkUrl(event.target.value)
}}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Enter) {
event.preventDefault()
handleLinkSubmission()
} else if (event.key === KeyboardKey.Escape) {
event.preventDefault()
setEditMode(false)
}
}}
onBlur={(event) => {
if (!editModeContainer.current?.contains(event.relatedTarget as Node)) {
setEditMode(false)
}
}}
className="flex-grow rounded-sm bg-contrast p-1 text-text sm:min-w-[40ch]"
/>
<StyledTooltip showOnMobile showOnHover label="Cancel editing">
<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={() => {
setEditMode(false)
editor.focus()
}}
onMouseDown={(event) => event.preventDefault()}
>
<Icon type="close" size="medium" />
</button>
</StyledTooltip>
<StyledTooltip showOnMobile showOnHover label="Save 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={handleLinkSubmission}
onMouseDown={(event) => event.preventDefault()}
>
<Icon type="check" size="medium" />
</button>
</StyledTooltip>
</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>
{!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>
)
}
export default LinkEditor

View File

@@ -1,124 +0,0 @@
import Icon from '@/Components/Icon/Icon'
import { KeyboardKey } from '@standardnotes/ui-services'
import { $getSelection, $isRangeSelection, $isTextNode, LexicalEditor, RangeSelection, TextNode } from 'lexical'
import { useCallback, useEffect, useRef, useState } from 'react'
import { VisuallyHidden } from '@ariakit/react'
import { getSelectedNode } from '../../Lexical/Utils/getSelectedNode'
import { $isLinkNode } from '@lexical/link'
import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
type Props = {
linkText: string
editor: LexicalEditor
isEditMode: boolean
setEditMode: (isEditMode: boolean) => void
}
export const $isLinkTextNode = (
node: ReturnType<typeof getSelectedNode>,
selection: RangeSelection,
): node is TextNode => {
const parent = node.getParent()
return $isLinkNode(parent) && $isTextNode(node) && selection.anchor.getNode() === selection.focus.getNode()
}
const LinkTextEditor = ({ linkText, editor, isEditMode, setEditMode }: Props) => {
const [editedLinkText, setEditedLinkText] = useState(() => linkText)
const editModeContainer = useRef<HTMLDivElement>(null)
useEffect(() => {
setEditedLinkText(linkText)
}, [linkText])
const focusInput = useCallback((input: HTMLInputElement | null) => {
if (input) {
input.focus()
}
}, [])
const handleLinkTextSubmission = () => {
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 ? (
<div className="flex items-center gap-2" ref={editModeContainer}>
<input
id="link-input"
ref={focusInput}
value={editedLinkText}
onChange={(event) => {
setEditedLinkText(event.target.value)
}}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Enter) {
event.preventDefault()
handleLinkTextSubmission()
} else if (event.key === KeyboardKey.Escape) {
event.preventDefault()
setEditMode(false)
}
}}
onBlur={(event) => {
if (!editModeContainer.current?.contains(event.relatedTarget as Node)) {
setEditMode(false)
}
}}
className="flex-grow rounded-sm bg-contrast p-1 text-text sm:min-w-[20ch]"
/>
<StyledTooltip showOnMobile showOnHover label="Cancel editing">
<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={() => {
setEditMode(false)
editor.focus()
}}
onMouseDown={(event) => event.preventDefault()}
>
<Icon type="close" size="medium" />
</button>
</StyledTooltip>
<StyledTooltip showOnMobile showOnHover label="Save link text">
<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={handleLinkTextSubmission}
onMouseDown={(event) => event.preventDefault()}
>
<Icon type="check" size="medium" />
</button>
</StyledTooltip>
</div>
) : (
<div className="flex items-center gap-1">
<Icon type="plain-text" className="ml-1 mr-1 flex-shrink-0" />
<div className="flex-grow overflow-hidden text-ellipsis whitespace-nowrap">
<VisuallyHidden>Link text:</VisuallyHidden>
{linkText}
</div>
<StyledTooltip showOnMobile showOnHover label="Edit link text">
<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={() => {
setEditedLinkText(linkText)
setEditMode(true)
}}
onMouseDown={(event) => event.preventDefault()}
>
<Icon type="pencil-filled" size="medium" />
</button>
</StyledTooltip>
</div>
)
}
export default LinkTextEditor

View File

@@ -23,6 +23,7 @@ import {
$createParagraphNode,
$isTextNode,
$getNodeByKey,
TextNode,
} from 'lexical'
import {
mergeRegister,
@@ -30,7 +31,7 @@ import {
$getNearestNodeOfType,
$getNearestBlockElementAncestorOrThrow,
} from '@lexical/utils'
import { $isLinkNode, $isAutoLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import { $isLinkNode, TOGGLE_LINK_COMMAND, LinkNode } from '@lexical/link'
import { $isListNode, ListNode } from '@lexical/list'
import { $isHeadingNode, $isQuoteNode } from '@lexical/rich-text'
import {
@@ -61,13 +62,12 @@ import StyledTooltip from '@/Components/StyledTooltip/StyledTooltip'
import { Toolbar, ToolbarItem, useToolbarStore } from '@ariakit/react'
import { PasswordBlock } from '../Blocks/Password'
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, { MenuItemProps } from '@/Components/Menu/MenuItem'
import { debounce, remToPx } from '@/Utils'
import FloatingLinkEditor from './FloatingLinkEditor'
import LinkEditor, { $isLinkTextNode } from './LinkEditor'
import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator'
import { useStateRef } from '@/Hooks/useStateRef'
import { getDOMRangeRect } from '../../Lexical/Utils/getDOMRangeRect'
@@ -75,6 +75,7 @@ import { getPositionedPopoverStyles } from '@/Components/Popover/GetPositionedPo
import usePreference from '@/Hooks/usePreference'
import { ElementIds } from '@/Constants/ElementIDs'
import { $isDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import LinkViewer from './LinkViewer'
const TOGGLE_LINK_AND_EDIT_COMMAND = createCommand<string | null>('TOGGLE_LINK_AND_EDIT_COMMAND')
@@ -220,12 +221,9 @@ const ToolbarPlugin = () => {
const [isCode, setIsCode] = useState(false)
const [isHighlight, setIsHighlight] = useState(false)
const [isLink, setIsLink] = useState(false)
const [isAutoLink, setIsAutoLink] = useState(false)
const [isLinkText, setIsLinkText] = useState(false)
const [isLinkEditMode, setIsLinkEditMode] = useState(false)
const [linkText, setLinkText] = useState<string>('')
const [linkUrl, setLinkUrl] = useState<string>('')
const [linkNode, setLinkNode] = useState<LinkNode | null>(null)
const [linkTextNode, setLinkTextNode] = useState<TextNode | null>(null)
const [isEditingLink, setIsEditingLink] = useState(false)
const [isTOCOpen, setIsTOCOpen] = useState(false)
const tocAnchorRef = useRef<HTMLButtonElement>(null)
@@ -342,23 +340,18 @@ const ToolbarPlugin = () => {
// Update links
const node = getSelectedNode(selection)
const parent = node.getParent()
if ($isLinkNode(parent) || $isLinkNode(node)) {
setIsLink(true)
setIsEditingLink(false)
if ($isLinkNode(node)) {
setLinkNode(node)
} else if ($isLinkNode(parent)) {
setLinkNode(parent)
} else {
setIsLink(false)
}
setLinkUrl($isLinkNode(parent) ? parent.getURL() : $isLinkNode(node) ? node.getURL() : '')
if ($isAutoLinkNode(parent) || $isAutoLinkNode(node)) {
setIsAutoLink(true)
} else {
setIsAutoLink(false)
setLinkNode(null)
}
if ($isLinkTextNode(node, selection)) {
setIsLinkText(true)
setLinkText(node.getTextContent())
setLinkTextNode(node)
} else {
setIsLinkText(false)
setLinkText('')
setLinkTextNode(null)
}
if (elementDOM !== null) {
@@ -505,13 +498,11 @@ const ToolbarPlugin = () => {
TOGGLE_LINK_AND_EDIT_COMMAND,
(payload) => {
if (payload === null) {
setIsEditingLink(false)
return activeEditor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
} else if (typeof payload === 'string') {
const dispatched = activeEditor.dispatchCommand(TOGGLE_LINK_COMMAND, payload)
setIsLink(true)
setLinkUrl(payload)
setIsLinkEditMode(true)
return dispatched
} else {
setIsEditingLink(true)
return true
}
return false
},
@@ -542,11 +533,9 @@ const ToolbarPlugin = () => {
.catch((error) => {
console.error(error)
activeEditor.dispatchCommand(TOGGLE_LINK_AND_EDIT_COMMAND, '')
setIsLinkEditMode(true)
})
} else {
activeEditor.dispatchCommand(TOGGLE_LINK_AND_EDIT_COMMAND, '')
setIsLinkEditMode(true)
}
return true
}
@@ -555,7 +544,7 @@ const ToolbarPlugin = () => {
},
COMMAND_PRIORITY_NORMAL,
)
}, [activeEditor, isLink])
}, [activeEditor])
const dismissButtonRef = useRef<HTMLButtonElement>(null)
@@ -580,7 +569,7 @@ const ToolbarPlugin = () => {
const elementToBeFocused = event.relatedTarget as Node
const containerContainsElementToFocus = container?.contains(elementToBeFocused)
const linkEditorContainsElementToFocus = document
.getElementById('super-link-editor')
.getElementById(ElementIds.SuperEditor)
?.contains(elementToBeFocused)
const willFocusDismissButton = dismissButtonRef.current === elementToBeFocused
if ((containerContainsElementToFocus || linkEditorContainsElementToFocus) && !willFocusDismissButton) {
@@ -664,16 +653,22 @@ const ToolbarPlugin = () => {
id="super-mobile-toolbar"
ref={containerRef}
>
{isLink && (
<FloatingLinkEditor
linkUrl={linkUrl}
linkText={linkText}
isEditMode={isLinkEditMode}
setEditMode={setIsLinkEditMode}
editor={editor}
isAutoLink={isAutoLink}
isLinkText={isLinkText}
{linkNode && !isEditingLink && (
<LinkViewer
key={linkNode.__key}
linkNode={linkNode}
isMobile={isMobile}
setIsEditingLink={setIsEditingLink}
editor={activeEditor}
/>
)}
{isEditingLink && (
<LinkEditor
editor={activeEditor}
setIsEditingLink={setIsEditingLink}
isMobile={isMobile}
linkNode={linkNode}
linkTextNode={linkTextNode}
/>
)}
<div className="flex w-full flex-shrink-0 border-t border-border md:border-0">
@@ -742,7 +737,7 @@ const ToolbarPlugin = () => {
<ToolbarButton
name="Link"
iconName="link"
active={isLink}
active={!!linkNode}
onSelect={() => {
editor.dispatchCommand(TOGGLE_LINK_AND_EDIT_COMMAND, '')
}}

View File

@@ -7682,6 +7682,7 @@ __metadata:
"@babel/preset-env": "*"
"@babel/preset-typescript": ^7.21.5
"@lexical/headless": 0.13.1
"@lexical/link": 0.13.1
"@lexical/list": 0.13.1
"@lexical/react": 0.13.1
"@lexical/rich-text": 0.13.1