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,164 @@
|
||||
/* eslint-disable no-underscore-dangle -- Lexical nodes use protected __ fields by convention. */
|
||||
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
TextNode,
|
||||
} from "lexical"
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedTextNode,
|
||||
Spread,
|
||||
} from "lexical"
|
||||
|
||||
import { isISODate } from "../date-value"
|
||||
|
||||
export type SerializedDateNode = Spread<
|
||||
{
|
||||
date: string
|
||||
},
|
||||
SerializedTextNode
|
||||
>
|
||||
|
||||
export class DateNode extends TextNode {
|
||||
__date: string
|
||||
|
||||
static getType(): string {
|
||||
return "date"
|
||||
}
|
||||
|
||||
static clone(node: DateNode): DateNode {
|
||||
return new DateNode(node.__date, node.__text, node.__key)
|
||||
}
|
||||
|
||||
static importJSON(node: SerializedDateNode): DateNode {
|
||||
return $createDateNode(node.date, node.text).updateFromJSON(node)
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
time: (element) => {
|
||||
const date =
|
||||
element.getAttribute("datetime") || element.dataset.lexicalDate
|
||||
if (!date || !isISODate(date)) return null
|
||||
|
||||
return {
|
||||
conversion: () => ({
|
||||
node: $createDateNode(date, element.textContent || date),
|
||||
forChild: () => null,
|
||||
}),
|
||||
priority: 4,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
constructor(date: string, text?: string, key?: NodeKey) {
|
||||
super(text ?? date, key)
|
||||
this.__date = date
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement {
|
||||
const element = super.createDOM(config, editor)
|
||||
element.dataset.lexicalDate = this.__date
|
||||
return element
|
||||
}
|
||||
|
||||
updateDOM(
|
||||
previousNode: this,
|
||||
element: HTMLElement,
|
||||
config: EditorConfig
|
||||
): boolean {
|
||||
const didUpdate = super.updateDOM(previousNode, element, config)
|
||||
if (previousNode.__date !== this.__date) {
|
||||
element.dataset.lexicalDate = this.__date
|
||||
}
|
||||
return didUpdate
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement("time")
|
||||
element.dateTime = this.__date
|
||||
element.dataset.lexicalDate = this.__date
|
||||
element.textContent = this.getTextContent()
|
||||
return { element }
|
||||
}
|
||||
|
||||
exportJSON(): SerializedDateNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
date: this.__date,
|
||||
type: "date",
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
getDate(): string {
|
||||
return this.getLatest().__date
|
||||
}
|
||||
|
||||
setDate(date: string, text: string): this {
|
||||
const writable = this.getWritable()
|
||||
writable.__date = date
|
||||
writable.__text = text
|
||||
return writable
|
||||
}
|
||||
|
||||
isTextEntity(): true {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function $createDateNode(date: string, text?: string): DateNode {
|
||||
return $applyNodeReplacement(new DateNode(date, text))
|
||||
.setMode("token")
|
||||
.setDetail("unmergable")
|
||||
}
|
||||
|
||||
export function $isDateNode(
|
||||
node: LexicalNode | null | undefined
|
||||
): node is DateNode {
|
||||
return node instanceof DateNode
|
||||
}
|
||||
|
||||
export function $getSelectedDateNode(): DateNode | null {
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection)) return null
|
||||
|
||||
const anchorNode = selection.anchor.getNode()
|
||||
const focusNode = selection.focus.getNode()
|
||||
if (anchorNode === focusNode && $isDateNode(anchorNode)) return anchorNode
|
||||
|
||||
const selectedNodes = selection.getNodes()
|
||||
return selectedNodes.length === 1 && $isDateNode(selectedNodes[0])
|
||||
? selectedNodes[0]
|
||||
: null
|
||||
}
|
||||
|
||||
export function $insertOrUpdateDate(
|
||||
date: string,
|
||||
text: string
|
||||
): DateNode | null {
|
||||
const selectedDateNode = $getSelectedDateNode()
|
||||
|
||||
if (selectedDateNode) {
|
||||
selectedDateNode.setDate(date, text).select(0, text.length)
|
||||
return selectedDateNode
|
||||
}
|
||||
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection)) return null
|
||||
|
||||
const dateNode = $createDateNode(date, text)
|
||||
selection.insertNodes([dateNode])
|
||||
dateNode.select(0, text.length)
|
||||
return dateNode
|
||||
}
|
||||
|
||||
export { isISODate } from "../date-value"
|
||||
@@ -0,0 +1,598 @@
|
||||
/* eslint-disable no-underscore-dangle -- Lexical nodes use protected __ fields by convention. */
|
||||
|
||||
import * as React from "react"
|
||||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
||||
import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection"
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
$getNodeByKey,
|
||||
CLICK_COMMAND,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
DecoratorNode,
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
KEY_DELETE_COMMAND,
|
||||
mergeRegister,
|
||||
} from "lexical"
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMExportOutput,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedLexicalNode,
|
||||
Spread,
|
||||
} from "lexical"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Trash2 } from "lucide-react"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
|
||||
import { ImageResizer } from "../components/image-resizer"
|
||||
import { useLexicalMessage } from "../i18n"
|
||||
import { getImageUploadState, useImageUploadState } from "../image-upload-store"
|
||||
import { lexicalMessages } from "../messages"
|
||||
|
||||
export type LexicalMediaKind = "image" | "video"
|
||||
export type LexicalMediaAlignment = "center" | "end" | "start"
|
||||
|
||||
export interface LexicalMediaPayload {
|
||||
alignment?: LexicalMediaAlignment
|
||||
alt?: string
|
||||
caption?: string
|
||||
height?: number
|
||||
kind: LexicalMediaKind
|
||||
poster?: string
|
||||
src: string
|
||||
width?: number
|
||||
}
|
||||
|
||||
export type SerializedLexicalMediaNode = Spread<
|
||||
LexicalMediaPayload,
|
||||
SerializedLexicalNode
|
||||
>
|
||||
|
||||
export class LexicalMediaNode extends DecoratorNode<React.ReactNode> {
|
||||
__alignment: LexicalMediaAlignment
|
||||
__alt: string
|
||||
__caption: string
|
||||
__height: number
|
||||
__kind: LexicalMediaKind
|
||||
__poster: string
|
||||
__src: string
|
||||
__width: number
|
||||
|
||||
static getType(): string {
|
||||
return "lexical-media"
|
||||
}
|
||||
|
||||
static clone(node: LexicalMediaNode): LexicalMediaNode {
|
||||
return new LexicalMediaNode(
|
||||
{
|
||||
alignment: node.__alignment,
|
||||
alt: node.__alt || undefined,
|
||||
caption: node.__caption || undefined,
|
||||
height: node.__height || undefined,
|
||||
kind: node.__kind,
|
||||
poster: node.__poster || undefined,
|
||||
src: node.__src,
|
||||
width: node.__width || undefined,
|
||||
},
|
||||
node.__key
|
||||
)
|
||||
}
|
||||
|
||||
static importJSON(
|
||||
node: SerializedLexicalNode & Record<string, unknown>
|
||||
): LexicalMediaNode {
|
||||
return $createLexicalMediaNode({
|
||||
alignment: normalizeAlignment(node.alignment),
|
||||
alt: typeof node.alt === "string" ? node.alt : undefined,
|
||||
caption: typeof node.caption === "string" ? node.caption : undefined,
|
||||
height: normalizeDimension(node.height),
|
||||
kind: node.kind === "video" ? "video" : "image",
|
||||
poster: typeof node.poster === "string" ? node.poster : undefined,
|
||||
src: typeof node.src === "string" ? node.src : "",
|
||||
width: normalizeDimension(node.width),
|
||||
})
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
figure: (element) => {
|
||||
const kind = element.dataset.lexicalMediaKind
|
||||
if (kind !== "image" && kind !== "video") return null
|
||||
|
||||
const media = element.querySelector(kind === "image" ? "img" : "video")
|
||||
const src = media?.getAttribute("src")?.trim()
|
||||
if (!media || !src) return null
|
||||
|
||||
return {
|
||||
conversion: () => ({
|
||||
node: $createLexicalMediaNode({
|
||||
alignment: normalizeAlignment(
|
||||
element.dataset.lexicalMediaAlignment || element.style.textAlign
|
||||
),
|
||||
alt:
|
||||
media instanceof HTMLImageElement
|
||||
? media.getAttribute("alt") || undefined
|
||||
: undefined,
|
||||
caption:
|
||||
element.querySelector("figcaption")?.textContent?.trim() ||
|
||||
undefined,
|
||||
height: normalizeDimension(media.getAttribute("height")),
|
||||
kind,
|
||||
poster:
|
||||
media instanceof HTMLVideoElement
|
||||
? media.getAttribute("poster") || undefined
|
||||
: undefined,
|
||||
src,
|
||||
width: normalizeDimension(media.getAttribute("width")),
|
||||
}),
|
||||
forChild: () => null,
|
||||
}),
|
||||
priority: 4,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
constructor(payload: LexicalMediaPayload, key?: NodeKey) {
|
||||
super(key)
|
||||
this.__alignment = normalizeAlignment(payload.alignment)
|
||||
this.__alt = payload.alt ?? ""
|
||||
this.__caption = payload.caption ?? ""
|
||||
this.__height = normalizeDimension(payload.height) ?? 0
|
||||
this.__kind = payload.kind
|
||||
this.__poster = payload.poster ?? ""
|
||||
this.__src = payload.src
|
||||
this.__width = normalizeDimension(payload.width) ?? 0
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
return document.createElement("div")
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const figure = document.createElement("figure")
|
||||
figure.dataset.lexicalMediaKind = this.__kind
|
||||
figure.dataset.lexicalMediaAlignment = this.__alignment
|
||||
figure.style.textAlign = this.__alignment
|
||||
|
||||
if (getImageUploadState(this.__key)) {
|
||||
figure.dataset.lexicalImageUploading = "true"
|
||||
return { element: figure }
|
||||
}
|
||||
|
||||
if (this.__kind === "image") {
|
||||
const image = document.createElement("img")
|
||||
image.setAttribute("src", this.__src)
|
||||
image.setAttribute("alt", this.__alt)
|
||||
if (this.__width) image.setAttribute("width", String(this.__width))
|
||||
if (this.__height) image.setAttribute("height", String(this.__height))
|
||||
figure.append(image)
|
||||
} else {
|
||||
const video = document.createElement("video")
|
||||
video.setAttribute("src", this.__src)
|
||||
video.setAttribute("controls", "")
|
||||
if (this.__poster) video.setAttribute("poster", this.__poster)
|
||||
figure.append(video)
|
||||
}
|
||||
|
||||
if (this.__caption) {
|
||||
const caption = document.createElement("figcaption")
|
||||
caption.textContent = this.__caption
|
||||
figure.append(caption)
|
||||
}
|
||||
|
||||
return { element: figure }
|
||||
}
|
||||
|
||||
exportJSON(): SerializedLexicalMediaNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
...this.getPayload(),
|
||||
type: "lexical-media",
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
getPayload(): LexicalMediaPayload {
|
||||
const latest = this.getLatest()
|
||||
|
||||
return {
|
||||
alignment: latest.__alignment,
|
||||
alt: latest.__alt || undefined,
|
||||
caption: latest.__caption || undefined,
|
||||
height: latest.__height || undefined,
|
||||
kind: latest.__kind,
|
||||
poster: latest.__poster || undefined,
|
||||
src: latest.__src,
|
||||
width: latest.__width || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
setCaption(caption: string): this {
|
||||
const writable = this.getWritable()
|
||||
writable.__caption = caption
|
||||
return this
|
||||
}
|
||||
|
||||
setAlignment(alignment: LexicalMediaAlignment): this {
|
||||
this.getWritable().__alignment = alignment
|
||||
return this
|
||||
}
|
||||
|
||||
setPayload(payload: LexicalMediaPayload): this {
|
||||
const writable = this.getWritable()
|
||||
writable.__alignment = normalizeAlignment(payload.alignment)
|
||||
writable.__alt = payload.alt ?? ""
|
||||
writable.__caption = payload.caption ?? ""
|
||||
writable.__height = normalizeDimension(payload.height) ?? 0
|
||||
writable.__kind = payload.kind
|
||||
writable.__poster = payload.poster ?? ""
|
||||
writable.__src = payload.src
|
||||
writable.__width = normalizeDimension(payload.width) ?? 0
|
||||
return this
|
||||
}
|
||||
|
||||
setWidthAndHeight(width: number, height: number): this {
|
||||
const writable = this.getWritable()
|
||||
writable.__width = normalizeDimension(width) ?? 0
|
||||
writable.__height = normalizeDimension(height) ?? 0
|
||||
return this
|
||||
}
|
||||
|
||||
decorate(): React.ReactNode {
|
||||
return <LexicalMedia payload={this.getPayload()} nodeKey={this.__key} />
|
||||
}
|
||||
|
||||
isInline(): false {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function LexicalMedia({
|
||||
nodeKey,
|
||||
payload,
|
||||
}: {
|
||||
nodeKey: NodeKey
|
||||
payload: LexicalMediaPayload
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [isSelected, setSelected, clearSelection] =
|
||||
useLexicalNodeSelection(nodeKey)
|
||||
const imageRef = React.useRef<HTMLImageElement>(null)
|
||||
const captionRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
const [draftCaption, setDraftCaption] = React.useState(payload.caption ?? "")
|
||||
const [isEditingCaption, setEditingCaption] = React.useState(false)
|
||||
const [isResizing, setResizing] = React.useState(false)
|
||||
const uploadState = useImageUploadState(nodeKey)
|
||||
const translate = useTranslate()
|
||||
const uploadingImageLabel = useLexicalMessage(lexicalMessages.uploadingImage)
|
||||
const uploadProgressLabel = useLexicalMessage(lexicalMessages.uploadProgress)
|
||||
const imageControlsLabel = useLexicalMessage(lexicalMessages.imageControls)
|
||||
const deleteImageLabel = useLexicalMessage(lexicalMessages.deleteImage)
|
||||
const captionLabel = useLexicalMessage(lexicalMessages.caption)
|
||||
const addCaptionLabel = useLexicalMessage(lexicalMessages.addCaption)
|
||||
const editCaptionLabel = useLexicalMessage(lexicalMessages.editCaption)
|
||||
const imageAlignmentLabels = {
|
||||
center: useLexicalMessage(lexicalMessages.imageAlignCenter),
|
||||
end: useLexicalMessage(lexicalMessages.imageAlignRight),
|
||||
start: useLexicalMessage(lexicalMessages.imageAlignLeft),
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isEditingCaption) {
|
||||
setDraftCaption(payload.caption ?? "")
|
||||
}
|
||||
}, [isEditingCaption, payload.caption])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isEditingCaption) {
|
||||
captionRef.current?.focus()
|
||||
captionRef.current?.select()
|
||||
}
|
||||
}, [isEditingCaption])
|
||||
|
||||
React.useEffect(
|
||||
() =>
|
||||
mergeRegister(
|
||||
editor.registerCommand(
|
||||
CLICK_COMMAND,
|
||||
(event) => {
|
||||
const element = editor.getElementByKey(nodeKey)
|
||||
const target = event.target
|
||||
if (!(target instanceof Node) || !element?.contains(target)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!event.shiftKey) clearSelection()
|
||||
setSelected(event.shiftKey ? !isSelected : true)
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_DELETE_COMMAND,
|
||||
(event) => {
|
||||
if (!isSelected) return false
|
||||
event?.preventDefault()
|
||||
editor.update(() => {
|
||||
const node = $getNodeByKey(nodeKey)
|
||||
if ($isLexicalMediaNode(node)) node.remove()
|
||||
})
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
(event) => {
|
||||
if (!isSelected) return false
|
||||
event?.preventDefault()
|
||||
editor.update(() => {
|
||||
const node = $getNodeByKey(nodeKey)
|
||||
if ($isLexicalMediaNode(node)) node.remove()
|
||||
})
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
)
|
||||
),
|
||||
[clearSelection, editor, isSelected, nodeKey, setSelected]
|
||||
)
|
||||
|
||||
return (
|
||||
<figure
|
||||
contentEditable={false}
|
||||
data-lexical-media-kind={payload.kind}
|
||||
className={cn(
|
||||
"my-4 flex max-w-full flex-col items-center gap-2 rounded-xl",
|
||||
payload.alignment === "start" && "items-start",
|
||||
payload.alignment === "end" && "items-end",
|
||||
payload.kind === "video" &&
|
||||
isSelected &&
|
||||
"ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||
)}
|
||||
>
|
||||
{payload.kind === "image" ? (
|
||||
<div
|
||||
aria-busy={uploadState ? true : undefined}
|
||||
className={cn(
|
||||
"relative inline-flex max-w-full rounded-xl",
|
||||
uploadState && "min-h-40 w-full bg-muted"
|
||||
)}
|
||||
data-lexical-image-selected={isSelected || undefined}
|
||||
>
|
||||
<img
|
||||
ref={imageRef}
|
||||
alt={payload.alt ?? ""}
|
||||
className="max-h-128 max-w-full rounded-xl object-contain"
|
||||
draggable={false}
|
||||
src={payload.src}
|
||||
style={{
|
||||
height: payload.height || undefined,
|
||||
width: payload.width || undefined,
|
||||
}}
|
||||
/>
|
||||
{uploadState && (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={uploadingImageLabel}
|
||||
className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 rounded-xl bg-black/55 px-8 text-sm font-medium text-white backdrop-blur-[2px]"
|
||||
>
|
||||
<span>
|
||||
{uploadState.progress === undefined
|
||||
? `${uploadingImageLabel}…`
|
||||
: translate(lexicalMessages.uploadingImageProgress, {
|
||||
progress: Math.round(uploadState.progress * 100),
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
role="progressbar"
|
||||
aria-label={uploadProgressLabel}
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={
|
||||
uploadState.progress === undefined
|
||||
? undefined
|
||||
: Math.round(uploadState.progress * 100)
|
||||
}
|
||||
className="h-1.5 w-full max-w-64 overflow-hidden rounded-full bg-white/25"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block h-full rounded-full bg-white transition-[width]",
|
||||
uploadState.progress === undefined && "w-1/3 animate-pulse"
|
||||
)}
|
||||
style={{
|
||||
width:
|
||||
uploadState.progress === undefined
|
||||
? undefined
|
||||
: `${uploadState.progress * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isSelected && editor.isEditable() && !uploadState && (
|
||||
<>
|
||||
<ImageResizer
|
||||
editor={editor}
|
||||
imageRef={imageRef}
|
||||
onResizeStart={() => setResizing(true)}
|
||||
onResizeEnd={(width, height) => {
|
||||
editor.update(() => {
|
||||
const node = $getNodeByKey(nodeKey)
|
||||
if ($isLexicalMediaNode(node)) {
|
||||
node.setWidthAndHeight(width, height)
|
||||
}
|
||||
})
|
||||
setResizing(false)
|
||||
}}
|
||||
/>
|
||||
{!isResizing && (
|
||||
<div
|
||||
className="absolute top-3 left-1/2 z-20 flex -translate-x-1/2 items-center gap-0.5 rounded-lg border border-white/20 bg-black/65 p-1 text-white shadow-lg backdrop-blur-sm"
|
||||
role="toolbar"
|
||||
aria-label={imageControlsLabel}
|
||||
>
|
||||
{IMAGE_ALIGNMENT_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.alignment}
|
||||
type="button"
|
||||
aria-label={imageAlignmentLabels[option.alignment]}
|
||||
aria-pressed={payload.alignment === option.alignment}
|
||||
className="inline-flex size-7 items-center justify-center rounded-md hover:bg-white/15 aria-pressed:bg-white aria-pressed:text-black"
|
||||
onClick={() => {
|
||||
editor.update(() => {
|
||||
const node = $getNodeByKey(nodeKey)
|
||||
if ($isLexicalMediaNode(node)) {
|
||||
node.setAlignment(option.alignment)
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option.icon className="size-4" />
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-0.5 h-4 w-px bg-white/25" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={deleteImageLabel}
|
||||
className="hover:text-destructive-foreground inline-flex size-7 items-center justify-center rounded-md hover:bg-destructive"
|
||||
onClick={() => {
|
||||
editor.update(() => {
|
||||
const node = $getNodeByKey(nodeKey)
|
||||
if ($isLexicalMediaNode(node)) node.remove()
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isResizing &&
|
||||
(isEditingCaption ? (
|
||||
<textarea
|
||||
ref={captionRef}
|
||||
aria-label={captionLabel}
|
||||
className="absolute inset-x-4 bottom-4 z-20 min-h-10 resize-none rounded-lg border border-white/20 bg-black/65 px-3 py-2 text-center text-sm text-white backdrop-blur-sm outline-none focus:border-white/60"
|
||||
contentEditable={false}
|
||||
placeholder={addCaptionLabel}
|
||||
rows={1}
|
||||
value={draftCaption}
|
||||
onBlur={() => setEditingCaption(false)}
|
||||
onChange={(event) => {
|
||||
const caption = event.target.value
|
||||
setDraftCaption(caption)
|
||||
editor.update(() => {
|
||||
const node = $getNodeByKey(nodeKey)
|
||||
if ($isLexicalMediaNode(node)) {
|
||||
node.setCaption(caption)
|
||||
}
|
||||
})
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : payload.caption ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={editCaptionLabel}
|
||||
className="absolute inset-x-4 bottom-4 z-20 rounded-lg border border-white/20 bg-black/65 px-3 py-2 text-center text-sm text-white backdrop-blur-sm hover:bg-black/75"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setEditingCaption(true)
|
||||
}}
|
||||
>
|
||||
{payload.caption}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-4 left-1/2 z-20 -translate-x-1/2 rounded-lg border border-white/20 bg-black/65 px-5 py-2 text-sm text-white backdrop-blur-sm hover:bg-black/75"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setEditingCaption(true)
|
||||
}}
|
||||
>
|
||||
{addCaptionLabel}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!isSelected && payload.caption && (
|
||||
<figcaption className="absolute inset-x-4 bottom-4 rounded-lg bg-black/65 px-3 py-2 text-center text-sm text-white backdrop-blur-sm">
|
||||
{payload.caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
className="max-h-128 max-w-full rounded-xl bg-black"
|
||||
controls
|
||||
poster={payload.poster}
|
||||
preload="metadata"
|
||||
src={payload.src}
|
||||
/>
|
||||
)}
|
||||
{payload.kind === "video" && payload.caption && (
|
||||
<figcaption className="text-center text-sm text-muted-foreground">
|
||||
{payload.caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeDimension(value: unknown): number | undefined {
|
||||
const dimension =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number.parseFloat(value)
|
||||
: Number.NaN
|
||||
|
||||
return Number.isFinite(dimension) && dimension > 0 ? dimension : undefined
|
||||
}
|
||||
|
||||
function normalizeAlignment(value: unknown): LexicalMediaAlignment {
|
||||
if (value === "start" || value === "left") return "start"
|
||||
if (value === "end" || value === "right") return "end"
|
||||
return "center"
|
||||
}
|
||||
|
||||
const IMAGE_ALIGNMENT_OPTIONS = [
|
||||
{
|
||||
alignment: "start",
|
||||
icon: AlignLeft,
|
||||
},
|
||||
{
|
||||
alignment: "center",
|
||||
icon: AlignCenter,
|
||||
},
|
||||
{
|
||||
alignment: "end",
|
||||
icon: AlignRight,
|
||||
},
|
||||
] as const
|
||||
|
||||
export function $createLexicalMediaNode(
|
||||
payload: LexicalMediaPayload
|
||||
): LexicalMediaNode {
|
||||
return $applyNodeReplacement(new LexicalMediaNode(payload))
|
||||
}
|
||||
|
||||
export function $isLexicalMediaNode(
|
||||
node: LexicalNode | null | undefined
|
||||
): node is LexicalMediaNode {
|
||||
return node instanceof LexicalMediaNode
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* eslint-disable no-underscore-dangle -- Lexical nodes use protected __ fields by convention. */
|
||||
|
||||
import { $isTextNode, TextNode } from "lexical"
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMConversionProp,
|
||||
SerializedTextNode,
|
||||
} from "lexical"
|
||||
|
||||
function patchTextStyleConversion(
|
||||
originalDOMConverter?: DOMConversionProp<HTMLElement>
|
||||
) {
|
||||
return (node: HTMLElement): DOMConversionOutput | null => {
|
||||
const original = originalDOMConverter?.(node)
|
||||
const output = original?.conversion(node)
|
||||
if (!output) return null
|
||||
|
||||
const color = node.style.color
|
||||
const backgroundColor = node.style.backgroundColor
|
||||
const textDecoration = node.style.textDecoration
|
||||
const style = [
|
||||
color ? `color: ${color}` : null,
|
||||
backgroundColor ? `background-color: ${backgroundColor}` : null,
|
||||
textDecoration ? `text-decoration: ${textDecoration}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ")
|
||||
|
||||
return {
|
||||
...output,
|
||||
forChild: (lexicalNode, parent) => {
|
||||
const converted = output.forChild
|
||||
? output.forChild(lexicalNode, parent)
|
||||
: lexicalNode
|
||||
if ($isTextNode(converted) && style) converted.setStyle(style)
|
||||
return converted
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LexicalTextNode extends TextNode {
|
||||
static getType(): string {
|
||||
return "lexical-text"
|
||||
}
|
||||
|
||||
static clone(node: LexicalTextNode): LexicalTextNode {
|
||||
return new LexicalTextNode(node.__text, node.__key)
|
||||
}
|
||||
|
||||
static importJSON(node: SerializedTextNode): LexicalTextNode {
|
||||
return new LexicalTextNode().updateFromJSON(node)
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
const importers = TextNode.importDOM()
|
||||
return {
|
||||
...importers,
|
||||
code: () => ({
|
||||
conversion: patchTextStyleConversion(importers?.code),
|
||||
priority: 1,
|
||||
}),
|
||||
em: () => ({
|
||||
conversion: patchTextStyleConversion(importers?.em),
|
||||
priority: 1,
|
||||
}),
|
||||
span: () => ({
|
||||
conversion: patchTextStyleConversion(importers?.span),
|
||||
priority: 1,
|
||||
}),
|
||||
strong: () => ({
|
||||
conversion: patchTextStyleConversion(importers?.strong),
|
||||
priority: 1,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
isSimpleText(): boolean {
|
||||
return this.__type === "lexical-text" && this.__mode === 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user