Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow dropping a folder into the dropzone #1066

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
29 changes: 21 additions & 8 deletions ember-file-upload/src/components/file-dropzone.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { getOwner } from '@ember/application';
import DataTransferWrapper from '../system/data-transfer-wrapper.ts';
import DataTransferWrapper, {
type FileWithDirectory,
} from '../system/data-transfer-wrapper.ts';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { UploadFile } from '../upload-file.ts';
Expand Down Expand Up @@ -134,7 +136,7 @@ export default class FileDropzoneComponent extends Component<FileDropzoneSignatu
}

@action
didDrop(event: FileUploadDragEvent) {
async didDrop(event: FileUploadDragEvent) {
if (this.dataTransferWrapper) {
this.dataTransferWrapper.dataTransfer = event.dataTransfer;
}
Expand Down Expand Up @@ -219,22 +221,33 @@ export default class FileDropzoneComponent extends Component<FileDropzoneSignatu
// }

if (this.dataTransferWrapper) {
const addedFiles = this.addFiles(this.files);
this.args.onDrop?.(addedFiles, this.dataTransferWrapper);
const files: FileWithDirectory[] = this.args.allowFolderDrop
? await this.dataTransferWrapper.getFilesAndDirectories()
: this.files.map((file) => ({ file }));

const addedFiles = this.addFiles(files);
this.args.onDrop?.(addedFiles, this.dataTransferWrapper);
this.active = false;
this.dataTransferWrapper = undefined;
}
}

addFiles(files: File[]) {
addFiles(files: FileWithDirectory[]) {
const addedFiles = [];
for (const file of files) {
if (file instanceof File) {
const uploadFile = new UploadFile(file, FileSource.DragAndDrop);
if (file.file instanceof File) {
const uploadFile = new UploadFile(
file.file,
FileSource.DragAndDrop,
file.folderName,
);
if (
this.args.filter &&
!this.args.filter(file, files, files.indexOf(file))
!this.args.filter(
file.file,
files.map((f) => f.file),
files.indexOf(file),
)
) {
continue;
}
Expand Down
8 changes: 8 additions & 0 deletions ember-file-upload/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ export interface FileDropzoneSignature {
* */
allowUploadsFromWebsites?: boolean;

/**
*
* Whether users can drop folders into the dropzone
*
* @defaulValue false
* */
allowFolderDrop?: boolean;

/**
* This is the type of cursor that should
* be shown when a drag event happens.
Expand Down
108 changes: 108 additions & 0 deletions ember-file-upload/src/system/data-transfer-wrapper.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,96 @@
import type { FileUploadDragEvent } from '../interfaces.ts';

interface FutureProofDataTransferItem extends DataTransferItem {
getAsEntry?: () => FileSystemDirectoryEntry | null;
}

export interface FileWithDirectory {
file: File;
folderName?: string;
}

const getDataSupport = {};

// this will read a filesystementry into a File object, but ignore the entry if it is a directory
const readEntry = (entry: FileSystemEntry): Promise<File | void> => {
return new Promise((resolve) => {
if (entry.isFile) {
(entry as FileSystemFileEntry).file((fileEntry: File) => {
resolve(fileEntry);
});
} else {
console.warn(
`The dropped directory contains a subdirectory ${entry.fullPath}, the contents of this will be skipped.`,
);
resolve();
}
});
};

const getEntry = (
item: FutureProofDataTransferItem,
): FileSystemDirectoryEntry => {
// In the future this method name might change, so already implementing it like this if needed
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/webkitGetAsEntry
return (
item.getAsEntry?.() ?? (item.webkitGetAsEntry() as FileSystemDirectoryEntry)
);
};

// this will read all the files directly in the given directory, but ignore any nested directory entries and the files therein.
const readAllFilesInDirectory = (item: DataTransferItem): Promise<File[]> =>
new Promise((resolve, reject) => {
const entry = getEntry(item);
if (!entry) {
reject('Could not read directory');
}

const reader = entry?.createReader();
if (!reader) {
reject('Could not read directory');
}
const entries: FileSystemEntry[] = [];
const read = () =>
new Promise<void>((resolve, reject) => {
const readChunk = () => {
reader.readEntries((newEntries: FileSystemEntry[]) => {
if (newEntries.length > 0) {
entries.push(...newEntries);
readChunk();
} else {
resolve();
}
}, reject);
};
readChunk();
});

read().then(async () => {
const readFiles: (File | void)[] = await Promise.all(
entries.map(readEntry),
).catch((err) => {
console.error(err);
throw err;
});
resolve(readFiles.filter(Boolean) as File[]);
});
});

const readDataTransferItem = async (
item: DataTransferItem,
): Promise<FileWithDirectory[]> => {
const entry = getEntry(item);
if (entry?.isDirectory) {
return (await readAllFilesInDirectory(item)).map((file) => ({
file,
folderName: entry?.name,
}));
} else {
const fileItem = item.getAsFile() as File;
return [{ file: fileItem }];
}
};

export default class DataTransferWrapper {
dataTransfer?: DataTransfer;
itemDetails?: FileUploadDragEvent['itemDetails'];
Expand Down Expand Up @@ -43,6 +132,25 @@ export default class DataTransferWrapper {
return this.files.length ? this.files : this.items;
}

async getFilesAndDirectories(): Promise<FileWithDirectory[]> {
if (this.dataTransfer?.items) {
const allFilesInDataTransferItems: FileWithDirectory[][] =
await Promise.all(
Array.from(this.dataTransfer?.items).map(readDataTransferItem),
);

const flattenedFileArray: FileWithDirectory[] =
allFilesInDataTransferItems.reduce((flattenedList, fileList) => {
return [...flattenedList, ...fileList];
}, []);

return flattenedFileArray;
} else {
const droppedFiles: File[] = Array.from(this.dataTransfer?.files ?? []);
return droppedFiles.map((file) => ({ file }));
}
}

get files() {
return Array.from(this.dataTransfer?.files ?? []);
}
Expand Down
66 changes: 66 additions & 0 deletions ember-file-upload/src/test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,72 @@ export async function dragAndDrop(
return triggerEvent(dropzone, 'drop', { dataTransfer });
}

interface FileSystemEntryStub {
isFile: boolean;
file: (callback: (file: File | Blob) => void) => void;
}

export async function dragAndDropDirectory(
target: string | HTMLElement,
folderName: string,
filesInDirectory: (File | Blob)[],
singleFiles?: (File | Blob)[],
) {
const dropzone = target instanceof HTMLElement ? target : find(target);
assert(`Selector '${dropzone}' could not be found.`, dropzone);
assert(
'filesInDirectory must be an array',
filesInDirectory instanceof Array,
);
assert(
'All files in directory must be instances of File/Blob type',
filesInDirectory.every((file) => file instanceof Blob),
);
if (singleFiles) {
assert('singleFiles must be an array', singleFiles instanceof Array);
assert(
'All added singleFiles must be instances of File/Blob type',
singleFiles.every((file) => file instanceof Blob),
);
}

const folderItem = {
webkitGetAsEntry: () => ({
isDirectory: true,
name: folderName,
createReader: () => ({
readEntries: (callback: (entries: FileSystemEntryStub[]) => void) => {
const readingFiles = filesInDirectory.splice(0, 2);
const entryFiles = readingFiles.map((file) => {
return {
isFile: true,
file: (callback: (file: File | Blob) => void) => {
callback(file);
},
};
});
callback(entryFiles);
},
}),
}),
};

const singleFileItem = (singleFile: File | Blob) => ({
webkitGetAsEntry: () => ({
isDirectory: false,
}),
getAsFile: () => singleFile,
});

const dataTransfer = {
items: [folderItem, ...(singleFiles || []).map(singleFileItem)],
};

await triggerEvent(dropzone, 'dragenter', { dataTransfer });
await triggerEvent(dropzone, 'dragover', { dataTransfer });
return triggerEvent(dropzone, 'drop', { dataTransfer });
}

/**
Triggers a `dragenter` event on a `FileDropzone` with `files`.

Expand Down
4 changes: 3 additions & 1 deletion ember-file-upload/src/upload-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ import { estimatedRate } from './system/rate.ts';
export class UploadFile {
file: File;
#source: FileSource;
folderName?: string;

queue?: Queue;

constructor(file: File, source: FileSource) {
constructor(file: File, source: FileSource, folderName?: string) {
this.file = file;
this.#source = source;
this.folderName = folderName;
}

/**
Expand Down
Loading
Loading