2026-07-31 15:07:09 +08:00
|
|
|
import * as React from "react"
|
|
|
|
|
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
|
|
|
|
import {
|
|
|
|
|
$getSelection,
|
|
|
|
|
$insertNodes,
|
|
|
|
|
$isRangeSelection,
|
|
|
|
|
$setSelection,
|
|
|
|
|
COMMAND_PRIORITY_EDITOR,
|
|
|
|
|
createCommand,
|
|
|
|
|
} from "lexical"
|
|
|
|
|
import type { RangeSelection } from "lexical"
|
|
|
|
|
import { Button } from "@workspace/ui/components/button"
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from "@workspace/ui/components/dialog"
|
|
|
|
|
import {
|
|
|
|
|
Field,
|
|
|
|
|
FieldDescription,
|
|
|
|
|
FieldGroup,
|
|
|
|
|
FieldLabel,
|
|
|
|
|
} from "@workspace/ui/components/field"
|
|
|
|
|
import { Input } from "@workspace/ui/components/input"
|
|
|
|
|
|
2026-07-31 16:03:54 +08:00
|
|
|
import { readFileAsDataUrl } from "../file-utils"
|
2026-07-31 15:07:09 +08:00
|
|
|
import { useLexicalMessage } from "../i18n"
|
|
|
|
|
import { lexicalMessages } from "../messages"
|
|
|
|
|
import {
|
|
|
|
|
$createLexicalMediaNode,
|
|
|
|
|
type LexicalMediaKind,
|
|
|
|
|
type LexicalMediaPayload,
|
|
|
|
|
} from "../nodes/media-node"
|
|
|
|
|
|
|
|
|
|
interface PendingMedia {
|
|
|
|
|
kind: LexicalMediaKind
|
|
|
|
|
selection: RangeSelection
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const OPEN_MEDIA_DIALOG_COMMAND = createCommand<LexicalMediaKind>(
|
|
|
|
|
"OPEN_MEDIA_DIALOG_COMMAND"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
export function LexicalMediaDialogPlugin() {
|
|
|
|
|
const [editor] = useLexicalComposerContext()
|
|
|
|
|
const [pendingMedia, setPendingMedia] = React.useState<PendingMedia | null>(
|
|
|
|
|
null
|
|
|
|
|
)
|
|
|
|
|
const [src, setSrc] = React.useState("")
|
|
|
|
|
const [alt, setAlt] = React.useState("")
|
|
|
|
|
const [caption, setCaption] = React.useState("")
|
2026-07-31 16:03:54 +08:00
|
|
|
const [imageFileName, setImageFileName] = React.useState("")
|
|
|
|
|
const [imageFileError, setImageFileError] = React.useState(false)
|
|
|
|
|
const [isReadingImageFile, setIsReadingImageFile] = React.useState(false)
|
2026-07-31 15:07:09 +08:00
|
|
|
const [poster, setPoster] = React.useState("")
|
2026-07-31 16:03:54 +08:00
|
|
|
const imageFileInputRef = React.useRef<HTMLInputElement>(null)
|
|
|
|
|
const imageFileReadId = React.useRef(0)
|
2026-07-31 15:07:09 +08:00
|
|
|
const imageDescription = useLexicalMessage(lexicalMessages.imageDescription)
|
|
|
|
|
const videoDescription = useLexicalMessage(lexicalMessages.videoDescription)
|
|
|
|
|
const imageUrlLabel = useLexicalMessage(lexicalMessages.imageUrl)
|
2026-07-31 16:03:54 +08:00
|
|
|
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
|
|
|
|
|
)
|
2026-07-31 15:07:09 +08:00
|
|
|
const videoUrlLabel = useLexicalMessage(lexicalMessages.videoUrl)
|
|
|
|
|
const urlDescription = useLexicalMessage(lexicalMessages.urlDescription)
|
|
|
|
|
const imageAltLabel = useLexicalMessage(lexicalMessages.imageAlt)
|
|
|
|
|
const imageAltDescription = useLexicalMessage(
|
|
|
|
|
lexicalMessages.imageAltDescription
|
|
|
|
|
)
|
|
|
|
|
const videoPosterLabel = useLexicalMessage(lexicalMessages.videoPoster)
|
|
|
|
|
const captionLabel = useLexicalMessage(lexicalMessages.caption)
|
|
|
|
|
const cancelLabel = useLexicalMessage(lexicalMessages.cancel)
|
|
|
|
|
const optionalLabel = useLexicalMessage(lexicalMessages.optional)
|
|
|
|
|
|
|
|
|
|
React.useEffect(
|
|
|
|
|
() =>
|
|
|
|
|
editor.registerCommand(
|
|
|
|
|
OPEN_MEDIA_DIALOG_COMMAND,
|
|
|
|
|
(kind) => {
|
|
|
|
|
const selection = editor.getEditorState().read(() => {
|
|
|
|
|
const currentSelection = $getSelection()
|
|
|
|
|
return $isRangeSelection(currentSelection)
|
|
|
|
|
? currentSelection.clone()
|
|
|
|
|
: null
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!selection) return false
|
|
|
|
|
|
|
|
|
|
setSrc("")
|
|
|
|
|
setAlt("")
|
|
|
|
|
setCaption("")
|
2026-07-31 16:03:54 +08:00
|
|
|
setImageFileName("")
|
|
|
|
|
setImageFileError(false)
|
|
|
|
|
setIsReadingImageFile(false)
|
2026-07-31 15:07:09 +08:00
|
|
|
setPoster("")
|
2026-07-31 16:03:54 +08:00
|
|
|
imageFileReadId.current += 1
|
|
|
|
|
if (imageFileInputRef.current) imageFileInputRef.current.value = ""
|
2026-07-31 15:07:09 +08:00
|
|
|
setPendingMedia({ kind, selection })
|
|
|
|
|
return true
|
|
|
|
|
},
|
|
|
|
|
COMMAND_PRIORITY_EDITOR
|
|
|
|
|
),
|
|
|
|
|
[editor]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const closeDialog = () => {
|
2026-07-31 16:03:54 +08:00
|
|
|
imageFileReadId.current += 1
|
2026-07-31 15:07:09 +08:00
|
|
|
setPendingMedia(null)
|
|
|
|
|
editor.focus()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-31 16:03:54 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-31 15:07:09 +08:00
|
|
|
const insertMedia = (event: React.FormEvent<HTMLFormElement>) => {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
if (!pendingMedia) return
|
|
|
|
|
|
|
|
|
|
const normalizedSrc = src.trim()
|
|
|
|
|
if (!normalizedSrc) return
|
|
|
|
|
|
|
|
|
|
const payload: LexicalMediaPayload = {
|
|
|
|
|
alt: pendingMedia.kind === "image" ? alt.trim() || undefined : undefined,
|
|
|
|
|
caption: caption.trim() || undefined,
|
|
|
|
|
kind: pendingMedia.kind,
|
|
|
|
|
poster:
|
|
|
|
|
pendingMedia.kind === "video" ? poster.trim() || undefined : undefined,
|
|
|
|
|
src: normalizedSrc,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
editor.update(() => {
|
|
|
|
|
$setSelection(pendingMedia.selection.clone())
|
|
|
|
|
$insertNodes([$createLexicalMediaNode(payload)])
|
|
|
|
|
})
|
|
|
|
|
closeDialog()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isImage = pendingMedia?.kind === "image"
|
|
|
|
|
const title = useLexicalMessage(
|
|
|
|
|
isImage ? lexicalMessages.insertImage : lexicalMessages.insertVideo
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog
|
|
|
|
|
open={pendingMedia !== null}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open && pendingMedia) closeDialog()
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DialogContent
|
|
|
|
|
finalFocus={() => editor.getRootElement()}
|
|
|
|
|
showCloseButton={false}
|
|
|
|
|
>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{title}</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
{isImage ? imageDescription : videoDescription}
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<form className="contents" onSubmit={insertMedia}>
|
|
|
|
|
<FieldGroup className="gap-4">
|
|
|
|
|
<Field>
|
|
|
|
|
<FieldLabel htmlFor="lexical-media-src">
|
|
|
|
|
{isImage ? imageUrlLabel : videoUrlLabel}
|
|
|
|
|
</FieldLabel>
|
|
|
|
|
<Input
|
|
|
|
|
autoFocus
|
|
|
|
|
id="lexical-media-src"
|
|
|
|
|
inputMode="url"
|
|
|
|
|
placeholder={
|
|
|
|
|
isImage
|
|
|
|
|
? "https://example.com/image.jpg"
|
|
|
|
|
: "https://example.com/video.mp4"
|
|
|
|
|
}
|
|
|
|
|
required
|
|
|
|
|
value={src}
|
2026-07-31 16:03:54 +08:00
|
|
|
onChange={(event) => {
|
|
|
|
|
imageFileReadId.current += 1
|
|
|
|
|
setImageFileError(false)
|
|
|
|
|
setImageFileName("")
|
|
|
|
|
setIsReadingImageFile(false)
|
|
|
|
|
setSrc(event.target.value)
|
|
|
|
|
}}
|
2026-07-31 15:07:09 +08:00
|
|
|
/>
|
|
|
|
|
<FieldDescription>{urlDescription}</FieldDescription>
|
|
|
|
|
</Field>
|
|
|
|
|
{isImage ? (
|
2026-07-31 16:03:54 +08:00
|
|
|
<>
|
|
|
|
|
<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>
|
|
|
|
|
</>
|
2026-07-31 15:07:09 +08:00
|
|
|
) : (
|
|
|
|
|
<Field>
|
|
|
|
|
<FieldLabel htmlFor="lexical-media-poster">
|
|
|
|
|
{videoPosterLabel}
|
|
|
|
|
</FieldLabel>
|
|
|
|
|
<Input
|
|
|
|
|
id="lexical-media-poster"
|
|
|
|
|
inputMode="url"
|
|
|
|
|
placeholder="https://example.com/poster.jpg"
|
|
|
|
|
value={poster}
|
|
|
|
|
onChange={(event) => setPoster(event.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
|
|
|
|
)}
|
|
|
|
|
<Field>
|
|
|
|
|
<FieldLabel htmlFor="lexical-media-caption">
|
|
|
|
|
{captionLabel}
|
|
|
|
|
</FieldLabel>
|
|
|
|
|
<Input
|
|
|
|
|
id="lexical-media-caption"
|
|
|
|
|
placeholder={optionalLabel}
|
|
|
|
|
value={caption}
|
|
|
|
|
onChange={(event) => setCaption(event.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
|
|
|
|
</FieldGroup>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button type="button" variant="outline" onClick={closeDialog}>
|
|
|
|
|
{cancelLabel}
|
|
|
|
|
</Button>
|
2026-07-31 16:03:54 +08:00
|
|
|
<Button type="submit" disabled={isReadingImageFile}>
|
|
|
|
|
{title}
|
|
|
|
|
</Button>
|
2026-07-31 15:07:09 +08:00
|
|
|
</DialogFooter>
|
|
|
|
|
</form>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
)
|
|
|
|
|
}
|