feat(lexical): add declarative rich text editor
Introduce @workspace/lexical with composable actions, toolbars, embeds, rich-text nodes, media uploads, selection utilities, HTML serialization, and theme styling.\n\nLocalize built-in controls through MessageDescriptor catalogs for English and Simplified Chinese, while providing an English I18nProvider fallback for standalone editor use. Include focused unit coverage for actions, controls, serialization, plugins, public API, and catalogs.
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user