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:
Maofeng
2026-07-31 15:07:09 +08:00
parent 1fe9c1021a
commit 0d817537be
65 changed files with 7453 additions and 0 deletions
@@ -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()
},
}
}