/** * The media-upload string field: a drag-drop surface plus a paste fallback that * reads the chosen file client-side and emits the encoded string the schema * expects (a base64 body or a full `data:` URL). Every input path enforces the * effective byte cap and the declared MIME, rejecting an over-cap value or a * mismatched type LOUDLY — never silently truncated or accepted. */ import type { ReactNode } from 'react'; import { useContext, useId, useState } from 'react'; import { Badge } from '../components/badge'; import { Field, type FieldControlProps, useFieldControl } from '../components/field'; import { XCircleIcon } from '../components/icons'; import { TextInput } from '../components/inputs'; import { Button } from '../components/primitives'; import { MaxUploadBytesContext } from './context'; import type { MediaUpload } from './field-model'; import { decodedByteSize, effectiveMaxBytes, overCapMessage } from './media'; /** * Add the upload error this module owns to the enclosing `Field`'s wiring. * * The upload error is raised by the input surfaces themselves rather than by the * schema, so `Field` cannot see it: without this the message was announced by * nothing and neither surface reported itself invalid. Used only where the * element is a bare ``; the SDK's `TextInput` composes the field wiring * with the caller's own attributes itself, so the paste fallback states only the * error's id and lets it do the joining. */ function withUploadError( field: FieldControlProps, uploadErrorId: string | undefined, ): FieldControlProps { if (uploadErrorId === undefined) return field; const describedBy = field['aria-describedby']; return { ...field, 'aria-describedby': describedBy === undefined ? uploadErrorId : `${describedBy} ${uploadErrorId}`, 'aria-invalid': true, }; } /** * The drag-drop surface + file input. Split out so its `useFieldControl` call * runs INSIDE the enclosing {@link Field}, wiring the input's id/aria to the * field label (a hook at {@link MediaField}'s top level would read the outer, * label-less context instead). */ function UploadDropZone({ accept, uploadErrorId, onFile, }: { accept: string | undefined; /** Set while an upload error stands, so the input points at it and reads invalid. */ uploadErrorId: string | undefined; onFile: (file: File | undefined) => void; }): ReactNode { const field = withUploadError(useFieldControl(), uploadErrorId); return (
{ event.preventDefault(); }} onDrop={(event) => { event.preventDefault(); onFile(event.dataTransfer.files[0]); }} > { onFile(event.target.files?.[0]); // Reset so re-picking the same file fires `change` again. event.target.value = ''; }} /> Drag & drop a file here, or choose one above.
); } /** * A string field whose schema opts into a file upload. Reads the chosen file * client-side and emits the encoded string the schema expects (a base64 body or * a full `data:` URL) — bounded by a size cap and the declared MIME, both * rejected LOUDLY. A text fallback (paste a URL/base64) stays available. */ export function MediaField({ heading, description, error, media, value, onChange, }: { heading: string; description: string | undefined; error: string | undefined; media: MediaUpload; value: string; onChange: (value: string) => void; }): ReactNode { const pasteId = useId(); const uploadErrorId = useId(); const defaultMax = useContext(MaxUploadBytesContext); const maxBytes = effectiveMaxBytes(media.maxBytes, defaultMax); const [uploadError, setUploadError] = useState(undefined); const [attached, setAttached] = useState(undefined); const accept = (file: File | undefined): void => { if (file === undefined) return; void readMediaFile(file, media, maxBytes).then( ({ value: encoded, previewUrl }) => { setUploadError(undefined); setAttached({ name: file.name, previewUrl, value: encoded }); onChange(encoded); }, (reason: unknown) => { // Reject loudly; keep any previously accepted value untouched. setUploadError(reason instanceof Error ? reason.message : String(reason)); }, ); }; const clear = (): void => { setUploadError(undefined); setAttached(undefined); onChange(''); }; const { previewUrl, isImage, fileName } = mediaPreview(attached, value, media); return ( {uploadError !== undefined ? ( {uploadError} ) : null} { setUploadError(undefined); setAttached(undefined); onChange(next); }} /> ); } /** The just-picked file, retained only while it still matches the current field value. */ interface AttachedMedia { name: string; previewUrl: string; value: string; } /** * The display derived from the attached file and the current value: the chip/preview * applies ONLY while the attachment still matches the value (an external reset or a * different record loading falls back to a value-derived display so a stale * filename/thumbnail can never linger); an existing data-url value reconstructs its * own preview. */ function mediaPreview( attached: AttachedMedia | undefined, value: string, media: MediaUpload, ): { previewUrl: string | undefined; isImage: boolean; fileName: string | undefined } { const activeAttached = attached?.value === value ? attached : undefined; const previewUrl = activeAttached?.previewUrl ?? (media.encoding === 'data-url' && value.startsWith('data:') ? value : undefined); const isImage = media.mediaType?.startsWith('image/') === true || previewUrl?.startsWith('data:image') === true; const fileName = activeAttached?.name ?? (value !== '' ? 'Attached file' : undefined); return { previewUrl, isImage, fileName }; } /** The attached-file chip: an image thumbnail when previewable, else a filename badge, plus Remove. */ function AttachedPreview({ fileName, isImage, previewUrl, onClear, }: { fileName: string | undefined; isImage: boolean; previewUrl: string | undefined; onClear: () => void; }): ReactNode { if (fileName === undefined) return null; return (
{isImage && previewUrl !== undefined ? ( {fileName} ) : ( {fileName} )}
); } /** * The paste fallback: a text input that enforces the SAME effective byte cap the * picker does, measured on the pasted string's DECODED size. An over-cap paste is * rejected LOUDLY (`onReject`) and the field keeps its prior value; an accepted paste * clears any attachment and emits the new value (`onAccept`). */ function MediaPasteInput({ pasteId, value, maxBytes, uploadErrorId, onReject, onAccept, }: { pasteId: string; value: string; maxBytes: number; /** Set while an upload error stands, so the input points at it and reads invalid. */ uploadErrorId: string | undefined; onReject: (message: string) => void; onAccept: (value: string) => void; }): ReactNode { return ( ); } /** Compare a file's MIME against a `contentMediaType` pattern (`image/*` etc.). */ function matchesMediaType(fileType: string, pattern: string): boolean { // An empty file type is genuinely unknown (jsdom / OS gaps) — the server still // validates, so we do not fabricate a mismatch here; only a KNOWN wrong type // is rejected. if (fileType === '') return true; const [patType, patSub] = pattern.toLowerCase().split('/'); const [fileMain, fileSub] = fileType.toLowerCase().split('/'); if (patType !== fileMain) return false; return patSub === '*' || patSub === fileSub; } /** * Validate a chosen file against the media contract and read it into the encoded * string the schema expects. Rejects an over-cap file or a MIME that does not * match `contentMediaType` — never truncates, never silently accepts. */ async function readMediaFile( file: File, media: MediaUpload, maxBytes: number, ): Promise<{ value: string; previewUrl: string }> { if (file.size > maxBytes) { throw new Error(overCapMessage(`"${file.name}"`, file.size, maxBytes)); } if (media.mediaType !== undefined && !matchesMediaType(file.type, media.mediaType)) { throw new Error( `"${file.name}" is ${file.type === '' ? 'an unknown type' : file.type}, which does not match the required ${media.mediaType}.`, ); } const dataUrl = await readAsDataUrl(file); const comma = dataUrl.indexOf(','); if (comma < 0) { throw new Error(`Could not read "${file.name}" as an encoded value.`); } const body = dataUrl.slice(comma + 1); return { value: media.encoding === 'data-url' ? dataUrl : body, previewUrl: dataUrl }; } function readAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { // A successful read must yield the data-url string; anything else is a real // read anomaly and rejects loudly rather than emitting an empty value. if (typeof reader.result === 'string') { resolve(reader.result); } else { reject(new Error(`Failed to read "${file.name}" as a data URL.`)); } }; reader.onerror = () => { reject(reader.error ?? new Error(`Failed to read "${file.name}".`)); }; reader.readAsDataURL(file); }); }