Files
simple-react-app-kit/packages/lexical/src/plugins/link-popover-plugin.tsx
T

344 lines
9.6 KiB
TypeScript
Raw Normal View History

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<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>
)
}