Files
tools/vue/toolkit/src/composables/browser/useFileSystemAccess/index.ts
T
robonen 59e995d0b5 feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
2026-06-08 15:51:16 +07:00

333 lines
9.9 KiB
TypeScript

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<string, string[]>;
}>;
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<string, string[]>;
}>;
excludeAcceptAllOption?: boolean;
}
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream/write
*/
export interface FileSystemWritableFileStreamWrite {
(data: string | BufferSource | Blob): Promise<void>;
(options: { type: 'write'; position: number; data: string | BufferSource | Blob }): Promise<void>;
(options: { type: 'seek'; position: number }): Promise<void>;
(options: { type: 'truncate'; size: number }): Promise<void>;
}
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream
*/
export interface FileSystemWritableFileStream extends WritableStream {
write: FileSystemWritableFileStreamWrite;
seek: (position: number) => Promise<void>;
truncate: (size: number) => Promise<void>;
}
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileHandle
*/
export interface FileSystemFileHandle {
getFile: () => Promise<File>;
createWritable: () => Promise<FileSystemWritableFileStream>;
}
/**
* A `window` augmented with the File System Access API entry points.
*/
export type FileSystemAccessWindow
= Window & {
showSaveFilePicker: (options: FileSystemAccessShowSaveFileOptions) => Promise<FileSystemFileHandle>;
showOpenFilePicker: (options: FileSystemAccessShowOpenFileOptions) => Promise<FileSystemFileHandle[]>;
};
/**
* The supported file data types.
*/
export type UseFileSystemAccessDataType = 'Text' | 'ArrayBuffer' | 'Blob';
/**
* Picker options shared between open/create/save operations.
*/
export type UseFileSystemAccessCommonOptions
= Pick<FileSystemAccessShowOpenFileOptions, 'types' | 'excludeAcceptAllOption'>;
/**
* Picker options accepted by save-style operations.
*/
export type UseFileSystemAccessShowSaveFileOptions
= Pick<FileSystemAccessShowSaveFileOptions, 'suggestedName'> & UseFileSystemAccessCommonOptions;
export type UseFileSystemAccessOptions
= ConfigurableWindow & UseFileSystemAccessCommonOptions & {
/**
* How the file contents are read into `data`.
*
* @default 'Text'
*/
dataType?: MaybeRefOrGetter<UseFileSystemAccessDataType>;
/**
* 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<T = string | ArrayBuffer | Blob> {
/**
* Whether the File System Access API is available.
*/
isSupported: ComputedRef<boolean>;
/**
* The current file contents, read according to `dataType`.
*/
data: ShallowRef<T | undefined>;
/**
* The currently bound `File`, or `undefined` when no file is open.
*/
file: ShallowRef<File | undefined>;
/**
* The current file name (empty string when no file is open).
*/
fileName: ComputedRef<string>;
/**
* The current file MIME type (empty string when no file is open).
*/
fileMIME: ComputedRef<string>;
/**
* The current file size in bytes (`0` when no file is open).
*/
fileSize: ComputedRef<number>;
/**
* The current file's last-modified timestamp (`0` when no file is open).
*/
fileLastModified: ComputedRef<number>;
/**
* Show the open-file picker and load the chosen file.
*/
open: (options?: UseFileSystemAccessCommonOptions) => Promise<void>;
/**
* Show the save-file picker to create a new, empty file handle.
*/
create: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
/**
* Write `data` back to the current handle (falls back to `saveAs` when none).
*/
save: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
/**
* Show the save-file picker, then write `data` to the chosen handle.
*/
saveAs: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
/**
* Re-read `data` (and metadata) from the current handle.
*/
updateData: () => Promise<void>;
}
/**
* @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<string | ArrayBuffer | Blob>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Text' }): UseFileSystemAccessReturn<string>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'ArrayBuffer' }): UseFileSystemAccessReturn<ArrayBuffer>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Blob' }): UseFileSystemAccessReturn<Blob>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions): UseFileSystemAccessReturn<string | ArrayBuffer | Blob>;
export function useFileSystemAccess(
options: UseFileSystemAccessOptions = {},
): UseFileSystemAccessReturn<string | ArrayBuffer | Blob> {
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<FileSystemFileHandle>();
const data = shallowRef<string | ArrayBuffer | Blob>();
const file = shallowRef<File>();
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<void> {
file.value = await fileHandle.value?.getFile();
}
async function updateData(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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,
};
}