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,
|
deleteImageUploadState,
|
||||||
setImageUploadState,
|
setImageUploadState,
|
||||||
} from "../image-upload-store"
|
} from "../image-upload-store"
|
||||||
|
import { readFileAsDataUrl } from "../file-utils"
|
||||||
import {
|
import {
|
||||||
$createLexicalMediaNode,
|
$createLexicalMediaNode,
|
||||||
$isLexicalMediaNode,
|
$isLexicalMediaNode,
|
||||||
@@ -65,70 +66,6 @@ function isClipboardEvent(
|
|||||||
return "clipboardData" in event && event.clipboardData != null
|
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(
|
async function resolveDefaultImage(
|
||||||
file: File,
|
file: File,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
@@ -143,7 +80,7 @@ async function resolveDefaultImage(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
alt: file.name,
|
alt: file.name,
|
||||||
src: await readFileAsDataUrl(file, signal, reportProgress),
|
src: await readFileAsDataUrl(file, { onProgress: reportProgress, signal }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
|
within,
|
||||||
} from "@testing-library/react"
|
} from "@testing-library/react"
|
||||||
import { afterEach, describe, expect, it } from "vitest"
|
import { afterEach, describe, expect, it } from "vitest"
|
||||||
|
|
||||||
@@ -190,6 +191,52 @@ describe("Lexical advanced actions", () => {
|
|||||||
expect((caption as HTMLTextAreaElement).value).toBe("Updated caption")
|
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 () => {
|
it("lets a custom Video control insert a video through execute", async () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<LexicalRoot value="<p>before</p>" onChange={() => undefined}>
|
<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.alignLeft": "Align image left",
|
||||||
"lexical.media.alignRight": "Align image right",
|
"lexical.media.alignRight": "Align image right",
|
||||||
"lexical.media.caption": "Caption",
|
"lexical.media.caption": "Caption",
|
||||||
|
"lexical.media.chooseImageFile": "Choose image",
|
||||||
"lexical.media.controls": "Image controls",
|
"lexical.media.controls": "Image controls",
|
||||||
"lexical.media.deleteImage": "Delete image",
|
"lexical.media.deleteImage": "Delete image",
|
||||||
"lexical.media.editCaption": "Edit caption",
|
"lexical.media.editCaption": "Edit caption",
|
||||||
"lexical.media.imageAlt": "Alternative text",
|
"lexical.media.imageAlt": "Alternative text",
|
||||||
"lexical.media.imageAltDescription": "Leave blank for decorative images; provide it when the image conveys information.",
|
"lexical.media.imageAltDescription":
|
||||||
"lexical.media.imageDescription": "Enter a publicly accessible image URL and provide alternative text.",
|
"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.imageUrl": "Image URL",
|
||||||
|
"lexical.media.readingImageFile": "Reading image…",
|
||||||
"lexical.media.uploading": "Uploading image",
|
"lexical.media.uploading": "Uploading image",
|
||||||
"lexical.media.uploadingProgress": "Uploading {progress}%",
|
"lexical.media.uploadingProgress": "Uploading {progress}%",
|
||||||
"lexical.media.uploadProgress": "Image upload progress",
|
"lexical.media.uploadProgress": "Image upload progress",
|
||||||
"lexical.media.urlDescription": "Supports absolute URLs and relative URLs within this site.",
|
"lexical.media.urlDescription":
|
||||||
"lexical.media.videoDescription": "Enter a publicly accessible video URL in a browser-supported format.",
|
"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.videoPoster": "Video poster URL",
|
||||||
"lexical.media.videoUrl": "Video URL",
|
"lexical.media.videoUrl": "Video URL",
|
||||||
"lexical.resize.east": "Resize image to the right",
|
"lexical.resize.east": "Resize image to the right",
|
||||||
|
|||||||
@@ -68,18 +68,27 @@ export const messages = {
|
|||||||
"lexical.media.alignLeft": "图片左对齐",
|
"lexical.media.alignLeft": "图片左对齐",
|
||||||
"lexical.media.alignRight": "图片右对齐",
|
"lexical.media.alignRight": "图片右对齐",
|
||||||
"lexical.media.caption": "图片说明",
|
"lexical.media.caption": "图片说明",
|
||||||
|
"lexical.media.chooseImageFile": "选择图片",
|
||||||
"lexical.media.controls": "图片操作",
|
"lexical.media.controls": "图片操作",
|
||||||
"lexical.media.deleteImage": "删除图片",
|
"lexical.media.deleteImage": "删除图片",
|
||||||
"lexical.media.editCaption": "编辑图片说明",
|
"lexical.media.editCaption": "编辑图片说明",
|
||||||
"lexical.media.imageAlt": "替代文本",
|
"lexical.media.imageAlt": "替代文本",
|
||||||
"lexical.media.imageAltDescription": "纯装饰图片可以留空;有信息含义时请填写。",
|
"lexical.media.imageAltDescription":
|
||||||
"lexical.media.imageDescription": "输入可公开访问的图片地址,并补充替代文本。",
|
"纯装饰图片可以留空;有信息含义时请填写。",
|
||||||
|
"lexical.media.imageDescription":
|
||||||
|
"输入图片地址,或选择本地图片,并补充替代文本。",
|
||||||
|
"lexical.media.imageFile": "图片文件",
|
||||||
|
"lexical.media.imageFileDescription":
|
||||||
|
"从设备选择图片,将以 Base64 Data URL 内嵌到内容中。",
|
||||||
|
"lexical.media.imageFileReadError": "无法读取该图片,请重新选择。",
|
||||||
"lexical.media.imageUrl": "图片地址",
|
"lexical.media.imageUrl": "图片地址",
|
||||||
|
"lexical.media.readingImageFile": "正在读取图片…",
|
||||||
"lexical.media.uploading": "正在上传图片",
|
"lexical.media.uploading": "正在上传图片",
|
||||||
"lexical.media.uploadingProgress": "正在上传 {progress}%",
|
"lexical.media.uploadingProgress": "正在上传 {progress}%",
|
||||||
"lexical.media.uploadProgress": "图片上传进度",
|
"lexical.media.uploadProgress": "图片上传进度",
|
||||||
"lexical.media.urlDescription": "支持绝对地址或站内相对地址。",
|
"lexical.media.urlDescription": "支持绝对地址或站内相对地址。",
|
||||||
"lexical.media.videoDescription": "输入可公开访问的视频地址;支持浏览器可播放的格式。",
|
"lexical.media.videoDescription":
|
||||||
|
"输入可公开访问的视频地址;支持浏览器可播放的格式。",
|
||||||
"lexical.media.videoPoster": "视频封面地址",
|
"lexical.media.videoPoster": "视频封面地址",
|
||||||
"lexical.media.videoUrl": "视频地址",
|
"lexical.media.videoUrl": "视频地址",
|
||||||
"lexical.resize.east": "向右调整图片尺寸",
|
"lexical.resize.east": "向右调整图片尺寸",
|
||||||
|
|||||||
@@ -135,7 +135,20 @@ export const lexicalMessages = {
|
|||||||
imageDescription: /* i18n */ {
|
imageDescription: /* i18n */ {
|
||||||
id: "lexical.media.imageDescription",
|
id: "lexical.media.imageDescription",
|
||||||
message:
|
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 */ {
|
imageControls: /* i18n */ {
|
||||||
id: "lexical.media.controls",
|
id: "lexical.media.controls",
|
||||||
@@ -145,6 +158,10 @@ export const lexicalMessages = {
|
|||||||
id: "lexical.media.imageUrl",
|
id: "lexical.media.imageUrl",
|
||||||
message: "Image URL",
|
message: "Image URL",
|
||||||
},
|
},
|
||||||
|
chooseImageFile: /* i18n */ {
|
||||||
|
id: "lexical.media.chooseImageFile",
|
||||||
|
message: "Choose image",
|
||||||
|
},
|
||||||
indent: /* i18n */ {
|
indent: /* i18n */ {
|
||||||
id: "lexical.actions.indent",
|
id: "lexical.actions.indent",
|
||||||
message: "Increase indent",
|
message: "Increase indent",
|
||||||
@@ -210,6 +227,10 @@ export const lexicalMessages = {
|
|||||||
},
|
},
|
||||||
quote: /* i18n */ { id: "lexical.actions.quote", message: "Quote" },
|
quote: /* i18n */ { id: "lexical.actions.quote", message: "Quote" },
|
||||||
redo: /* i18n */ { id: "lexical.actions.redo", message: "Redo" },
|
redo: /* i18n */ { id: "lexical.actions.redo", message: "Redo" },
|
||||||
|
readingImageFile: /* i18n */ {
|
||||||
|
id: "lexical.media.readingImageFile",
|
||||||
|
message: "Reading image…",
|
||||||
|
},
|
||||||
removeLink: /* i18n */ {
|
removeLink: /* i18n */ {
|
||||||
id: "lexical.link.remove",
|
id: "lexical.link.remove",
|
||||||
message: "Remove link",
|
message: "Remove link",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "@workspace/ui/components/field"
|
} from "@workspace/ui/components/field"
|
||||||
import { Input } from "@workspace/ui/components/input"
|
import { Input } from "@workspace/ui/components/input"
|
||||||
|
|
||||||
|
import { readFileAsDataUrl } from "../file-utils"
|
||||||
import { useLexicalMessage } from "../i18n"
|
import { useLexicalMessage } from "../i18n"
|
||||||
import { lexicalMessages } from "../messages"
|
import { lexicalMessages } from "../messages"
|
||||||
import {
|
import {
|
||||||
@@ -51,10 +52,28 @@ export function LexicalMediaDialogPlugin() {
|
|||||||
const [src, setSrc] = React.useState("")
|
const [src, setSrc] = React.useState("")
|
||||||
const [alt, setAlt] = React.useState("")
|
const [alt, setAlt] = React.useState("")
|
||||||
const [caption, setCaption] = 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 [poster, setPoster] = React.useState("")
|
||||||
|
const imageFileInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
const imageFileReadId = React.useRef(0)
|
||||||
const imageDescription = useLexicalMessage(lexicalMessages.imageDescription)
|
const imageDescription = useLexicalMessage(lexicalMessages.imageDescription)
|
||||||
const videoDescription = useLexicalMessage(lexicalMessages.videoDescription)
|
const videoDescription = useLexicalMessage(lexicalMessages.videoDescription)
|
||||||
const imageUrlLabel = useLexicalMessage(lexicalMessages.imageUrl)
|
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 videoUrlLabel = useLexicalMessage(lexicalMessages.videoUrl)
|
||||||
const urlDescription = useLexicalMessage(lexicalMessages.urlDescription)
|
const urlDescription = useLexicalMessage(lexicalMessages.urlDescription)
|
||||||
const imageAltLabel = useLexicalMessage(lexicalMessages.imageAlt)
|
const imageAltLabel = useLexicalMessage(lexicalMessages.imageAlt)
|
||||||
@@ -83,7 +102,12 @@ export function LexicalMediaDialogPlugin() {
|
|||||||
setSrc("")
|
setSrc("")
|
||||||
setAlt("")
|
setAlt("")
|
||||||
setCaption("")
|
setCaption("")
|
||||||
|
setImageFileName("")
|
||||||
|
setImageFileError(false)
|
||||||
|
setIsReadingImageFile(false)
|
||||||
setPoster("")
|
setPoster("")
|
||||||
|
imageFileReadId.current += 1
|
||||||
|
if (imageFileInputRef.current) imageFileInputRef.current.value = ""
|
||||||
setPendingMedia({ kind, selection })
|
setPendingMedia({ kind, selection })
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
@@ -93,10 +117,37 @@ export function LexicalMediaDialogPlugin() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
|
imageFileReadId.current += 1
|
||||||
setPendingMedia(null)
|
setPendingMedia(null)
|
||||||
editor.focus()
|
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>) => {
|
const insertMedia = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!pendingMedia) return
|
if (!pendingMedia) return
|
||||||
@@ -159,23 +210,64 @@ export function LexicalMediaDialogPlugin() {
|
|||||||
}
|
}
|
||||||
required
|
required
|
||||||
value={src}
|
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>
|
<FieldDescription>{urlDescription}</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
<Field>
|
<>
|
||||||
<FieldLabel htmlFor="lexical-media-alt">
|
<Field>
|
||||||
{imageAltLabel}
|
<FieldLabel htmlFor="lexical-image-file">
|
||||||
</FieldLabel>
|
{imageFileLabel}
|
||||||
<Input
|
</FieldLabel>
|
||||||
id="lexical-media-alt"
|
<input
|
||||||
placeholder={imageAltLabel}
|
ref={imageFileInputRef}
|
||||||
value={alt}
|
className="sr-only"
|
||||||
onChange={(event) => setAlt(event.target.value)}
|
id="lexical-image-file"
|
||||||
/>
|
type="file"
|
||||||
<FieldDescription>{imageAltDescription}</FieldDescription>
|
accept="image/*"
|
||||||
</Field>
|
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>
|
<Field>
|
||||||
<FieldLabel htmlFor="lexical-media-poster">
|
<FieldLabel htmlFor="lexical-media-poster">
|
||||||
@@ -206,7 +298,9 @@ export function LexicalMediaDialogPlugin() {
|
|||||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">{title}</Button>
|
<Button type="submit" disabled={isReadingImageFile}>
|
||||||
|
{title}
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user