From 263b40086013d0af7d4fbf83fe51fa9d570e24db Mon Sep 17 00:00:00 2001 From: Maofeng Date: Fri, 31 Jul 2026 16:03:54 +0800 Subject: [PATCH] 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. --- .../lexical/src/actions/clipboard-images.tsx | 67 +--------- .../lexical/src/advanced-features.test.tsx | 47 +++++++ packages/lexical/src/file-utils.ts | 68 ++++++++++ packages/lexical/src/locales/en.ts | 19 ++- packages/lexical/src/locales/zh-Hans.ts | 15 ++- packages/lexical/src/messages.ts | 23 +++- .../src/plugins/media-dialog-plugin.tsx | 122 ++++++++++++++++-- 7 files changed, 274 insertions(+), 87 deletions(-) create mode 100644 packages/lexical/src/file-utils.ts diff --git a/packages/lexical/src/actions/clipboard-images.tsx b/packages/lexical/src/actions/clipboard-images.tsx index 39fac5b..71132ac 100644 --- a/packages/lexical/src/actions/clipboard-images.tsx +++ b/packages/lexical/src/actions/clipboard-images.tsx @@ -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((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 }), } } diff --git a/packages/lexical/src/advanced-features.test.tsx b/packages/lexical/src/advanced-features.test.tsx index c9eb155..602050c 100644 --- a/packages/lexical/src/advanced-features.test.tsx +++ b/packages/lexical/src/advanced-features.test.tsx @@ -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( + undefined}> + + + + + + + ) + + 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( undefined}> diff --git a/packages/lexical/src/file-utils.ts b/packages/lexical/src/file-utils.ts new file mode 100644 index 0000000..335e80c --- /dev/null +++ b/packages/lexical/src/file-utils.ts @@ -0,0 +1,68 @@ +export interface ReadFileAsDataUrlOptions { + onProgress?: (progress: number) => void + signal?: AbortSignal +} + +export function readFileAsDataUrl( + file: File, + { onProgress, signal }: ReadFileAsDataUrlOptions = {} +): Promise { + 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) + } + }) +} diff --git a/packages/lexical/src/locales/en.ts b/packages/lexical/src/locales/en.ts index e03cc4e..138f38f 100644 --- a/packages/lexical/src/locales/en.ts +++ b/packages/lexical/src/locales/en.ts @@ -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", diff --git a/packages/lexical/src/locales/zh-Hans.ts b/packages/lexical/src/locales/zh-Hans.ts index 6b9cedd..1d21f81 100644 --- a/packages/lexical/src/locales/zh-Hans.ts +++ b/packages/lexical/src/locales/zh-Hans.ts @@ -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": "向右调整图片尺寸", diff --git a/packages/lexical/src/messages.ts b/packages/lexical/src/messages.ts index 6c3a2f9..c92589e 100644 --- a/packages/lexical/src/messages.ts +++ b/packages/lexical/src/messages.ts @@ -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", diff --git a/packages/lexical/src/plugins/media-dialog-plugin.tsx b/packages/lexical/src/plugins/media-dialog-plugin.tsx index eff9a78..e9ceae9 100644 --- a/packages/lexical/src/plugins/media-dialog-plugin.tsx +++ b/packages/lexical/src/plugins/media-dialog-plugin.tsx @@ -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(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) => { + 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) => { 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) + }} /> {urlDescription} {isImage ? ( - - - {imageAltLabel} - - setAlt(event.target.value)} - /> - {imageAltDescription} - + <> + + + {imageFileLabel} + + +
+ + + {isReadingImageFile + ? readingImageFileLabel + : imageFileName || imageFileDescription} + +
+ {imageFileError && ( + + {imageFileReadErrorLabel} + + )} +
+ + + {imageAltLabel} + + setAlt(event.target.value)} + /> + {imageAltDescription} + + ) : ( @@ -206,7 +298,9 @@ export function LexicalMediaDialogPlugin() { - +