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:
@@ -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}>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "向右调整图片尺寸",
|
||||
|
||||
@@ -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,11 +210,51 @@ 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-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}
|
||||
@@ -176,6 +267,7 @@ export function LexicalMediaDialogPlugin() {
|
||||
/>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user