feat(lexical): support local image data URLs

Allow the image insertion dialog to select a local image and embed it as a Base64 data URL, while retaining URL-based insertion and leaving video URL-only.

Extract shared abortable FileReader handling for clipboard uploads and dialog selection; add English and Simplified Chinese messages plus an interaction test covering file selection through insertion.
This commit is contained in:
Maofeng
2026-07-31 16:03:54 +08:00
parent cbf5ee784f
commit 263b400860
7 changed files with 274 additions and 87 deletions
@@ -19,6 +19,7 @@ import {
deleteImageUploadState,
setImageUploadState,
} from "../image-upload-store"
import { readFileAsDataUrl } from "../file-utils"
import {
$createLexicalMediaNode,
$isLexicalMediaNode,
@@ -65,70 +66,6 @@ function isClipboardEvent(
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,
@@ -143,7 +80,7 @@ async function resolveDefaultImage(
return {
alt: file.name,
src: await readFileAsDataUrl(file, signal, reportProgress),
src: await readFileAsDataUrl(file, { onProgress: reportProgress, signal }),
}
}
@@ -6,6 +6,7 @@ import {
render,
screen,
waitFor,
within,
} from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"
@@ -190,6 +191,52 @@ describe("Lexical advanced actions", () => {
expect((caption as HTMLTextAreaElement).value).toBe("Updated caption")
})
it("reads an image selected from the insert dialog as a Base64 data URL", async () => {
const { container } = render(
<LexicalRoot value="<p>before</p>" onChange={() => undefined}>
<LexicalActions>
<Image />
</LexicalActions>
<LexicalFixedToolbar />
<LexicalContent />
</LexicalRoot>
)
fireEvent.click(screen.getByRole("button", { name: "Insert image" }))
const dialog = await screen.findByRole("dialog")
const file = new File([new Uint8Array([137, 80, 78, 71])], "local.png", {
type: "image/png",
})
fireEvent.change(within(dialog).getByLabelText("Image file"), {
target: { files: [file] },
})
const urlInput = within(dialog).getByRole("textbox", {
name: "Image URL",
})
await waitFor(() => {
expect((urlInput as HTMLInputElement).value).toMatch(
/^data:image\/png;base64,/
)
})
fireEvent.change(
within(dialog).getByRole("textbox", { name: "Alternative text" }),
{
target: { value: "Local image" },
}
)
fireEvent.submit(dialog.querySelector("form")!)
await waitFor(() => {
expect(
screen.getByRole("img", { name: "Local image" }).getAttribute("src")
).toMatch(/^data:image\/png;base64,/)
})
expect(container.querySelector("figure")).not.toBeNull()
})
it("lets a custom Video control insert a video through execute", async () => {
const { container } = render(
<LexicalRoot value="<p>before</p>" onChange={() => undefined}>
+68
View File
@@ -0,0 +1,68 @@
export interface ReadFileAsDataUrlOptions {
onProgress?: (progress: number) => void
signal?: AbortSignal
}
export function readFileAsDataUrl(
file: File,
{ onProgress, signal }: ReadFileAsDataUrlOptions = {}
): Promise<string> {
return new Promise((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) {
onProgress?.(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)
}
})
}
+15 -4
View File
@@ -68,18 +68,29 @@ export const messages = {
"lexical.media.alignLeft": "Align image left",
"lexical.media.alignRight": "Align image right",
"lexical.media.caption": "Caption",
"lexical.media.chooseImageFile": "Choose image",
"lexical.media.controls": "Image controls",
"lexical.media.deleteImage": "Delete image",
"lexical.media.editCaption": "Edit caption",
"lexical.media.imageAlt": "Alternative text",
"lexical.media.imageAltDescription": "Leave blank for decorative images; provide it when the image conveys information.",
"lexical.media.imageDescription": "Enter a publicly accessible image URL and provide alternative text.",
"lexical.media.imageAltDescription":
"Leave blank for decorative images; provide it when the image conveys information.",
"lexical.media.imageDescription":
"Enter an image URL or choose a local image file, then provide alternative text.",
"lexical.media.imageFile": "Image file",
"lexical.media.imageFileDescription":
"Choose an image from your device to embed it as a Base64 data URL.",
"lexical.media.imageFileReadError":
"The image could not be read. Try selecting it again.",
"lexical.media.imageUrl": "Image URL",
"lexical.media.readingImageFile": "Reading image…",
"lexical.media.uploading": "Uploading image",
"lexical.media.uploadingProgress": "Uploading {progress}%",
"lexical.media.uploadProgress": "Image upload progress",
"lexical.media.urlDescription": "Supports absolute URLs and relative URLs within this site.",
"lexical.media.videoDescription": "Enter a publicly accessible video URL in a browser-supported format.",
"lexical.media.urlDescription":
"Supports absolute URLs and relative URLs within this site.",
"lexical.media.videoDescription":
"Enter a publicly accessible video URL in a browser-supported format.",
"lexical.media.videoPoster": "Video poster URL",
"lexical.media.videoUrl": "Video URL",
"lexical.resize.east": "Resize image to the right",
+12 -3
View File
@@ -68,18 +68,27 @@ export const messages = {
"lexical.media.alignLeft": "图片左对齐",
"lexical.media.alignRight": "图片右对齐",
"lexical.media.caption": "图片说明",
"lexical.media.chooseImageFile": "选择图片",
"lexical.media.controls": "图片操作",
"lexical.media.deleteImage": "删除图片",
"lexical.media.editCaption": "编辑图片说明",
"lexical.media.imageAlt": "替代文本",
"lexical.media.imageAltDescription": "纯装饰图片可以留空;有信息含义时请填写。",
"lexical.media.imageDescription": "输入可公开访问的图片地址,并补充替代文本。",
"lexical.media.imageAltDescription":
"纯装饰图片可以留空;有信息含义时请填写。",
"lexical.media.imageDescription":
"输入图片地址,或选择本地图片,并补充替代文本。",
"lexical.media.imageFile": "图片文件",
"lexical.media.imageFileDescription":
"从设备选择图片,将以 Base64 Data URL 内嵌到内容中。",
"lexical.media.imageFileReadError": "无法读取该图片,请重新选择。",
"lexical.media.imageUrl": "图片地址",
"lexical.media.readingImageFile": "正在读取图片…",
"lexical.media.uploading": "正在上传图片",
"lexical.media.uploadingProgress": "正在上传 {progress}%",
"lexical.media.uploadProgress": "图片上传进度",
"lexical.media.urlDescription": "支持绝对地址或站内相对地址。",
"lexical.media.videoDescription": "输入可公开访问的视频地址;支持浏览器可播放的格式。",
"lexical.media.videoDescription":
"输入可公开访问的视频地址;支持浏览器可播放的格式。",
"lexical.media.videoPoster": "视频封面地址",
"lexical.media.videoUrl": "视频地址",
"lexical.resize.east": "向右调整图片尺寸",
+22 -1
View File
@@ -135,7 +135,20 @@ export const lexicalMessages = {
imageDescription: /* i18n */ {
id: "lexical.media.imageDescription",
message:
"Enter a publicly accessible image URL and provide alternative text.",
"Enter an image URL or choose a local image file, then provide alternative text.",
},
imageFile: /* i18n */ {
id: "lexical.media.imageFile",
message: "Image file",
},
imageFileDescription: /* i18n */ {
id: "lexical.media.imageFileDescription",
message:
"Choose an image from your device to embed it as a Base64 data URL.",
},
imageFileReadError: /* i18n */ {
id: "lexical.media.imageFileReadError",
message: "The image could not be read. Try selecting it again.",
},
imageControls: /* i18n */ {
id: "lexical.media.controls",
@@ -145,6 +158,10 @@ export const lexicalMessages = {
id: "lexical.media.imageUrl",
message: "Image URL",
},
chooseImageFile: /* i18n */ {
id: "lexical.media.chooseImageFile",
message: "Choose image",
},
indent: /* i18n */ {
id: "lexical.actions.indent",
message: "Increase indent",
@@ -210,6 +227,10 @@ export const lexicalMessages = {
},
quote: /* i18n */ { id: "lexical.actions.quote", message: "Quote" },
redo: /* i18n */ { id: "lexical.actions.redo", message: "Redo" },
readingImageFile: /* i18n */ {
id: "lexical.media.readingImageFile",
message: "Reading image…",
},
removeLink: /* i18n */ {
id: "lexical.link.remove",
message: "Remove link",
@@ -26,6 +26,7 @@ import {
} from "@workspace/ui/components/field"
import { Input } from "@workspace/ui/components/input"
import { readFileAsDataUrl } from "../file-utils"
import { useLexicalMessage } from "../i18n"
import { lexicalMessages } from "../messages"
import {
@@ -51,10 +52,28 @@ export function LexicalMediaDialogPlugin() {
const [src, setSrc] = React.useState("")
const [alt, setAlt] = React.useState("")
const [caption, setCaption] = React.useState("")
const [imageFileName, setImageFileName] = React.useState("")
const [imageFileError, setImageFileError] = React.useState(false)
const [isReadingImageFile, setIsReadingImageFile] = React.useState(false)
const [poster, setPoster] = React.useState("")
const imageFileInputRef = React.useRef<HTMLInputElement>(null)
const imageFileReadId = React.useRef(0)
const imageDescription = useLexicalMessage(lexicalMessages.imageDescription)
const videoDescription = useLexicalMessage(lexicalMessages.videoDescription)
const imageUrlLabel = useLexicalMessage(lexicalMessages.imageUrl)
const imageFileLabel = useLexicalMessage(lexicalMessages.imageFile)
const imageFileDescription = useLexicalMessage(
lexicalMessages.imageFileDescription
)
const chooseImageFileLabel = useLexicalMessage(
lexicalMessages.chooseImageFile
)
const readingImageFileLabel = useLexicalMessage(
lexicalMessages.readingImageFile
)
const imageFileReadErrorLabel = useLexicalMessage(
lexicalMessages.imageFileReadError
)
const videoUrlLabel = useLexicalMessage(lexicalMessages.videoUrl)
const urlDescription = useLexicalMessage(lexicalMessages.urlDescription)
const imageAltLabel = useLexicalMessage(lexicalMessages.imageAlt)
@@ -83,7 +102,12 @@ export function LexicalMediaDialogPlugin() {
setSrc("")
setAlt("")
setCaption("")
setImageFileName("")
setImageFileError(false)
setIsReadingImageFile(false)
setPoster("")
imageFileReadId.current += 1
if (imageFileInputRef.current) imageFileInputRef.current.value = ""
setPendingMedia({ kind, selection })
return true
},
@@ -93,10 +117,37 @@ export function LexicalMediaDialogPlugin() {
)
const closeDialog = () => {
imageFileReadId.current += 1
setPendingMedia(null)
editor.focus()
}
const selectImageFile = () => imageFileInputRef.current?.click()
const readImageFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
const readId = imageFileReadId.current + 1
imageFileReadId.current = readId
setImageFileName(file.name)
setImageFileError(false)
setIsReadingImageFile(true)
setSrc("")
try {
const dataUrl = await readFileAsDataUrl(file)
if (imageFileReadId.current === readId) setSrc(dataUrl)
} catch {
if (imageFileReadId.current === readId) {
setImageFileError(true)
setImageFileName("")
}
} finally {
if (imageFileReadId.current === readId) setIsReadingImageFile(false)
}
}
const insertMedia = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!pendingMedia) return
@@ -159,23 +210,64 @@ export function LexicalMediaDialogPlugin() {
}
required
value={src}
onChange={(event) => setSrc(event.target.value)}
onChange={(event) => {
imageFileReadId.current += 1
setImageFileError(false)
setImageFileName("")
setIsReadingImageFile(false)
setSrc(event.target.value)
}}
/>
<FieldDescription>{urlDescription}</FieldDescription>
</Field>
{isImage ? (
<Field>
<FieldLabel htmlFor="lexical-media-alt">
{imageAltLabel}
</FieldLabel>
<Input
id="lexical-media-alt"
placeholder={imageAltLabel}
value={alt}
onChange={(event) => setAlt(event.target.value)}
/>
<FieldDescription>{imageAltDescription}</FieldDescription>
</Field>
<>
<Field>
<FieldLabel htmlFor="lexical-image-file">
{imageFileLabel}
</FieldLabel>
<input
ref={imageFileInputRef}
className="sr-only"
id="lexical-image-file"
type="file"
accept="image/*"
onChange={readImageFile}
/>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
disabled={isReadingImageFile}
onClick={selectImageFile}
>
{chooseImageFileLabel}
</Button>
<span className="truncate text-sm text-muted-foreground">
{isReadingImageFile
? readingImageFileLabel
: imageFileName || imageFileDescription}
</span>
</div>
{imageFileError && (
<FieldDescription className="text-destructive">
{imageFileReadErrorLabel}
</FieldDescription>
)}
</Field>
<Field>
<FieldLabel htmlFor="lexical-media-alt">
{imageAltLabel}
</FieldLabel>
<Input
id="lexical-media-alt"
placeholder={imageAltLabel}
value={alt}
onChange={(event) => setAlt(event.target.value)}
/>
<FieldDescription>{imageAltDescription}</FieldDescription>
</Field>
</>
) : (
<Field>
<FieldLabel htmlFor="lexical-media-poster">
@@ -206,7 +298,9 @@ export function LexicalMediaDialogPlugin() {
<Button type="button" variant="outline" onClick={closeDialog}>
{cancelLabel}
</Button>
<Button type="submit">{title}</Button>
<Button type="submit" disabled={isReadingImageFile}>
{title}
</Button>
</DialogFooter>
</form>
</DialogContent>