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,204 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
||||
import {
|
||||
$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 { Calendar } from "@workspace/ui/components/calendar"
|
||||
import { useLocale } from "@workspace/ui/components/i18n"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTitle,
|
||||
} from "@workspace/ui/components/popover"
|
||||
|
||||
import { formatDate, parseISODate, toISODate } from "../date-value"
|
||||
import { $insertOrUpdateDate, $isDateNode } from "../nodes/date-node"
|
||||
import { createSelectionAnchor, type SelectionAnchor } from "./selection-anchor"
|
||||
import { useLexicalMessage } from "../i18n"
|
||||
import { lexicalMessages } from "../messages"
|
||||
|
||||
interface ExistingDate {
|
||||
anchor: HTMLElement
|
||||
kind: "existing"
|
||||
key: NodeKey
|
||||
value: string
|
||||
}
|
||||
|
||||
interface PendingDate {
|
||||
anchor: HTMLElement | SelectionAnchor
|
||||
kind: "selection"
|
||||
selection: RangeSelection
|
||||
value: string
|
||||
}
|
||||
|
||||
type ActiveDate = ExistingDate | PendingDate
|
||||
|
||||
export const OPEN_DATE_POPOVER_COMMAND = createCommand<void>(
|
||||
"OPEN_DATE_POPOVER_COMMAND"
|
||||
)
|
||||
|
||||
export function LexicalDatePopoverPlugin() {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const locale = useLocale()
|
||||
const [activeDate, setActiveDate] = useState<ActiveDate | null>(null)
|
||||
const insertDateLabel = useLexicalMessage(lexicalMessages.insertDate)
|
||||
const editDateLabel = useLexicalMessage(lexicalMessages.editDate)
|
||||
const deleteDateLabel = useLexicalMessage(lexicalMessages.deleteDate)
|
||||
|
||||
useEffect(() => {
|
||||
const rootElement = editor.getRootElement()
|
||||
|
||||
const unregisterOpenCommand = editor.registerCommand(
|
||||
OPEN_DATE_POPOVER_COMMAND,
|
||||
() => {
|
||||
if (!rootElement) return false
|
||||
|
||||
const pendingDate = editor.getEditorState().read(() => {
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection)) return null
|
||||
|
||||
return {
|
||||
anchor: createSelectionAnchor(rootElement),
|
||||
kind: "selection" as const,
|
||||
selection: selection.clone(),
|
||||
value: toISODate(new Date()),
|
||||
}
|
||||
})
|
||||
|
||||
if (!pendingDate) return false
|
||||
setActiveDate(pendingDate)
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR
|
||||
)
|
||||
|
||||
if (!rootElement) return unregisterOpenCommand
|
||||
|
||||
const handleEditorClick = (event: MouseEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
const dateElement = target?.closest<HTMLElement>("[data-lexical-date]")
|
||||
|
||||
if (!dateElement || !rootElement.contains(dateElement)) {
|
||||
setActiveDate(null)
|
||||
return
|
||||
}
|
||||
|
||||
const editorState = editor.getEditorState()
|
||||
const date = editorState.read(
|
||||
() => {
|
||||
const node = $getNearestNodeFromDOMNode(dateElement, editorState)
|
||||
return $isDateNode(node)
|
||||
? { key: node.getKey(), value: node.getDate() }
|
||||
: null
|
||||
},
|
||||
{ editor }
|
||||
)
|
||||
|
||||
if (date) {
|
||||
setActiveDate({
|
||||
anchor: dateElement,
|
||||
kind: "existing",
|
||||
...date,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
rootElement.addEventListener("click", handleEditorClick)
|
||||
return () => {
|
||||
unregisterOpenCommand()
|
||||
rootElement.removeEventListener("click", handleEditorClick)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
if (!activeDate) return null
|
||||
|
||||
const selectedDate = parseISODate(activeDate.value)
|
||||
if (!selectedDate) return null
|
||||
|
||||
const updateDate = (date: Date) => {
|
||||
const value = toISODate(date)
|
||||
const text = formatDate(date, locale)
|
||||
|
||||
editor.update(() => {
|
||||
if (activeDate.kind === "existing") {
|
||||
const node = $getNodeByKey(activeDate.key)
|
||||
if ($isDateNode(node)) {
|
||||
node.setDate(value, text).select(0, text.length)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
$setSelection(activeDate.selection.clone())
|
||||
$insertOrUpdateDate(value, text)
|
||||
})
|
||||
setActiveDate(null)
|
||||
editor.focus()
|
||||
}
|
||||
|
||||
const removeDate = () => {
|
||||
if (activeDate.kind !== "existing") return
|
||||
|
||||
editor.update(() => {
|
||||
const node = $getNodeByKey(activeDate.key)
|
||||
if ($isDateNode(node)) node.remove()
|
||||
})
|
||||
setActiveDate(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActiveDate(null)
|
||||
}}
|
||||
>
|
||||
<PopoverContent
|
||||
anchor={activeDate.anchor}
|
||||
align="center"
|
||||
positionMethod="fixed"
|
||||
className="w-auto gap-0 p-0"
|
||||
finalFocus={() => editor.getRootElement()}
|
||||
showArrow
|
||||
>
|
||||
<PopoverTitle className="sr-only">
|
||||
{activeDate.kind === "selection" ? insertDateLabel : editDateLabel}
|
||||
</PopoverTitle>
|
||||
<Calendar
|
||||
mode="single"
|
||||
required
|
||||
selected={selectedDate}
|
||||
defaultMonth={selectedDate}
|
||||
onSelect={updateDate}
|
||||
/>
|
||||
<div className="flex items-center gap-2 border-t px-3 py-2">
|
||||
<time
|
||||
dateTime={activeDate.value}
|
||||
className="min-w-0 flex-1 truncate text-muted-foreground"
|
||||
>
|
||||
{formatDate(selectedDate, locale)}
|
||||
</time>
|
||||
{activeDate.kind === "existing" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={deleteDateLabel}
|
||||
onClick={removeDate}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
||||
import { DraggableBlockPlugin_EXPERIMENTAL } from "@lexical/react/LexicalDraggableBlockPlugin"
|
||||
import { GripVertical } from "lucide-react"
|
||||
import { $getRoot, $isParagraphNode } from "lexical"
|
||||
|
||||
import { useLexicalMessage } from "../i18n"
|
||||
import { lexicalMessages } from "../messages"
|
||||
|
||||
const DRAGGABLE_ANCHOR_ATTRIBUTE = "data-lexical-draggable-anchor"
|
||||
const DRAGGABLE_CONTENT_CLASS_NAME = "lexical-draggable-content"
|
||||
|
||||
function $isEditorEmpty() {
|
||||
const children = $getRoot().getChildren()
|
||||
|
||||
return (
|
||||
children.length === 0 ||
|
||||
(children.length === 1 &&
|
||||
$isParagraphNode(children[0]) &&
|
||||
children[0].isEmpty())
|
||||
)
|
||||
}
|
||||
|
||||
export function LexicalDraggableBlockPlugin() {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [anchorElement, setAnchorElement] = useState<HTMLElement | null>(null)
|
||||
const [isEditorEmpty, setEditorEmpty] = useState(true)
|
||||
const dragBlockLabel = useLexicalMessage(lexicalMessages.dragBlock)
|
||||
const menuRef = useRef<HTMLButtonElement>(null)
|
||||
const targetLineRef = useRef<HTMLDivElement>(null)
|
||||
const isOnMenu = useCallback(
|
||||
(element: HTMLElement) => menuRef.current?.contains(element) ?? false,
|
||||
[]
|
||||
)
|
||||
|
||||
useLayoutEffect(
|
||||
() =>
|
||||
editor.registerRootListener((rootElement, previousRootElement) => {
|
||||
const previousAnchor = previousRootElement?.parentElement
|
||||
const nextAnchor = rootElement?.parentElement ?? null
|
||||
|
||||
if (previousAnchor !== nextAnchor) {
|
||||
previousAnchor?.removeAttribute(DRAGGABLE_ANCHOR_ATTRIBUTE)
|
||||
}
|
||||
previousRootElement?.classList.remove(DRAGGABLE_CONTENT_CLASS_NAME)
|
||||
nextAnchor?.setAttribute(DRAGGABLE_ANCHOR_ATTRIBUTE, "")
|
||||
rootElement?.classList.add(DRAGGABLE_CONTENT_CLASS_NAME)
|
||||
setAnchorElement(nextAnchor)
|
||||
}),
|
||||
[editor]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
anchorElement?.removeAttribute(DRAGGABLE_ANCHOR_ATTRIBUTE)
|
||||
},
|
||||
[anchorElement]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const updateEmptyState = () => {
|
||||
const nextIsEmpty = editor.getEditorState().read($isEditorEmpty)
|
||||
setEditorEmpty((currentIsEmpty) =>
|
||||
currentIsEmpty === nextIsEmpty ? currentIsEmpty : nextIsEmpty
|
||||
)
|
||||
}
|
||||
|
||||
updateEmptyState()
|
||||
return editor.registerUpdateListener(updateEmptyState)
|
||||
}, [editor])
|
||||
|
||||
if (!anchorElement || isEditorEmpty) return null
|
||||
|
||||
return (
|
||||
<DraggableBlockPlugin_EXPERIMENTAL
|
||||
anchorElem={anchorElement}
|
||||
menuRef={menuRef}
|
||||
targetLineRef={targetLineRef}
|
||||
isOnMenu={isOnMenu}
|
||||
menuComponent={
|
||||
<button
|
||||
ref={menuRef}
|
||||
type="button"
|
||||
aria-label={dragBlockLabel}
|
||||
data-slot="lexical-drag-handle"
|
||||
className="absolute top-0 left-0 z-10 hidden size-7 cursor-grab items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
}
|
||||
targetLineComponent={
|
||||
<div
|
||||
ref={targetLineRef}
|
||||
aria-hidden="true"
|
||||
data-slot="lexical-drop-indicator"
|
||||
className="pointer-events-none absolute top-0 left-0 z-20 h-1 rounded-full bg-primary opacity-0"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from "react"
|
||||
import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html"
|
||||
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
|
||||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getRoot,
|
||||
$isDecoratorNode,
|
||||
$isElementNode,
|
||||
} from "lexical"
|
||||
import type { LexicalEditor, LexicalNode, RootNode } from "lexical"
|
||||
|
||||
export function normalizeLexicalHtml(html: string): string {
|
||||
const normalized = html.trim()
|
||||
return normalized === "<p><br></p>" || normalized === "<p></p>"
|
||||
? ""
|
||||
: normalized
|
||||
}
|
||||
|
||||
interface LexicalHtmlPluginProps {
|
||||
value?: string
|
||||
onChange: (html: string) => void
|
||||
}
|
||||
|
||||
function appendImportedNodes(root: RootNode, nodes: LexicalNode[]) {
|
||||
let paragraph: ReturnType<typeof $createParagraphNode> | undefined
|
||||
|
||||
for (const node of nodes) {
|
||||
const isTopLevelNode =
|
||||
($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline()
|
||||
|
||||
if (isTopLevelNode) {
|
||||
paragraph = undefined
|
||||
root.append(node)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!paragraph) {
|
||||
paragraph = $createParagraphNode()
|
||||
root.append(paragraph)
|
||||
}
|
||||
paragraph.append(node)
|
||||
}
|
||||
}
|
||||
|
||||
export function LexicalHtmlPlugin({ value, onChange }: LexicalHtmlPluginProps) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const normalizedValue = normalizeLexicalHtml(value ?? "")
|
||||
const lastHtmlRef = useRef(normalizedValue)
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (initializedRef.current && normalizedValue === lastHtmlRef.current)
|
||||
return
|
||||
initializedRef.current = true
|
||||
lastHtmlRef.current = normalizedValue
|
||||
editor.update(() => {
|
||||
const root = $getRoot()
|
||||
root.clear()
|
||||
if (!normalizedValue) {
|
||||
root.append($createParagraphNode())
|
||||
return
|
||||
}
|
||||
const document = new DOMParser().parseFromString(
|
||||
normalizedValue,
|
||||
"text/html"
|
||||
)
|
||||
appendImportedNodes(root, $generateNodesFromDOM(editor, document))
|
||||
})
|
||||
}, [editor, normalizedValue])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(_editorState: unknown, currentEditor: LexicalEditor) => {
|
||||
currentEditor.getEditorState().read(() => {
|
||||
const html = normalizeLexicalHtml(
|
||||
$generateHtmlFromNodes(currentEditor, null)
|
||||
)
|
||||
if (html === lastHtmlRef.current) return
|
||||
lastHtmlRef.current = html
|
||||
onChange(html)
|
||||
})
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
return <OnChangePlugin onChange={handleChange} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import * as React from "react"
|
||||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
||||
import {
|
||||
$getSelection,
|
||||
$insertNodes,
|
||||
$isRangeSelection,
|
||||
$setSelection,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
createCommand,
|
||||
} from "lexical"
|
||||
import type { RangeSelection } from "lexical"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@workspace/ui/components/dialog"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@workspace/ui/components/field"
|
||||
import { Input } from "@workspace/ui/components/input"
|
||||
|
||||
import { useLexicalMessage } from "../i18n"
|
||||
import { lexicalMessages } from "../messages"
|
||||
import {
|
||||
$createLexicalMediaNode,
|
||||
type LexicalMediaKind,
|
||||
type LexicalMediaPayload,
|
||||
} from "../nodes/media-node"
|
||||
|
||||
interface PendingMedia {
|
||||
kind: LexicalMediaKind
|
||||
selection: RangeSelection
|
||||
}
|
||||
|
||||
export const OPEN_MEDIA_DIALOG_COMMAND = createCommand<LexicalMediaKind>(
|
||||
"OPEN_MEDIA_DIALOG_COMMAND"
|
||||
)
|
||||
|
||||
export function LexicalMediaDialogPlugin() {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [pendingMedia, setPendingMedia] = React.useState<PendingMedia | null>(
|
||||
null
|
||||
)
|
||||
const [src, setSrc] = React.useState("")
|
||||
const [alt, setAlt] = React.useState("")
|
||||
const [caption, setCaption] = React.useState("")
|
||||
const [poster, setPoster] = React.useState("")
|
||||
const imageDescription = useLexicalMessage(lexicalMessages.imageDescription)
|
||||
const videoDescription = useLexicalMessage(lexicalMessages.videoDescription)
|
||||
const imageUrlLabel = useLexicalMessage(lexicalMessages.imageUrl)
|
||||
const videoUrlLabel = useLexicalMessage(lexicalMessages.videoUrl)
|
||||
const urlDescription = useLexicalMessage(lexicalMessages.urlDescription)
|
||||
const imageAltLabel = useLexicalMessage(lexicalMessages.imageAlt)
|
||||
const imageAltDescription = useLexicalMessage(
|
||||
lexicalMessages.imageAltDescription
|
||||
)
|
||||
const videoPosterLabel = useLexicalMessage(lexicalMessages.videoPoster)
|
||||
const captionLabel = useLexicalMessage(lexicalMessages.caption)
|
||||
const cancelLabel = useLexicalMessage(lexicalMessages.cancel)
|
||||
const optionalLabel = useLexicalMessage(lexicalMessages.optional)
|
||||
|
||||
React.useEffect(
|
||||
() =>
|
||||
editor.registerCommand(
|
||||
OPEN_MEDIA_DIALOG_COMMAND,
|
||||
(kind) => {
|
||||
const selection = editor.getEditorState().read(() => {
|
||||
const currentSelection = $getSelection()
|
||||
return $isRangeSelection(currentSelection)
|
||||
? currentSelection.clone()
|
||||
: null
|
||||
})
|
||||
|
||||
if (!selection) return false
|
||||
|
||||
setSrc("")
|
||||
setAlt("")
|
||||
setCaption("")
|
||||
setPoster("")
|
||||
setPendingMedia({ kind, selection })
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR
|
||||
),
|
||||
[editor]
|
||||
)
|
||||
|
||||
const closeDialog = () => {
|
||||
setPendingMedia(null)
|
||||
editor.focus()
|
||||
}
|
||||
|
||||
const insertMedia = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (!pendingMedia) return
|
||||
|
||||
const normalizedSrc = src.trim()
|
||||
if (!normalizedSrc) return
|
||||
|
||||
const payload: LexicalMediaPayload = {
|
||||
alt: pendingMedia.kind === "image" ? alt.trim() || undefined : undefined,
|
||||
caption: caption.trim() || undefined,
|
||||
kind: pendingMedia.kind,
|
||||
poster:
|
||||
pendingMedia.kind === "video" ? poster.trim() || undefined : undefined,
|
||||
src: normalizedSrc,
|
||||
}
|
||||
|
||||
editor.update(() => {
|
||||
$setSelection(pendingMedia.selection.clone())
|
||||
$insertNodes([$createLexicalMediaNode(payload)])
|
||||
})
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
const isImage = pendingMedia?.kind === "image"
|
||||
const title = useLexicalMessage(
|
||||
isImage ? lexicalMessages.insertImage : lexicalMessages.insertVideo
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={pendingMedia !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && pendingMedia) closeDialog()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
finalFocus={() => editor.getRootElement()}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isImage ? imageDescription : videoDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="contents" onSubmit={insertMedia}>
|
||||
<FieldGroup className="gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lexical-media-src">
|
||||
{isImage ? imageUrlLabel : videoUrlLabel}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoFocus
|
||||
id="lexical-media-src"
|
||||
inputMode="url"
|
||||
placeholder={
|
||||
isImage
|
||||
? "https://example.com/image.jpg"
|
||||
: "https://example.com/video.mp4"
|
||||
}
|
||||
required
|
||||
value={src}
|
||||
onChange={(event) => setSrc(event.target.value)}
|
||||
/>
|
||||
<FieldDescription>{urlDescription}</FieldDescription>
|
||||
</Field>
|
||||
{isImage ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lexical-media-alt">
|
||||
{imageAltLabel}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="lexical-media-alt"
|
||||
placeholder={imageAltLabel}
|
||||
value={alt}
|
||||
onChange={(event) => setAlt(event.target.value)}
|
||||
/>
|
||||
<FieldDescription>{imageAltDescription}</FieldDescription>
|
||||
</Field>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lexical-media-poster">
|
||||
{videoPosterLabel}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="lexical-media-poster"
|
||||
inputMode="url"
|
||||
placeholder="https://example.com/poster.jpg"
|
||||
value={poster}
|
||||
onChange={(event) => setPoster(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lexical-media-caption">
|
||||
{captionLabel}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="lexical-media-caption"
|
||||
placeholder={optionalLabel}
|
||||
value={caption}
|
||||
onChange={(event) => setCaption(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button type="submit">{title}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { createSelectionAnchor } from "./selection-anchor"
|
||||
|
||||
describe("createSelectionAnchor", () => {
|
||||
it("does not use a browser selection outside the editor", () => {
|
||||
const rootElement = document.createElement("div")
|
||||
const externalElement = document.createElement("p")
|
||||
const externalText = document.createTextNode("outside")
|
||||
|
||||
externalElement.append(externalText)
|
||||
document.body.append(rootElement, externalElement)
|
||||
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(externalText)
|
||||
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
|
||||
expect(createSelectionAnchor(rootElement)).toBe(rootElement)
|
||||
|
||||
selection?.removeAllRanges()
|
||||
rootElement.remove()
|
||||
externalElement.remove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { LexicalEditor } from "lexical"
|
||||
|
||||
export interface SelectionAnchor {
|
||||
contextElement: HTMLElement
|
||||
getBoundingClientRect: () => DOMRect
|
||||
}
|
||||
|
||||
export function runWithEditorFocus(
|
||||
editor: LexicalEditor,
|
||||
callback: VoidFunction
|
||||
) {
|
||||
const rootElement = editor.getRootElement()
|
||||
if (!rootElement) return
|
||||
|
||||
const document = rootElement.ownerDocument
|
||||
const selection = document.defaultView?.getSelection()
|
||||
const selectionIsInside =
|
||||
selection?.focusNode != null && rootElement.contains(selection.focusNode)
|
||||
const focusIsInside =
|
||||
document.activeElement != null &&
|
||||
rootElement.contains(document.activeElement)
|
||||
|
||||
if (selectionIsInside && focusIsInside) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
|
||||
editor.focus(callback)
|
||||
}
|
||||
|
||||
export function createSelectionAnchor(
|
||||
rootElement: HTMLElement
|
||||
): HTMLElement | SelectionAnchor {
|
||||
const document = rootElement.ownerDocument
|
||||
const domSelection = document.defaultView?.getSelection()
|
||||
const focusNode = domSelection?.focusNode
|
||||
let range: Range | null = null
|
||||
|
||||
if (focusNode && rootElement.contains(focusNode)) {
|
||||
try {
|
||||
range = document.createRange()
|
||||
range.setStart(focusNode, domSelection.focusOffset)
|
||||
range.collapse(true)
|
||||
} catch {
|
||||
range = null
|
||||
}
|
||||
}
|
||||
|
||||
if (!range) return rootElement
|
||||
const selectionRange = range
|
||||
|
||||
return {
|
||||
contextElement: rootElement,
|
||||
getBoundingClientRect: () => {
|
||||
const rangeRect =
|
||||
typeof selectionRange.getBoundingClientRect === "function"
|
||||
? selectionRange.getBoundingClientRect()
|
||||
: null
|
||||
|
||||
if (
|
||||
rangeRect &&
|
||||
(rangeRect.width > 0 ||
|
||||
rangeRect.height > 0 ||
|
||||
rangeRect.x !== 0 ||
|
||||
rangeRect.y !== 0)
|
||||
) {
|
||||
return rangeRect
|
||||
}
|
||||
|
||||
const clientRects =
|
||||
typeof selectionRange.getClientRects === "function"
|
||||
? Array.from(selectionRange.getClientRects())
|
||||
: []
|
||||
|
||||
return clientRects.at(-1) ?? rootElement.getBoundingClientRect()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user