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,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
|
||||
)
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)),
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user