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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user