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
+60
View File
@@ -0,0 +1,60 @@
import { AlignCenter, AlignJustify, AlignLeft, AlignRight } from "lucide-react"
import {
$getSelection,
$isRangeSelection,
FORMAT_ELEMENT_COMMAND,
} from "lexical"
import type { ElementFormatType } from "lexical"
import type { LexicalActionDefinition, LexicalActionName } from "../types"
import type { LexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
function alignmentAction(
name: LexicalActionName,
label: LexicalMessage,
alignment: ElementFormatType,
icon: LexicalActionDefinition["icon"]
): LexicalActionDefinition {
return {
name,
label,
icon,
execute: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, alignment)
},
isActive: ({ editor }) =>
editor.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return false
return (
selection.anchor.getNode().getTopLevelElement()?.getFormatType() ===
alignment
)
}),
}
}
export const leftAlignAction = alignmentAction(
"leftAlign",
lexicalMessages.alignLeft,
"left",
AlignLeft
)
export const centerAlignAction = alignmentAction(
"centerAlign",
lexicalMessages.alignCenter,
"center",
AlignCenter
)
export const rightAlignAction = alignmentAction(
"rightAlign",
lexicalMessages.alignRight,
"right",
AlignRight
)
export const justifyAlignAction = alignmentAction(
"justifyAlign",
lexicalMessages.alignJustify,
"justify",
AlignJustify
)
+95
View File
@@ -0,0 +1,95 @@
import {
$createHeadingNode,
$createQuoteNode,
$isHeadingNode,
$isQuoteNode,
HeadingNode,
QuoteNode,
} from "@lexical/rich-text"
import type { HeadingTagType } from "@lexical/rich-text"
import { $setBlocksType } from "@lexical/selection"
import { Heading1, Heading2, Heading3, Pilcrow, Quote } from "lucide-react"
import { $createParagraphNode, $getSelection, $isRangeSelection } from "lexical"
import type { LexicalActionDefinition } from "../types"
import { lexicalMessages } from "../messages"
function setBlock(
editor: Parameters<LexicalActionDefinition["execute"]>[0]["editor"],
block: "paragraph" | HeadingTagType | "quote"
) {
editor.update(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return
$setBlocksType(selection, () => {
if (block === "paragraph") return $createParagraphNode()
if (block === "quote") return $createQuoteNode()
return $createHeadingNode(block)
})
})
}
function isBlockActive(
editor: Parameters<LexicalActionDefinition["execute"]>[0]["editor"],
block: "paragraph" | HeadingTagType | "quote"
) {
return editor.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return false
const node = selection.anchor.getNode().getTopLevelElement()
if (block === "quote") return $isQuoteNode(node)
if (block === "paragraph") return node?.getType() === "paragraph"
return $isHeadingNode(node) && node.getTag() === block
})
}
function blockAction(
definition: Omit<LexicalActionDefinition, "execute" | "isActive">,
block: "paragraph" | HeadingTagType | "quote"
): LexicalActionDefinition {
return {
...definition,
execute: ({ editor }) => setBlock(editor, block),
isActive: ({ editor }) => isBlockActive(editor, block),
}
}
export const normalAction = blockAction(
{ name: "normal", label: lexicalMessages.normal, icon: Pilcrow },
"paragraph"
)
export const heading1Action = blockAction(
{
name: "heading1",
label: lexicalMessages.heading1,
icon: Heading1,
nodes: [HeadingNode],
},
"h1"
)
export const heading2Action = blockAction(
{
name: "heading2",
label: lexicalMessages.heading2,
icon: Heading2,
nodes: [HeadingNode],
},
"h2"
)
export const heading3Action = blockAction(
{
name: "heading3",
label: lexicalMessages.heading3,
icon: Heading3,
nodes: [HeadingNode],
},
"h3"
)
export const quoteAction = blockAction(
{
name: "quote",
label: lexicalMessages.quote,
icon: Quote,
nodes: [QuoteNode],
},
"quote"
)
@@ -0,0 +1,24 @@
import { TOGGLE_LINK_COMMAND } from "@lexical/link"
import { REMOVE_LIST_COMMAND } from "@lexical/list"
import { $patchStyleText, $setBlocksType } from "@lexical/selection"
import { Eraser } from "lucide-react"
import { $createParagraphNode, $getSelection, $isRangeSelection } from "lexical"
import type { LexicalActionDefinition } from "../types"
import { lexicalMessages } from "../messages"
export const clearFormattingAction: LexicalActionDefinition = {
name: "clearFormatting",
label: lexicalMessages.clearFormatting,
icon: Eraser,
execute: ({ editor }) => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
editor.update(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return
selection.setFormat(0)
$patchStyleText(selection, { color: null, "font-size": null })
$setBlocksType(selection, () => $createParagraphNode())
})
},
}
@@ -0,0 +1,225 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"
import {
ClipboardImages,
LexicalActions,
LexicalContent,
LexicalRoot,
} from ".."
afterEach(cleanup)
if (typeof globalThis.DragEvent === "undefined") {
Object.defineProperty(globalThis, "DragEvent", {
configurable: true,
value: class DragEvent extends Event {},
})
}
if (typeof globalThis.ClipboardEvent === "undefined") {
Object.defineProperty(globalThis, "ClipboardEvent", {
configurable: true,
value: class ClipboardEvent extends Event {},
})
}
function pasteFiles(element: Element, files: readonly File[]) {
fireEvent.paste(element, {
clipboardData: {
files,
getData: () => "",
types: ["Files"],
},
})
}
describe("clipboard images", () => {
it("embeds pasted images as data URLs by default", async () => {
const { container } = render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<ClipboardImages />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const editor = container.querySelector("[contenteditable=true]")
expect(editor).not.toBeNull()
pasteFiles(editor!, [
new File([new Uint8Array([137, 80, 78, 71])], "avatar.png", {
type: "image/png",
}),
])
await waitFor(() => {
expect(
screen.getByRole("img", { name: "avatar.png" }).getAttribute("src")
).toMatch(/^data:image\/png;base64,/)
})
})
it("lets applications upload images and insert persistent URLs", async () => {
const resolveImage = vi.fn(async (file: File) => ({
alt: file.name,
src: `/uploads/${file.name}`,
}))
const { container } = render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<ClipboardImages resolveImage={resolveImage} />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const editor = container.querySelector("[contenteditable=true]")
expect(editor).not.toBeNull()
pasteFiles(editor!, [
new File(["image"], "product.webp", {
type: "image/webp",
}),
])
await waitFor(() => {
expect(
screen.getByRole("img", { name: "product.webp" }).getAttribute("src")
).toBe("/uploads/product.webp")
})
expect(resolveImage).toHaveBeenCalledOnce()
})
it("shows a local placeholder and resolver-reported upload progress", async () => {
let finishUpload:
| ((image: { alt: string; src: string }) => void)
| undefined
let reportProgress: ((progress: number) => void) | undefined
const resolveImage = vi.fn(
(
_file: File,
context: { reportProgress: (progress: number) => void }
) => {
reportProgress = context.reportProgress
return new Promise<{ alt: string; src: string }>((resolve) => {
finishUpload = resolve
})
}
)
const handleChange = vi.fn()
const { container } = render(
<LexicalRoot value="" onChange={handleChange}>
<LexicalActions>
<ClipboardImages resolveImage={resolveImage} />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const editor = container.querySelector("[contenteditable=true]")
expect(editor).not.toBeNull()
pasteFiles(editor!, [
new File(["large image"], "large.png", { type: "image/png" }),
])
expect(
await screen.findByRole("status", { name: "Uploading image" })
).not.toBeNull()
expect(screen.getByText("Uploading image…")).not.toBeNull()
expect(handleChange).toHaveBeenCalled()
expect(handleChange.mock.lastCall?.[0]).not.toContain("blob:")
act(() => reportProgress?.(0.42))
expect(screen.getByText("Uploading 42%")).not.toBeNull()
expect(
screen
.getByRole("progressbar", { name: "Image upload progress" })
.getAttribute("aria-valuenow")
).toBe("42")
await act(async () => {
finishUpload?.({
alt: "Uploaded large image",
src: "/uploads/large.png",
})
})
await waitFor(() => {
expect(
screen
.getByRole("img", { name: "Uploaded large image" })
.getAttribute("src")
).toBe("/uploads/large.png")
expect(
screen.queryByRole("status", { name: "Uploading image" })
).toBeNull()
})
})
it("does not intercept non-image files", async () => {
const resolveImage = vi.fn()
const { container } = render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<ClipboardImages resolveImage={resolveImage} />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const editor = container.querySelector("[contenteditable=true]")
expect(editor).not.toBeNull()
pasteFiles(editor!, [
new File(["Quarterly report"], "report.pdf", {
type: "application/pdf",
}),
])
await waitFor(() => expect(resolveImage).not.toHaveBeenCalled())
expect(container.querySelector("img")).toBeNull()
})
it("reports failures without preventing later image pastes", async () => {
const onError = vi.fn()
const resolveImage = vi
.fn()
.mockRejectedValueOnce(new Error("Upload failed"))
.mockResolvedValueOnce({
alt: "Recovered",
src: "/uploads/recovered.png",
})
const { container } = render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<ClipboardImages resolveImage={resolveImage} onError={onError} />
</LexicalActions>
<LexicalContent />
</LexicalRoot>
)
const editor = container.querySelector("[contenteditable=true]")
expect(editor).not.toBeNull()
pasteFiles(editor!, [
new File(["first"], "first.png", { type: "image/png" }),
])
await waitFor(() => {
expect(onError).toHaveBeenCalledOnce()
})
pasteFiles(editor!, [
new File(["second"], "second.png", { type: "image/png" }),
])
expect(await screen.findByRole("img", { name: "Recovered" })).not.toBeNull()
})
})
@@ -0,0 +1,381 @@
"use client"
import { useEffect } from "react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { DRAG_DROP_PASTE } from "@lexical/rich-text"
import {
$getNodeByKey,
$getRoot,
$getSelection,
$insertNodes,
$setSelection,
COMMAND_PRIORITY_HIGH,
mergeRegister,
PASTE_COMMAND,
} from "lexical"
import type { BaseSelection, LexicalEditor, NodeKey } from "lexical"
import {
deleteImageUploadState,
setImageUploadState,
} from "../image-upload-store"
import {
$createLexicalMediaNode,
$isLexicalMediaNode,
LexicalMediaNode,
type LexicalMediaPayload,
} from "../nodes/media-node"
import type { LexicalActionDefinition } from "../types"
import { lexicalMessages } from "../messages"
export type LexicalClipboardImage = Omit<LexicalMediaPayload, "kind" | "poster">
export interface LexicalClipboardImageContext {
editor: LexicalEditor
reportProgress: (progress: number) => void
signal: AbortSignal
}
export type LexicalClipboardImageResolver = (
file: File,
context: LexicalClipboardImageContext
) =>
| LexicalClipboardImage
| null
| undefined
| Promise<LexicalClipboardImage | null | undefined>
export interface CreateClipboardImagesActionOptions {
/**
* Maximum image size that may be embedded as a data URL when `resolveImage`
* is not supplied. Images are unrestricted by default.
*/
maxInlineImageBytes?: number
onError?: (error: unknown, file: File) => void
/**
* Uploads or otherwise resolves a pasted image to a persistent URL.
* Non-image clipboard files are always ignored.
*/
resolveImage?: LexicalClipboardImageResolver
}
function isClipboardEvent(
event: ClipboardEvent | InputEvent | KeyboardEvent
): event is ClipboardEvent {
return "clipboardData" in event && event.clipboardData != null
}
function readFileAsDataUrl(
file: File,
signal: AbortSignal,
reportProgress: (progress: number) => void
) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
const handleSignalAbort = () => reader.abort()
const cleanUp = () => signal.removeEventListener("abort", handleSignalAbort)
if (signal.aborted) {
reject(new DOMException("The image read was aborted.", "AbortError"))
return
}
signal.addEventListener("abort", handleSignalAbort, { once: true })
reader.addEventListener(
"error",
() => {
const error =
reader.error ?? new Error(`Unable to read "${file.name}".`)
cleanUp()
reject(error)
},
{ once: true }
)
reader.addEventListener(
"abort",
() => {
cleanUp()
reject(new DOMException("The image read was aborted.", "AbortError"))
},
{ once: true }
)
reader.addEventListener("progress", (event) => {
if (event.lengthComputable && event.total > 0) {
reportProgress(event.loaded / event.total)
}
})
reader.addEventListener(
"load",
() => {
const result = reader.result
cleanUp()
if (typeof result === "string") {
resolve(result)
} else {
reject(new Error(`Unable to read "${file.name}" as a data URL.`))
}
},
{ once: true }
)
try {
reader.readAsDataURL(file)
} catch (error) {
cleanUp()
reject(error)
}
})
}
async function resolveDefaultImage(
file: File,
signal: AbortSignal,
maxInlineImageBytes: number,
reportProgress: (progress: number) => void
): Promise<LexicalClipboardImage> {
if (file.size > maxInlineImageBytes) {
throw new Error(
`"${file.name}" exceeds the ${maxInlineImageBytes}-byte inline image limit.`
)
}
return {
alt: file.name,
src: await readFileAsDataUrl(file, signal, reportProgress),
}
}
interface PendingImage {
file: File
nodeKey: NodeKey
releasePreview: VoidFunction
}
const TRANSPARENT_IMAGE =
"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
function createImagePreview(file: File) {
if (typeof URL.createObjectURL !== "function") {
return {
release: () => undefined,
src: TRANSPARENT_IMAGE,
}
}
const src = URL.createObjectURL(file)
let released = false
return {
release: () => {
if (released) return
released = true
URL.revokeObjectURL(src)
},
src,
}
}
function insertImagePlaceholders(
selection: BaseSelection | null,
images: readonly File[]
): PendingImage[] {
const pendingImages: PendingImage[] = []
if (selection) {
$setSelection(selection)
} else {
$getRoot().selectEnd()
}
const nodes = images.map((file) => {
const preview = createImagePreview(file)
const node = $createLexicalMediaNode({
alt: file.name,
kind: "image",
src: preview.src,
})
const nodeKey = node.getKey()
setImageUploadState(nodeKey, {})
pendingImages.push({
file,
nodeKey,
releasePreview: preview.release,
})
return node
})
$insertNodes(nodes)
return pendingImages
}
function updatePendingImage(
editor: LexicalEditor,
nodeKey: NodeKey,
image: LexicalClipboardImage,
src: string,
fallbackAlt: string
) {
editor.update(
() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) {
node.setPayload({
...image,
alt: image.alt ?? fallbackAlt,
kind: "image",
src,
})
}
},
{ discrete: true }
)
}
function removePendingImageNode(editor: LexicalEditor, nodeKey: NodeKey) {
editor.update(
() => {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) node.remove()
},
{ discrete: true }
)
}
function LexicalClipboardImagesPlugin({
maxInlineImageBytes = Number.POSITIVE_INFINITY,
onError,
resolveImage,
}: CreateClipboardImagesActionOptions) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
const abortController = new AbortController()
const pendingPreviews = new Map<NodeKey, VoidFunction>()
const finishPendingImage = (nodeKey: NodeKey) => {
deleteImageUploadState(nodeKey)
pendingPreviews.get(nodeKey)?.()
pendingPreviews.delete(nodeKey)
}
const removePendingImage = (nodeKey: NodeKey) => {
removePendingImageNode(editor, nodeKey)
finishPendingImage(nodeKey)
}
const handleImages = (files: readonly File[]) => {
const images = files.filter((file) => file.type.startsWith("image/"))
if (images.length === 0) return false
const selection = $getSelection()?.clone() ?? null
const signal = abortController.signal
const pendingImages = insertImagePlaceholders(selection, images)
for (const pendingImage of pendingImages) {
const { file, nodeKey, releasePreview } = pendingImage
pendingPreviews.set(nodeKey, releasePreview)
const reportProgress = (progress: number) => {
if (signal.aborted || !pendingPreviews.has(nodeKey)) return
setImageUploadState(nodeKey, {
progress: Math.min(Math.max(progress, 0), 1),
})
}
void (async () => {
try {
const image = resolveImage
? await resolveImage(file, {
editor,
reportProgress,
signal,
})
: await resolveDefaultImage(
file,
signal,
maxInlineImageBytes,
reportProgress
)
const src = image?.src.trim()
if (!image || !src || signal.aborted) {
removePendingImage(nodeKey)
return
}
updatePendingImage(editor, nodeKey, image, src, file.name)
finishPendingImage(nodeKey)
} catch (error) {
if (!signal.aborted) onError?.(error, file)
removePendingImage(nodeKey)
}
})()
}
return true
}
return mergeRegister(
editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (!isClipboardEvent(event)) return false
const clipboardData = event.clipboardData
if (!clipboardData) return false
const handled = handleImages(Array.from(clipboardData.files))
if (handled) event.preventDefault()
return handled
},
COMMAND_PRIORITY_HIGH
),
editor.registerCommand(
DRAG_DROP_PASTE,
handleImages,
COMMAND_PRIORITY_HIGH
),
() => {
abortController.abort()
const nodeKeys = [...pendingPreviews.keys()]
if (nodeKeys.length > 0) {
editor.update(
() => {
for (const nodeKey of nodeKeys) {
const node = $getNodeByKey(nodeKey)
if ($isLexicalMediaNode(node)) node.remove()
}
},
{ discrete: true }
)
}
nodeKeys.forEach(finishPendingImage)
}
)
}, [editor, maxInlineImageBytes, onError, resolveImage])
return null
}
export function createClipboardImagesAction(
options: CreateClipboardImagesActionOptions = {}
): LexicalActionDefinition {
function ClipboardImagesPlugin() {
return <LexicalClipboardImagesPlugin {...options} />
}
ClipboardImagesPlugin.displayName = "LexicalClipboardImagesPlugin"
return {
name: "clipboardImages",
label: lexicalMessages.pasteImage,
hidden: true,
nodes: [LexicalMediaNode],
plugins: [ClipboardImagesPlugin],
execute: () => undefined,
}
}
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { $createParagraphNode, $createTextNode, $getRoot } from "lexical"
import { useEffect } from "react"
import { afterEach, describe, expect, it } from "vitest"
import {
LexicalActions,
LexicalContent,
LexicalFixedToolbar,
LexicalRoot,
TextColor,
} from ".."
afterEach(cleanup)
function InsertSelectedTextPlugin() {
const [editor] = useLexicalComposerContext()
useEffect(() => {
editor.update(() => {
const textNode = $createTextNode("Lexical")
$getRoot().clear().append($createParagraphNode().append(textNode))
textNode.select(0, textNode.getTextContentSize())
})
}, [editor])
return null
}
describe("color picker action", () => {
it("provides preset colors and a custom color input", async () => {
render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<TextColor />
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
</LexicalRoot>
)
fireEvent.click(screen.getByRole("button", { name: "Text color" }))
expect(await screen.findByText("Preset colors")).not.toBeNull()
expect(
screen.getByRole("button", { name: "Select text color #dc2626" })
).not.toBeNull()
expect(screen.getByLabelText("Choose a custom text color")).toHaveProperty(
"type",
"color"
)
expect(
screen.getByRole("textbox", { name: "Custom text color value" })
).toHaveProperty("value", "#000000")
fireEvent.input(screen.getByLabelText("Choose a custom text color"), {
target: { value: "#4F3030" },
})
expect(screen.getByText("Preset colors")).not.toBeNull()
expect(
screen.getByRole("textbox", { name: "Custom text color value" })
).toHaveProperty("value", "#4F3030")
})
it("applies a custom hex color to the selection captured before input focus", async () => {
const { container } = render(
<LexicalRoot value="" onChange={() => undefined}>
<LexicalActions>
<TextColor />
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
<InsertSelectedTextPlugin />
</LexicalRoot>
)
await waitFor(() => {
expect(
container.querySelector("[contenteditable=true]")?.textContent
).toBe("Lexical")
})
fireEvent.click(screen.getByRole("button", { name: "Text color" }))
const input = await screen.findByRole("textbox", {
name: "Custom text color value",
})
input.focus()
fireEvent.change(input, { target: { value: "#825230" } })
fireEvent.blur(input)
await waitFor(() => {
expect(
container.querySelector<HTMLElement>(
"[contenteditable=true] [style*='color']"
)?.style.color
).toBe("rgb(130, 82, 48)")
})
})
})
@@ -0,0 +1,241 @@
import {
$getSelectionStyleValueForProperty,
$patchStyleText,
} from "@lexical/selection"
import { Baseline, Pipette } from "lucide-react"
import { $getSelection, $isRangeSelection, $setSelection } from "lexical"
import type { LexicalEditor, RangeSelection } from "lexical"
import { useRef, useState } from "react"
import { useTranslate } from "@workspace/i18n"
import { Button } from "@workspace/ui/components/button"
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from "@workspace/ui/components/popover"
import { cn } from "@workspace/ui/lib/utils"
import type {
LexicalActionDefaultControlProps,
LexicalActionDefinition,
} from "../types"
import { useLexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
const presetTextColors = [
["#000000", "#dc2626", "#f97316", "#facc15", "#16a34a", "#2563eb", "#9333ea"],
["#f5f5f5", "#fecaca", "#fed7aa", "#fef08a", "#bbf7d0", "#bfdbfe", "#e9d5ff"],
["#a3a3a3", "#f87171", "#fdba74", "#fde047", "#4ade80", "#60a5fa", "#c084fc"],
["#737373", "#b91c1c", "#c2410c", "#a16207", "#15803d", "#1d4ed8", "#7e22ce"],
["#404040", "#7f1d1d", "#7c2d12", "#713f12", "#14532d", "#1e3a8a", "#581c87"],
].flat()
function normalizeHexColor(value: string) {
const hex = value.trim().replace(/^#/, "")
if (/^[\da-f]{3}$/i.test(hex)) {
return `#${[...hex]
.map((character) => character.repeat(2))
.join("")
.toUpperCase()}`
}
return /^[\da-f]{6}$/i.test(hex) ? `#${hex.toUpperCase()}` : null
}
function applyTextColor(
editor: LexicalEditor,
value: string,
savedSelection?: RangeSelection | null
) {
editor.update(() => {
const currentSelection = $getSelection()
const selection = $isRangeSelection(currentSelection)
? currentSelection
: savedSelection?.clone()
if (!selection) return
if (selection !== currentSelection) $setSelection(selection)
$patchStyleText(selection, { color: value })
})
}
function ColorPickerControl({
context,
label,
}: LexicalActionDefaultControlProps) {
const presetColorsLabel = useLexicalMessage(lexicalMessages.presetColors)
const textColorPickerLabel = useLexicalMessage(
lexicalMessages.textColorPicker
)
const customTextColorLabel = useLexicalMessage(
lexicalMessages.customTextColor
)
const customTextColorValueLabel = useLexicalMessage(
lexicalMessages.customTextColorValue
)
const translate = useTranslate()
const [open, setOpen] = useState(false)
const [customColor, setCustomColor] = useState("#000000")
const selectionRef = useRef<RangeSelection | null>(null)
const openedColorRef = useRef("#000000")
const color = context.editor.getEditorState().read(() => {
const selection = $getSelection()
return $isRangeSelection(selection)
? $getSelectionStyleValueForProperty(selection, "color", "#000000")
: "#000000"
})
const normalizedColor = normalizeHexColor(color) ?? "#000000"
const selectColor = (value: string) => {
applyTextColor(context.editor, value, selectionRef.current)
setOpen(false)
}
const applyCustomColor = (value: string) => {
const normalizedValue = normalizeHexColor(value)
if (!normalizedValue) return false
setCustomColor(normalizedValue)
applyTextColor(context.editor, normalizedValue, selectionRef.current)
return true
}
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen)
if (!nextOpen) return
const openedState = context.editor.getEditorState().read(() => {
const selection = $getSelection()
const openedColor = $isRangeSelection(selection)
? $getSelectionStyleValueForProperty(selection, "color", "#000000")
: "#000000"
return {
color: normalizeHexColor(openedColor) ?? "#000000",
selection: $isRangeSelection(selection) ? selection.clone() : null,
}
})
selectionRef.current = openedState.selection
openedColorRef.current = openedState.color
setCustomColor(openedState.color)
}}
>
<PopoverTrigger
render={
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={label}
title={label}
onMouseDown={(event) => event.preventDefault()}
/>
}
>
<span className="relative grid size-5 place-items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-baseline size-4.5"
aria-hidden="true"
>
<path d="M4 20h16" stroke={color}></path>
<path d="m6 16 6-12 6 12"></path>
<path d="M8 12h8"></path>
</svg>
</span>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto gap-3 rounded-xl p-3"
initialFocus={false}
showArrow
>
<PopoverTitle className="text-sm">{presetColorsLabel}</PopoverTitle>
<div className="grid grid-cols-7 gap-2">
{presetTextColors.map((presetColor) => {
const active = color.toLowerCase() === presetColor
return (
<button
key={presetColor}
type="button"
aria-label={translate(lexicalMessages.selectColor, {
color: presetColor,
})}
aria-pressed={active}
title={presetColor.toUpperCase()}
className={cn(
"size-7 rounded-md border border-foreground/10 transition-transform outline-none hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring",
active && "ring-2 ring-ring ring-offset-2 ring-offset-popover"
)}
style={{ backgroundColor: presetColor }}
onMouseDown={(event) => event.preventDefault()}
onClick={() => selectColor(presetColor)}
/>
)
})}
</div>
<div className="flex h-9 items-center gap-2 rounded-lg border px-2 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/50 hover:bg-muted">
<label className="relative grid size-6 shrink-0 cursor-pointer place-items-center rounded-md hover:bg-accent">
<Pipette className="size-4 text-muted-foreground" />
<input
type="color"
value={normalizeHexColor(customColor) ?? normalizedColor}
aria-label={textColorPickerLabel}
className="absolute inset-0 cursor-pointer opacity-0"
onInput={(event) => applyCustomColor(event.currentTarget.value)}
/>
</label>
<span>{customTextColorLabel}</span>
<input
type="text"
value={customColor}
aria-label={customTextColorValueLabel}
autoComplete="off"
spellCheck={false}
className="ml-auto h-7 w-24 rounded-md bg-background px-2 text-end font-mono text-xs text-foreground outline-none"
onBlur={() => {
if (!applyCustomColor(customColor))
setCustomColor(openedColorRef.current)
}}
onChange={(event) => {
setCustomColor(event.target.value)
}}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
if (applyCustomColor(customColor)) setOpen(false)
} else if (event.key === "Escape") {
event.preventDefault()
setCustomColor(openedColorRef.current)
setOpen(false)
}
}}
/>
</div>
</PopoverContent>
</Popover>
)
}
export const colorPickerAction: LexicalActionDefinition = {
name: "colorPicker",
label: lexicalMessages.colorPicker,
icon: Baseline,
control: ColorPickerControl,
execute: ({ editor }, value = "#000000") => {
applyTextColor(editor, value)
},
}
+89
View File
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { $generateHtmlFromNodes } from "@lexical/html"
import {
$createParagraphNode,
$getRoot,
createEditor,
type LexicalEditor,
} from "lexical"
import { describe, expect, it } from "vitest"
import {
$createDateNode,
$getSelectedDateNode,
$isDateNode,
DateNode,
} from ".."
import { dateAction } from "./date"
import type { LexicalActionContext } from "../types"
function createDateEditor(): LexicalEditor {
return createEditor({
namespace: "date-action-test",
nodes: [DateNode],
onError: (error) => {
throw error
},
})
}
function createActionContext(editor: LexicalEditor): LexicalActionContext {
return {
editor,
state: {
canRedo: false,
canUndo: false,
revision: 0,
},
}
}
describe("date action", () => {
it("serializes a date as a semantic time element", () => {
const editor = createDateEditor()
let html = ""
editor.update(
() => {
const dateNode = $createDateNode("2026-07-30", "2026年7月30日")
$getRoot().append($createParagraphNode().append(dateNode))
dateNode.select(0, dateNode.getTextContentSize())
html = $generateHtmlFromNodes(editor)
},
{ discrete: true }
)
const document = new DOMParser().parseFromString(html, "text/html")
const time = document.querySelector("time")
expect(time?.dateTime).toBe("2026-07-30")
expect(time?.dataset.lexicalDate).toBe("2026-07-30")
expect(time?.textContent).toBe("2026年7月30日")
expect(dateAction.isActive?.(createActionContext(editor))).toBe(true)
})
it("updates the selected date node instead of inserting a duplicate", () => {
const editor = createDateEditor()
const context = createActionContext(editor)
editor.update(
() => {
const dateNode = $createDateNode("2026-07-30", "2026年7月30日")
$getRoot().append($createParagraphNode().append(dateNode))
dateNode.select(0, dateNode.getTextContentSize())
},
{ discrete: true }
)
dateAction.execute(context, "2026-08-01")
editor.read(() => {
const dateNodes = $getRoot().getAllTextNodes().filter($isDateNode)
const dateNode = $getSelectedDateNode()
expect(dateNodes).toHaveLength(1)
expect(dateNode?.getDate()).toBe("2026-08-01")
})
})
})
+95
View File
@@ -0,0 +1,95 @@
import { CalendarDays } from "lucide-react"
import { Button } from "@workspace/ui/components/button"
import { DropdownMenuItem } from "@workspace/ui/components/dropdown-menu"
import { cn } from "@workspace/ui/lib/utils"
import {
$getSelectedDateNode,
$insertOrUpdateDate,
DateNode,
} from "../nodes/date-node"
import {
LexicalDatePopoverPlugin,
OPEN_DATE_POPOVER_COMMAND,
} from "../plugins/date-popover-plugin"
import { runWithEditorFocus } from "../plugins/selection-anchor"
import { formatDate, parseISODate, toISODate } from "../date-value"
import { lexicalMessages } from "../messages"
import type {
LexicalActionContext,
LexicalActionDefaultControlProps,
LexicalActionDefinition,
} from "../types"
function setDate(
context: LexicalActionContext,
value: string,
locale?: string
) {
const date = parseISODate(value)
if (!date) return
const text = formatDate(date, locale)
context.editor.update(() => {
$insertOrUpdateDate(value, text)
})
}
function DateControl({
active,
context,
disabled,
label,
presentation,
}: LexicalActionDefaultControlProps) {
if (presentation === "control") {
return (
<Button
type="button"
size="icon-sm"
variant={active ? "secondary" : "ghost"}
disabled={disabled}
aria-label={label}
aria-pressed={active}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
runWithEditorFocus(context.editor, () => {
context.editor.dispatchCommand(OPEN_DATE_POPOVER_COMMAND, undefined)
})
}}
>
<CalendarDays />
</Button>
)
}
return (
<DropdownMenuItem
disabled={disabled}
className={cn(active && "bg-accent text-accent-foreground")}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
runWithEditorFocus(context.editor, () => {
context.editor.dispatchCommand(OPEN_DATE_POPOVER_COMMAND, undefined)
})
}}
>
<CalendarDays />
<span>{label}</span>
</DropdownMenuItem>
)
}
export const dateAction: LexicalActionDefinition = {
name: "date",
label: lexicalMessages.date,
icon: CalendarDays,
nodes: [DateNode],
plugins: [LexicalDatePopoverPlugin],
control: DateControl,
execute: (context, value = toISODate(new Date())) => {
setDate(context, value)
},
isActive: ({ editor }) =>
editor.getEditorState().read(() => $getSelectedDateNode() !== null),
}
@@ -0,0 +1,85 @@
import {
$getSelectionStyleValueForProperty,
$patchStyleText,
} from "@lexical/selection"
import { ALargeSmall, ChevronDown } from "lucide-react"
import { $getSelection, $isRangeSelection } from "lexical"
import { Button } from "@workspace/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu"
import type {
LexicalActionDefaultControlProps,
LexicalActionDefinition,
} from "../types"
import { lexicalMessages } from "../messages"
const fontSizes = ["12px", "14px", "16px", "18px", "24px", "32px"] as const
function FontSizeControl({ context, label }: LexicalActionDefaultControlProps) {
const fontSize = context.editor.getEditorState().read(() => {
const selection = $getSelection()
return $isRangeSelection(selection)
? $getSelectionStyleValueForProperty(selection, "font-size", "16px")
: "16px"
})
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="sm"
variant="ghost"
aria-label={label}
className="w-fit justify-between px-2 font-normal"
onMouseDown={(event) => event.preventDefault()}
/>
}
>
<span>{fontSize}</span>
<ChevronDown className="opacity-60" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="w-fit min-w-auto"
showArrow
>
<DropdownMenuRadioGroup
value={fontSize}
onValueChange={(value) => fontSizeAction.execute(context, value)}
>
{fontSizes.map((size) => (
<DropdownMenuRadioItem
key={size}
value={size}
onMouseDown={(event) => event.preventDefault()}
>
{size}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export const fontSizeAction: LexicalActionDefinition = {
name: "fontSize",
label: lexicalMessages.fontSize,
icon: ALargeSmall,
control: FontSizeControl,
execute: ({ editor }, value = "16px") => {
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$patchStyleText(selection, { "font-size": value })
}
})
},
}
+27
View File
@@ -0,0 +1,27 @@
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
import { Redo2, Undo2 } from "lucide-react"
import { REDO_COMMAND, UNDO_COMMAND } from "lexical"
import type { LexicalActionDefinition } from "../types"
import { lexicalMessages } from "../messages"
export const undoAction: LexicalActionDefinition = {
name: "undo",
label: lexicalMessages.undo,
icon: Undo2,
plugins: [HistoryPlugin],
execute: ({ editor }) => {
editor.dispatchCommand(UNDO_COMMAND, undefined)
},
isDisabled: ({ state }) => !state.canUndo,
}
export const redoAction: LexicalActionDefinition = {
name: "redo",
label: lexicalMessages.redo,
icon: Redo2,
plugins: [HistoryPlugin],
execute: ({ editor }) => {
editor.dispatchCommand(REDO_COMMAND, undefined)
},
isDisabled: ({ state }) => !state.canRedo,
}
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import { $generateHtmlFromNodes } from "@lexical/html"
import { HorizontalRuleNode } from "@lexical/extension"
import { $createParagraphNode, $getRoot, createEditor } from "lexical"
import { describe, expect, it } from "vitest"
import { horizontalRuleAction } from "./horizontal-rule"
import type { LexicalActionContext } from "../types"
describe("horizontal rule action", () => {
it("inserts a semantic horizontal rule without the deprecated React plugin", () => {
const editor = createEditor({
namespace: "horizontal-rule-action-test",
nodes: [HorizontalRuleNode],
onError: (error) => {
throw error
},
})
const context: LexicalActionContext = {
editor,
state: {
canRedo: false,
canUndo: false,
revision: 0,
},
}
editor.update(
() => {
const paragraph = $createParagraphNode()
$getRoot().append(paragraph)
paragraph.selectEnd()
},
{ discrete: true }
)
horizontalRuleAction.execute(context)
editor.read(() => {
expect($generateHtmlFromNodes(editor)).toContain("<hr>")
})
})
})
@@ -0,0 +1,24 @@
import {
$createHorizontalRuleNode,
HorizontalRuleNode,
} from "@lexical/extension"
import { $insertNodeToNearestRoot } from "@lexical/utils"
import { Minus } from "lucide-react"
import { $getSelection, $isRangeSelection } from "lexical"
import type { LexicalActionDefinition } from "../types"
import { lexicalMessages } from "../messages"
export const horizontalRuleAction: LexicalActionDefinition = {
name: "horizontalRule",
label: lexicalMessages.horizontalRule,
icon: Minus,
nodes: [HorizontalRuleNode],
execute: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return
$insertNodeToNearestRoot($createHorizontalRuleNode())
})
},
}
+22
View File
@@ -0,0 +1,22 @@
import { IndentDecrease, IndentIncrease } from "lucide-react"
import { INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND } from "lexical"
import type { LexicalActionDefinition } from "../types"
import { lexicalMessages } from "../messages"
export const outdentAction: LexicalActionDefinition = {
name: "outdent",
label: lexicalMessages.outdent,
icon: IndentDecrease,
execute: ({ editor }) => {
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
},
}
export const indentAction: LexicalActionDefinition = {
name: "indent",
label: lexicalMessages.indent,
icon: IndentIncrease,
execute: ({ editor }) => {
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
},
}
+14
View File
@@ -0,0 +1,14 @@
export * from "./alignment"
export * from "./block"
export * from "./clear-formatting"
export * from "./clipboard-images"
export * from "./color-picker"
export * from "./date"
export * from "./font-size"
export * from "./history"
export * from "./horizontal-rule"
export * from "./indent"
export * from "./link"
export * from "./list"
export * from "./media"
export * from "./text-format"
+84
View File
@@ -0,0 +1,84 @@
import { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"
import { Link } from "lucide-react"
import { $findMatchingParent, $getSelection, $isRangeSelection } from "lexical"
import { Button } from "@workspace/ui/components/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
import {
LexicalLinkPopoverPlugin,
OPEN_LINK_POPOVER_COMMAND,
} from "../plugins/link-popover-plugin"
import { runWithEditorFocus } from "../plugins/selection-anchor"
import { lexicalMessages } from "../messages"
import type {
LexicalActionDefaultControlProps,
LexicalActionDefinition,
} from "../types"
function currentLinkUrl(
editor: Parameters<LexicalActionDefinition["execute"]>[0]["editor"]
) {
return editor.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return ""
return (
$findMatchingParent(selection.anchor.getNode(), $isLinkNode)?.getURL() ??
""
)
})
}
function LinkControl({
active,
context,
disabled,
label,
}: LexicalActionDefaultControlProps) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
size="icon-sm"
variant={active ? "secondary" : "ghost"}
disabled={disabled}
aria-label={label}
aria-pressed={active}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
runWithEditorFocus(context.editor, () => {
context.editor.dispatchCommand(
OPEN_LINK_POPOVER_COMMAND,
undefined
)
})
}}
/>
}
>
<Link />
</TooltipTrigger>
<TooltipContent showArrow>{label}</TooltipContent>
</Tooltip>
)
}
export const insertLinkAction: LexicalActionDefinition = {
name: "insertLink",
label: lexicalMessages.insertLink,
icon: Link,
nodes: [LinkNode],
plugins: [LinkPlugin, LexicalLinkPopoverPlugin],
control: LinkControl,
execute: ({ editor }, value) => {
if (value === undefined) return
editor.dispatchCommand(TOGGLE_LINK_COMMAND, value.trim() || null)
},
isActive: ({ editor }) => Boolean(currentLinkUrl(editor)),
}
+77
View File
@@ -0,0 +1,77 @@
import {
$isListNode,
INSERT_CHECK_LIST_COMMAND,
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
ListItemNode,
ListNode,
REMOVE_LIST_COMMAND,
} from "@lexical/list"
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"
import { ListPlugin } from "@lexical/react/LexicalListPlugin"
import { List, ListChecks, ListOrdered } from "lucide-react"
import { $getSelection, $isRangeSelection } from "lexical"
import type { LexicalCommand, LexicalEditor } from "lexical"
import type { LexicalActionDefinition, LexicalActionName } from "../types"
import type { LexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
type ListType = "number" | "bullet" | "check"
function isListActive(editor: LexicalEditor, type: ListType) {
return editor.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return false
const node = selection.anchor.getNode().getTopLevelElement()
return $isListNode(node) && node.getListType() === type
})
}
function listAction(
name: LexicalActionName,
label: LexicalMessage,
type: ListType,
command: LexicalCommand<void>,
icon: LexicalActionDefinition["icon"],
plugins: LexicalActionDefinition["plugins"] = [ListPlugin]
): LexicalActionDefinition {
const definition: LexicalActionDefinition = {
name,
label,
icon,
nodes: [ListNode, ListItemNode],
plugins,
execute: ({ editor }) => {
if (isListActive(editor, type)) {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
} else {
editor.dispatchCommand(command, undefined)
}
},
isActive: ({ editor }) => isListActive(editor, type),
}
return definition
}
export const orderedListAction = listAction(
"orderedList",
lexicalMessages.orderedList,
"number",
INSERT_ORDERED_LIST_COMMAND,
ListOrdered
)
export const bulletListAction = listAction(
"bulletList",
lexicalMessages.bulletList,
"bullet",
INSERT_UNORDERED_LIST_COMMAND,
List
)
export const checkListAction = listAction(
"checkList",
lexicalMessages.checkList,
"check",
INSERT_CHECK_LIST_COMMAND,
ListChecks,
[ListPlugin, CheckListPlugin]
)
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { $generateHtmlFromNodes } from "@lexical/html"
import { $getRoot, createEditor } from "lexical"
import { describe, expect, it } from "vitest"
import { $createLexicalMediaNode, LexicalMediaNode } from ".."
import { insertImageAction, insertVideoAction } from "./media"
describe("media actions", () => {
it("declares its node and plugin dependencies on each action", () => {
expect(
[insertImageAction, insertVideoAction].map((action) => action.name)
).toEqual(["insertImage", "insertVideo"])
expect(insertImageAction.nodes).toEqual([LexicalMediaNode])
expect(insertImageAction.plugins).toHaveLength(1)
})
it("serializes images and videos as portable native HTML", () => {
const editor = createEditor({
namespace: "media-action-test",
nodes: [LexicalMediaNode],
onError: (error) => {
throw error
},
})
let html = ""
editor.update(
() => {
$getRoot().append(
$createLexicalMediaNode({
alignment: "end",
alt: "山间日落",
caption: "旅行照片",
height: 180,
kind: "image",
src: "/media/sunset.jpg",
width: 320,
}),
$createLexicalMediaNode({
caption: "产品演示",
kind: "video",
poster: "/media/demo-poster.jpg",
src: "/media/demo.mp4",
})
)
html = $generateHtmlFromNodes(editor)
},
{ discrete: true }
)
const document = new DOMParser().parseFromString(html, "text/html")
const imageFigure = document.querySelector(
'figure[data-lexical-media-kind="image"]'
)
const videoFigure = document.querySelector(
'figure[data-lexical-media-kind="video"]'
)
expect(imageFigure?.querySelector("img")?.getAttribute("src")).toBe(
"/media/sunset.jpg"
)
expect(imageFigure?.querySelector("img")?.getAttribute("alt")).toBe(
"山间日落"
)
expect(imageFigure?.querySelector("img")?.getAttribute("width")).toBe("320")
expect(imageFigure?.querySelector("img")?.getAttribute("height")).toBe(
"180"
)
expect(imageFigure?.getAttribute("data-lexical-media-alignment")).toBe(
"end"
)
expect((imageFigure as HTMLElement | null)?.style.textAlign).toBe("end")
expect(imageFigure?.querySelector("figcaption")?.textContent).toBe(
"旅行照片"
)
expect(videoFigure?.querySelector("video")?.getAttribute("src")).toBe(
"/media/demo.mp4"
)
expect(videoFigure?.querySelector("video")?.getAttribute("poster")).toBe(
"/media/demo-poster.jpg"
)
expect(videoFigure?.querySelector("video")?.hasAttribute("controls")).toBe(
true
)
})
})
+70
View File
@@ -0,0 +1,70 @@
import { ImageIcon, VideoIcon } from "lucide-react"
import { $insertNodes } from "lexical"
import {
$createLexicalMediaNode,
LexicalMediaNode,
type LexicalMediaKind,
type LexicalMediaPayload,
} from "../nodes/media-node"
import {
LexicalMediaDialogPlugin,
OPEN_MEDIA_DIALOG_COMMAND,
} from "../plugins/media-dialog-plugin"
import { runWithEditorFocus } from "../plugins/selection-anchor"
import { lexicalMessages } from "../messages"
import type { LexicalActionDefinition } from "../types"
export type LexicalImageInput = Omit<LexicalMediaPayload, "kind" | "poster">
export type LexicalVideoInput = Omit<LexicalMediaPayload, "alt" | "kind">
function createMediaAction<
Value extends {
caption?: string
src: string
},
>(
kind: LexicalMediaKind,
definition: Pick<LexicalActionDefinition, "icon" | "label" | "name">
): LexicalActionDefinition<Value> {
return {
...definition,
group: "insert",
nodes: [LexicalMediaNode],
plugins: [LexicalMediaDialogPlugin],
execute: ({ editor }, value) => {
runWithEditorFocus(editor, () => {
if (!value) {
editor.dispatchCommand(OPEN_MEDIA_DIALOG_COMMAND, kind)
return
}
const src = value.src.trim()
if (!src) return
editor.update(() => {
$insertNodes([
$createLexicalMediaNode({
...value,
kind,
src,
}),
])
})
})
},
}
}
export const insertImageAction = createMediaAction<LexicalImageInput>("image", {
name: "insertImage",
label: lexicalMessages.insertImage,
icon: ImageIcon,
})
export const insertVideoAction = createMediaAction<LexicalVideoInput>("video", {
name: "insertVideo",
label: lexicalMessages.insertVideo,
icon: VideoIcon,
})
@@ -0,0 +1,92 @@
import {
Bold,
CaseLower,
CaseSensitive,
CaseUpper,
Italic,
Strikethrough,
Subscript,
Superscript,
Underline,
} from "lucide-react"
import { $getSelection, $isRangeSelection, FORMAT_TEXT_COMMAND } from "lexical"
import type { TextFormatType } from "lexical"
import type { LexicalActionDefinition, LexicalActionName } from "../types"
import type { LexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
function textFormatAction(
name: LexicalActionName,
label: LexicalMessage,
format: TextFormatType,
icon: LexicalActionDefinition["icon"]
): LexicalActionDefinition {
return {
name,
label,
icon,
execute: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format)
},
isActive: ({ editor }) =>
editor.getEditorState().read(() => {
const selection = $getSelection()
return $isRangeSelection(selection) && selection.hasFormat(format)
}),
}
}
export const boldAction = textFormatAction(
"bold",
lexicalMessages.bold,
"bold",
Bold
)
export const italicAction = textFormatAction(
"italic",
lexicalMessages.italic,
"italic",
Italic
)
export const underlineAction = textFormatAction(
"underline",
lexicalMessages.underline,
"underline",
Underline
)
export const lowercaseAction = textFormatAction(
"lowercase",
lexicalMessages.lowercase,
"lowercase",
CaseLower
)
export const uppercaseAction = textFormatAction(
"uppercase",
lexicalMessages.uppercase,
"uppercase",
CaseUpper
)
export const capitalizeAction = textFormatAction(
"capitalize",
lexicalMessages.capitalize,
"capitalize",
CaseSensitive
)
export const strikethroughAction = textFormatAction(
"strikethrough",
lexicalMessages.strikethrough,
"strikethrough",
Strikethrough
)
export const subscriptAction = textFormatAction(
"subscript",
lexicalMessages.subscript,
"subscript",
Subscript
)
export const superscriptAction = textFormatAction(
"superscript",
lexicalMessages.superscript,
"superscript",
Superscript
)