import { h, Fragment } from 'preact'; import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { noAwait } from './util/promises'; export interface FilePickerAcceptType { description?: string | undefined; accept: Record; } const ACCEPT_EVERYTHING_TYPE: FilePickerAcceptType = { description: 'Any file', accept: { '*/*': [] }, }; export interface FileChooserProps { disabled?: boolean; onChange: (handles: Array) => void; accept?: FilePickerAcceptType[]; control?: typeof controlDefault; } const hasPicker = !!window.showOpenFilePicker; const controlDefault = hasPicker ? 'picker' : 'input'; interface InputFileChooserProps { disabled?: boolean; onChange?: (files: FileList) => void; accept?: FilePickerAcceptType[]; } interface ExtraProps { accept?: string; } const InputFileChooser = ({ disabled = false, onChange = () => { /* do nothing */ }, accept = [], }: InputFileChooserProps) => { const inputRef = useRef(null); const filesRef = useRef(); const onChangeInternal = useCallback(() => { if (inputRef.current?.files) { const newFiles = inputRef.current?.files; if (filesRef.current !== newFiles) { filesRef.current = newFiles; onChange(newFiles); } } }, [onChange]); const extraProps = useMemo(() => { // Accept all of the given MIME types and extensions. An argument // could be made to throw out all of the MIME types and just keep // the extensions, but this seemed to follow the intent in the // spec: // // https://html.spec.whatwg.org/multipage/input.html#file-upload-state-(type=file) // // which allows pretty generous interpretations. // // Update(whscullin) - Adding the MIME type seems to block loading anything // from iCloud when using Safari, so reverting to simply using extensions for // now. const newAccept = []; for (const type of accept) { for (const [/* typeString */, suffixes] of Object.entries(type.accept)) { // newAccept.push(typeString); if (Array.isArray(suffixes)) { newAccept.push(...suffixes); } else { newAccept.push(suffixes); } } } const extraProps: { accept?: string } = {}; if (newAccept.length > 0) { extraProps['accept'] = newAccept.join(','); } return extraProps; }, [accept]); return ( ); }; interface FilePickerChooserProps { disabled?: boolean; onChange?: (files: FileSystemFileHandle[]) => void; accept?: FilePickerAcceptType[]; } const FilePickerChooser = ({ disabled = false, onChange = () => { /* do nothing */ }, accept = [ACCEPT_EVERYTHING_TYPE] }: FilePickerChooserProps) => { const [busy, setBusy] = useState(false); const [selectedFilename, setSelectedFilename] = useState(); const [fileHandles, setFileHandles] = useState(); const onClickInternal = useCallback(async () => { if (busy) { return; } setBusy(true); try { const pickedFiles = await window.showOpenFilePicker({ multiple: false, excludeAcceptAllOption: true, types: accept, }); if (fileHandles !== pickedFiles) { setFileHandles(pickedFiles); onChange(pickedFiles); } } catch (e: unknown) { console.error(e); } finally { setBusy(false); } }, [accept, busy, fileHandles, onChange]); useEffect(() => { setSelectedFilename( fileHandles?.length ? fileHandles[0].name : 'No file selected'); }, [fileHandles]); return ( <>   {selectedFilename} ); }; /** * File chooser component displayed as a button followed by the name of the * chosen file (if any). When clicked, the button opens a native file chooser. * If the browser supports the `window.showOpenFilePicker` function, this * component uses it to open the file chooser. Otherwise, the component uses * a regular file input element. * * Using `window.showOpenFilePicker` has the advantage of allowing read/write * access to the file, whereas the regular input element only gives read * access. */ export const FileChooser = ({ onChange, control = controlDefault, ...rest }: FileChooserProps) => { const onChangeForInput = useCallback((files: FileList) => { const handles: FileSystemFileHandle[] = []; for (let i = 0; i < files.length; i++) { const file = files.item(i); if (file === null) { continue; } handles.push({ kind: 'file', name: file.name, getFile: () => Promise.resolve(file), createWritable: (_options) => Promise.reject('File not writable.'), queryPermission: (descriptor) => Promise.resolve(descriptor?.mode === 'read' ? 'granted' : 'denied'), requestPermission: (descriptor) => Promise.resolve(descriptor?.mode === 'read' ? 'granted' : 'denied'), isSameEntry: (_unused) => Promise.resolve(false), isDirectory: false, isFile: true, }); } onChange(handles); }, [onChange]); const onChangeForPicker = useCallback((fileHandles: FileSystemFileHandle[]) => { onChange(fileHandles); }, [onChange]); return control === 'picker' ? ( ) : ( ); };