344 lines
9.6 KiB
TypeScript
344 lines
9.6 KiB
TypeScript
|
|
import { useEffect, useId, useRef, useState } from "react"
|
||
|
|
import { $isLinkNode, $toggleLink } from "@lexical/link"
|
||
|
|
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
||
|
|
import { Check, Pencil, Trash2 } from "lucide-react"
|
||
|
|
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<void>(
|
||
|
|
"OPEN_LINK_POPOVER_COMMAND"
|
||
|
|
)
|
||
|
|
|
||
|
|
export function LexicalLinkPopoverPlugin() {
|
||
|
|
const [editor] = useLexicalComposerContext()
|
||
|
|
const [activeLink, setActiveLink] = useState<ActiveLink | null>(null)
|
||
|
|
const [draftUrl, setDraftUrl] = useState("")
|
||
|
|
const [isEditing, setEditing] = useState(false)
|
||
|
|
const inputId = useId()
|
||
|
|
const inputRef = useRef<HTMLTextAreaElement>(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 (
|
||
|
|
<Popover
|
||
|
|
open
|
||
|
|
onOpenChange={(open) => {
|
||
|
|
if (!open) closePopover()
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<PopoverContent
|
||
|
|
anchor={activeLink.anchor}
|
||
|
|
align="center"
|
||
|
|
positionMethod="fixed"
|
||
|
|
className="w-80 gap-1.5 p-2"
|
||
|
|
finalFocus={() => editor.getRootElement()}
|
||
|
|
initialFocus={showEditor ? inputRef : undefined}
|
||
|
|
showArrow
|
||
|
|
>
|
||
|
|
<PopoverTitle className="sr-only">
|
||
|
|
{activeLink.kind === "selection" ? insertLinkLabel : linkDetailsLabel}
|
||
|
|
</PopoverTitle>
|
||
|
|
{showEditor ? (
|
||
|
|
<form
|
||
|
|
className="min-w-0"
|
||
|
|
onSubmit={(event) => {
|
||
|
|
event.preventDefault()
|
||
|
|
saveLink()
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div className="mb-1.5 flex items-center gap-1 ps-1">
|
||
|
|
<label
|
||
|
|
htmlFor={inputId}
|
||
|
|
className="text-xs font-medium text-muted-foreground"
|
||
|
|
>
|
||
|
|
{linkAddressLabel}
|
||
|
|
</label>
|
||
|
|
<div className="-my-1 ms-auto flex items-center gap-1">
|
||
|
|
<InputGroupButton
|
||
|
|
type="submit"
|
||
|
|
size="icon-xs"
|
||
|
|
aria-label={applyLinkLabel}
|
||
|
|
>
|
||
|
|
<Check className="size-3" />
|
||
|
|
</InputGroupButton>
|
||
|
|
{activeLink.kind === "existing" && (
|
||
|
|
<InputGroupButton
|
||
|
|
size="icon-xs"
|
||
|
|
aria-label={removeLinkLabel}
|
||
|
|
onClick={removeLink}
|
||
|
|
>
|
||
|
|
<Trash2 className="size-3" />
|
||
|
|
</InputGroupButton>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<InputGroup>
|
||
|
|
<InputGroupTextarea
|
||
|
|
id={inputId}
|
||
|
|
ref={inputRef}
|
||
|
|
aria-label={linkAddressLabel}
|
||
|
|
className="min-h-16 resize-y break-all"
|
||
|
|
inputMode="url"
|
||
|
|
placeholder="https://example.com"
|
||
|
|
rows={2}
|
||
|
|
value={draftUrl}
|
||
|
|
onChange={(event) => 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)
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</InputGroup>
|
||
|
|
</form>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<div className="flex items-center gap-1 ps-1">
|
||
|
|
<span className="text-xs font-medium text-muted-foreground">
|
||
|
|
{linkAddressLabel}
|
||
|
|
</span>
|
||
|
|
<div className="-my-1 ms-auto flex items-center gap-1">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="icon-xs"
|
||
|
|
aria-label={editLinkLabel}
|
||
|
|
onClick={startEditing}
|
||
|
|
>
|
||
|
|
<Pencil />
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="icon-xs"
|
||
|
|
aria-label={removeLinkLabel}
|
||
|
|
onClick={removeLink}
|
||
|
|
>
|
||
|
|
<Trash2 />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<a
|
||
|
|
href={activeLink.url}
|
||
|
|
target="_blank"
|
||
|
|
rel="noreferrer"
|
||
|
|
className="block max-h-24 overflow-y-auto px-1 break-all text-primary underline underline-offset-3"
|
||
|
|
>
|
||
|
|
{activeLink.url}
|
||
|
|
</a>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
)
|
||
|
|
}
|