import { computed, shallowRef, toValue, watch } from 'vue'; import type { ComputedRef, MaybeRefOrGetter, ShallowRef } from 'vue'; import { noop } from '@robonen/stdlib'; import { defaultWindow } from '@/types'; import type { ConfigurableWindow } from '@/types'; import { useSupported } from '@/composables/utilities/useSupported'; /** * `window.showOpenFilePicker` parameters. * * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker#parameters */ export interface FileSystemAccessShowOpenFileOptions { multiple?: boolean; types?: Array<{ description?: string; accept: Record; }>; excludeAcceptAllOption?: boolean; } /** * `window.showSaveFilePicker` parameters. * * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showSaveFilePicker#parameters */ export interface FileSystemAccessShowSaveFileOptions { suggestedName?: string; types?: Array<{ description?: string; accept: Record; }>; excludeAcceptAllOption?: boolean; } /** * @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream/write */ export interface FileSystemWritableFileStreamWrite { (data: string | BufferSource | Blob): Promise; (options: { type: 'write'; position: number; data: string | BufferSource | Blob }): Promise; (options: { type: 'seek'; position: number }): Promise; (options: { type: 'truncate'; size: number }): Promise; } /** * @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream */ export interface FileSystemWritableFileStream extends WritableStream { write: FileSystemWritableFileStreamWrite; seek: (position: number) => Promise; truncate: (size: number) => Promise; } /** * @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileHandle */ export interface FileSystemFileHandle { getFile: () => Promise; createWritable: () => Promise; } /** * A `window` augmented with the File System Access API entry points. */ export type FileSystemAccessWindow = Window & { showSaveFilePicker: (options: FileSystemAccessShowSaveFileOptions) => Promise; showOpenFilePicker: (options: FileSystemAccessShowOpenFileOptions) => Promise; }; /** * The supported file data types. */ export type UseFileSystemAccessDataType = 'Text' | 'ArrayBuffer' | 'Blob'; /** * Picker options shared between open/create/save operations. */ export type UseFileSystemAccessCommonOptions = Pick; /** * Picker options accepted by save-style operations. */ export type UseFileSystemAccessShowSaveFileOptions = Pick & UseFileSystemAccessCommonOptions; export type UseFileSystemAccessOptions = ConfigurableWindow & UseFileSystemAccessCommonOptions & { /** * How the file contents are read into `data`. * * @default 'Text' */ dataType?: MaybeRefOrGetter; /** * Called when a picker or file operation fails. * * User-cancelled pickers reject with an `AbortError`; route them here to * keep them out of the global unhandled-rejection channel. * * @default noop */ onError?: (error: unknown) => void; }; export interface UseFileSystemAccessReturn { /** * Whether the File System Access API is available. */ isSupported: ComputedRef; /** * The current file contents, read according to `dataType`. */ data: ShallowRef; /** * The currently bound `File`, or `undefined` when no file is open. */ file: ShallowRef; /** * The current file name (empty string when no file is open). */ fileName: ComputedRef; /** * The current file MIME type (empty string when no file is open). */ fileMIME: ComputedRef; /** * The current file size in bytes (`0` when no file is open). */ fileSize: ComputedRef; /** * The current file's last-modified timestamp (`0` when no file is open). */ fileLastModified: ComputedRef; /** * Show the open-file picker and load the chosen file. */ open: (options?: UseFileSystemAccessCommonOptions) => Promise; /** * Show the save-file picker to create a new, empty file handle. */ create: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise; /** * Write `data` back to the current handle (falls back to `saveAs` when none). */ save: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise; /** * Show the save-file picker, then write `data` to the chosen handle. */ saveAs: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise; /** * Re-read `data` (and metadata) from the current handle. */ updateData: () => Promise; } /** * @name useFileSystemAccess * @category Browser * @description Create, read, and write local files via the File System Access API. * * @param {UseFileSystemAccessOptions} [options={}] Options including `dataType`, `types`, `excludeAcceptAllOption`, and `onError` * @returns {UseFileSystemAccessReturn} `isSupported`, `data`, `file`, `fileName`, `fileMIME`, `fileSize`, `fileLastModified`, `open`, `create`, `save`, `saveAs`, `updateData` * * @example * const { isSupported, data, open, save } = useFileSystemAccess({ dataType: 'Text' }); * await open(); * data.value += '\nappended'; * await save(); * * @example * // Read raw bytes * const { data } = useFileSystemAccess({ dataType: 'ArrayBuffer' }); * * @since 0.0.15 */ export function useFileSystemAccess(): UseFileSystemAccessReturn; export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Text' }): UseFileSystemAccessReturn; export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'ArrayBuffer' }): UseFileSystemAccessReturn; export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Blob' }): UseFileSystemAccessReturn; export function useFileSystemAccess(options: UseFileSystemAccessOptions): UseFileSystemAccessReturn; export function useFileSystemAccess( options: UseFileSystemAccessOptions = {}, ): UseFileSystemAccessReturn { const { window: win = defaultWindow, dataType = 'Text', types, excludeAcceptAllOption, onError = noop, } = options; const fsWindow = win as FileSystemAccessWindow | undefined; const isSupported = useSupported(() => fsWindow && 'showSaveFilePicker' in fsWindow && 'showOpenFilePicker' in fsWindow); const fileHandle = shallowRef(); const data = shallowRef(); const file = shallowRef(); const fileName = computed(() => file.value?.name ?? ''); const fileMIME = computed(() => file.value?.type ?? ''); const fileSize = computed(() => file.value?.size ?? 0); const fileLastModified = computed(() => file.value?.lastModified ?? 0); // Resolve the picker defaults once per call rather than spreading the full // options bag (which carries `window`/`onError`) into the native picker. function pickerDefaults(): UseFileSystemAccessCommonOptions { return { types, excludeAcceptAllOption }; } async function updateFile(): Promise { file.value = await fileHandle.value?.getFile(); } async function updateData(): Promise { await updateFile(); const type = toValue(dataType); if (type === 'Text') data.value = await file.value?.text(); else if (type === 'ArrayBuffer') data.value = await file.value?.arrayBuffer(); else if (type === 'Blob') data.value = file.value; } async function writeData(): Promise { if (!fileHandle.value || data.value === undefined || data.value === null) return; const stream = await fileHandle.value.createWritable(); await stream.write(data.value); await stream.close(); } async function open(_options: UseFileSystemAccessCommonOptions = {}): Promise { if (!isSupported.value || !fsWindow) return; try { const [handle] = await fsWindow.showOpenFilePicker({ ...pickerDefaults(), ..._options }); if (!handle) return; fileHandle.value = handle; await updateData(); } catch (error) { onError(error); } } async function create(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise { if (!isSupported.value || !fsWindow) return; try { fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options }); data.value = undefined; await updateData(); } catch (error) { onError(error); } } async function saveAs(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise { if (!isSupported.value || !fsWindow) return; try { fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options }); await writeData(); await updateFile(); } catch (error) { onError(error); } } async function save(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise { if (!isSupported.value) return; if (!fileHandle.value) return saveAs(_options); try { await writeData(); await updateFile(); } catch (error) { onError(error); } } // Re-read with the new strategy whenever `dataType` changes; skip the redundant // initial run since nothing is open yet. watch(() => toValue(dataType), () => { if (fileHandle.value) void updateData(); }); return { isSupported, data, file, fileName, fileMIME, fileSize, fileLastModified, open, create, save, saveAs, updateData, }; }