Files
simple-react-app-kit/packages/lexical/src/plugins/media-dialog-plugin.tsx
T

310 lines
10 KiB
TypeScript
Raw Normal View History

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"
import { readFileAsDataUrl } from "../file-utils"
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("")
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)
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("")
setImageFileName("")
setImageFileError(false)
setIsReadingImageFile(false)
setPoster("")
imageFileReadId.current += 1
if (imageFileInputRef.current) imageFileInputRef.current.value = ""
setPendingMedia({ kind, selection })
return true
},
COMMAND_PRIORITY_EDITOR
),
[editor]
)
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
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}
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}
</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">
{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>
<Button type="submit" disabled={isReadingImageFile}>
{title}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}