import { useEffect, useId, useRef, useState } from "react" import { $isLinkNode, $toggleLink } from "@lexical/link" import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext" import { Check, Pencil, Trash2 } from "@workspace/ui/components/icon" import { $findMatchingParent, $getNearestNodeFromDOMNode, $getNodeByKey, $getSelection, $isRangeSelection, $setSelection, COMMAND_PRIORITY_EDITOR, createCommand, } from "lexical" import type { NodeKey, RangeSelection } from "lexical" import { Button } from "@workspace/ui/components/button" import { InputGroup, InputGroupButton, InputGroupTextarea, } from "@workspace/ui/components/input-group" import { Popover, PopoverContent, PopoverTitle, } from "@workspace/ui/components/popover" import { useLexicalMessage } from "../i18n" import { lexicalMessages } from "../messages" import { createSelectionAnchor, type SelectionAnchor } from "./selection-anchor" interface SelectedTextLink { anchor: HTMLElement | SelectionAnchor kind: "selection" selection: RangeSelection url: string } interface ExistingLink { anchor: HTMLAnchorElement key: NodeKey kind: "existing" url: string } type ActiveLink = ExistingLink | SelectedTextLink export const OPEN_LINK_POPOVER_COMMAND = createCommand( "OPEN_LINK_POPOVER_COMMAND" ) export function LexicalLinkPopoverPlugin() { const [editor] = useLexicalComposerContext() const [activeLink, setActiveLink] = useState(null) const [draftUrl, setDraftUrl] = useState("") const [isEditing, setEditing] = useState(false) const inputId = useId() const inputRef = useRef(null) const insertLinkLabel = useLexicalMessage(lexicalMessages.insertLink) const linkDetailsLabel = useLexicalMessage(lexicalMessages.linkDetails) const linkAddressLabel = useLexicalMessage(lexicalMessages.linkAddress) const applyLinkLabel = useLexicalMessage(lexicalMessages.applyLink) const removeLinkLabel = useLexicalMessage(lexicalMessages.removeLink) const editLinkLabel = useLexicalMessage(lexicalMessages.editLink) useEffect(() => { const rootElement = editor.getRootElement() const unregisterOpenCommand = editor.registerCommand( OPEN_LINK_POPOVER_COMMAND, () => { if (!rootElement) return false const selectionLink = editor.getEditorState().read(() => { const selection = $getSelection() if (!$isRangeSelection(selection)) return null const linkNode = $findMatchingParent( selection.anchor.getNode(), $isLinkNode ) return { anchor: createSelectionAnchor(rootElement), kind: "selection" as const, selection: selection.clone(), url: linkNode?.getURL() ?? "https://", } }) if (!selectionLink) return false setActiveLink(selectionLink) setDraftUrl(selectionLink.url) setEditing(true) return true }, COMMAND_PRIORITY_EDITOR ) if (!rootElement) return unregisterOpenCommand const handleEditorClick = (event: MouseEvent) => { const target = event.target instanceof Element ? event.target : null const anchor = target?.closest("a") if ( !(anchor instanceof HTMLAnchorElement) || !rootElement.contains(anchor) ) { setActiveLink(null) setEditing(false) return } event.preventDefault() const editorState = editor.getEditorState() const link = editorState.read( () => { const node = $getNearestNodeFromDOMNode(anchor, editorState) return $isLinkNode(node) ? { key: node.getKey(), url: node.getURL() } : null }, { editor } ) if (!link) return setActiveLink({ anchor, key: link.key, kind: "existing", url: link.url, }) setDraftUrl(link.url) setEditing(false) } rootElement.addEventListener("click", handleEditorClick) return () => { unregisterOpenCommand() rootElement.removeEventListener("click", handleEditorClick) } }, [editor]) useEffect(() => { if (isEditing) inputRef.current?.focus() }, [isEditing]) if (!activeLink) return null const closePopover = () => { setActiveLink(null) setEditing(false) } const startEditing = () => { setDraftUrl(activeLink.url) setEditing(true) } const saveLink = () => { const normalizedUrl = draftUrl.trim().replace(/[\r\n]+/g, "") if (activeLink.kind === "selection") { if (normalizedUrl) { editor.update(() => { $setSelection(activeLink.selection.clone()) $toggleLink(normalizedUrl) }) } closePopover() editor.focus() return } editor.update(() => { const node = $getNodeByKey(activeLink.key) if (!$isLinkNode(node)) return if (normalizedUrl) { node.setURL(normalizedUrl) } else { const children = node.getChildren() children.forEach((child) => node.insertBefore(child)) node.remove() } }) if (normalizedUrl) { setActiveLink((current) => current ? { ...current, url: normalizedUrl } : null ) setEditing(false) } else { closePopover() } } const removeLink = () => { if (activeLink.kind !== "existing") return editor.update(() => { const node = $getNodeByKey(activeLink.key) if (!$isLinkNode(node)) return const children = node.getChildren() children.forEach((child) => node.insertBefore(child)) node.remove() }) closePopover() } const showEditor = activeLink.kind === "selection" || isEditing return ( { if (!open) closePopover() }} > editor.getRootElement()} initialFocus={showEditor ? inputRef : undefined} showArrow > {activeLink.kind === "selection" ? insertLinkLabel : linkDetailsLabel} {showEditor ? (
{ event.preventDefault() saveLink() }} >
{activeLink.kind === "existing" && ( )}
setDraftUrl(event.target.value)} onKeyDown={(event) => { if ( event.key === "Enter" && (event.metaKey || event.ctrlKey) ) { event.preventDefault() saveLink() return } if ( event.key === "Escape" && activeLink.kind === "existing" ) { event.preventDefault() setEditing(false) } }} />
) : ( <>
{linkAddressLabel}
{activeLink.url} )}
) }