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
@@ -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>