apple2js/js/components/FileChooser.tsx

240 lines
7.2 KiB
TypeScript

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<string, string | string[]>;
}
const ACCEPT_EVERYTHING_TYPE: FilePickerAcceptType = {
description: 'Any file',
accept: { '*/*': [] },
};
export interface FileChooserProps {
disabled?: boolean;
onChange: (handles: Array<FileSystemFileHandle>) => 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<HTMLInputElement>(null);
const filesRef = useRef<FileList>();
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<ExtraProps>(() => {
// 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.
//
// However, it turns out that Safari does not like MIME types when
// reading from iCloud. It may be related to:
//
// https://bugs.webkit.org/show_bug.cgi?id=244666
//
// or it could be related to some permissions issue with iCloud. For
// the moment, not adding the MIME type is sufficient.
const newAccept: string[] = [];
for (const type of accept) {
for (let [, /* typeString */ suffixes] of Object.entries(
type.accept
)) {
// newAccept.push(typeString);
if (!Array.isArray(suffixes)) {
suffixes = [suffixes];
}
for (const suffix of suffixes) {
if (!newAccept.includes(suffix)) {
newAccept.push(suffix);
}
}
}
}
const extraProps: { accept?: string } = {};
if (newAccept.length > 0) {
extraProps['accept'] = newAccept.join(',');
}
return extraProps;
}, [accept]);
return (
<input
type="file"
role="button"
aria-label="Open file"
ref={inputRef}
onChange={onChangeInternal}
disabled={disabled}
{...extraProps}
/>
);
};
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<boolean>(false);
const [selectedFilename, setSelectedFilename] = useState<string>();
const [fileHandles, setFileHandles] = useState<FileSystemFileHandle[]>();
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 (
<>
<button
onClick={noAwait(onClickInternal)}
disabled={disabled || busy}
>
Choose File
</button>
&nbsp;
<span role="label">{selectedFilename}</span>
</>
);
};
/**
* 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.
*
* The FileChooser takes an optional `accept` parameter that specifies which
* types of files can be opened. The parameter is a map of MIME type to file
* extension. If the MIME type is the empty string, t
*/
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' ? (
<FilePickerChooser onChange={onChangeForPicker} {...rest} />
) : (
<InputFileChooser onChange={onChangeForInput} {...rest} />
);
};