From 3ba168ee955a3b3231ca42c7112963fa2ff240cb Mon Sep 17 00:00:00 2001 From: Aleksandr Grenishin Date: Tue, 10 Oct 2023 10:56:30 +0300 Subject: [PATCH] fix: capture and store file's full path while getting drag'n'dropped (#536) --- abstract/UploaderBlock.js | 5 +- abstract/uploadEntrySchema.js | 5 ++ blocks/DropArea/DropArea.js | 108 ++++++++++++++++++++------------ blocks/DropArea/getDropItems.js | 87 +++++++++++++++++-------- 4 files changed, 138 insertions(+), 67 deletions(-) diff --git a/abstract/UploaderBlock.js b/abstract/UploaderBlock.js index 26ff97e65..90a370eb2 100644 --- a/abstract/UploaderBlock.js +++ b/abstract/UploaderBlock.js @@ -165,9 +165,9 @@ export class UploaderBlock extends ActivityBlock { /** * @param {File} file - * @param {{ silent?: boolean; fileName?: string; source?: string }} [options] + * @param {{ silent?: boolean; fileName?: string; source?: string; fullPath?: string }} [options] */ - addFileFromObject(file, { silent, fileName, source } = {}) { + addFileFromObject(file, { silent, fileName, source, fullPath } = {}) { this.uploadCollection.add({ file, isImage: fileIsImage(file), @@ -176,6 +176,7 @@ export class UploaderBlock extends ActivityBlock { fileSize: file.size, silentUpload: silent ?? false, source: source ?? UploadSource.API, + fullPath, }); } diff --git a/abstract/uploadEntrySchema.js b/abstract/uploadEntrySchema.js index 30d380e99..62ebc73e8 100644 --- a/abstract/uploadEntrySchema.js +++ b/abstract/uploadEntrySchema.js @@ -122,4 +122,9 @@ export const uploadEntrySchema = Object.freeze({ value: false, nullable: true, }, + fullPath: { + type: String, + value: null, + nullable: true, + }, }); diff --git a/blocks/DropArea/DropArea.js b/blocks/DropArea/DropArea.js index a6e509fa8..eb977ce91 100644 --- a/blocks/DropArea/DropArea.js +++ b/blocks/DropArea/DropArea.js @@ -1,23 +1,28 @@ -import { UploaderBlock } from '../../abstract/UploaderBlock.js'; +// @ts-check + import { ActivityBlock } from '../../abstract/ActivityBlock.js'; -import { DropzoneState, addDropzone } from './addDropzone.js'; -import { Modal } from '../Modal/Modal.js'; +import { UploaderBlock } from '../../abstract/UploaderBlock.js'; import { stringToArray } from '../../utils/stringToArray.js'; -import { UploadSource } from '../utils/UploadSource.js'; import { asBoolean } from '../Config/normalizeConfigValue.js'; +import { UploadSource } from '../utils/UploadSource.js'; +import { DropzoneState, addDropzone } from './addDropzone.js'; export class DropArea extends UploaderBlock { - init$ = { - ...this.init$, - state: DropzoneState.INACTIVE, - withIcon: false, - isClickable: false, - isFullscreen: false, - isEnabled: true, - isVisible: true, - text: this.l10n('drop-files-here'), - 'lr-drop-area/targets': null, - }; + constructor() { + super(); + + this.init$ = { + ...this.init$, + state: DropzoneState.INACTIVE, + withIcon: false, + isClickable: false, + isFullscreen: false, + isEnabled: true, + isVisible: true, + text: this.l10n('drop-files-here'), + 'lr-drop-area/targets': null, + }; + } isActive() { if (!this.$.isEnabled) { @@ -36,6 +41,7 @@ export class DropArea extends UploaderBlock { return hasSize && visible && isInViewport; } + initCallback() { super.initCallback(); @@ -44,46 +50,62 @@ export class DropArea extends UploaderBlock { } this.$['lr-drop-area/targets'].add(this); - this.defineAccessor('disabled', (value) => { - this.set$({ isEnabled: !asBoolean(value) }); - }); - this.defineAccessor('clickable', (value) => { - this.set$({ isClickable: asBoolean(value) }); - }); - this.defineAccessor('with-icon', (value) => { - this.set$({ withIcon: asBoolean(value) }); - }); - this.defineAccessor('fullscreen', (value) => { - this.set$({ isFullscreen: asBoolean(value) }); - }); - - this.defineAccessor('text', (value) => { - if (value) { - this.set$({ text: this.l10n(value) || value }); - } else { - this.set$({ text: this.l10n('drop-files-here') }); + this.defineAccessor( + 'disabled', + /** @param {unknown} value */ (value) => { + this.set$({ isEnabled: !asBoolean(value) }); } - }); + ); + this.defineAccessor( + 'clickable', + /** @param {unknown} value */ (value) => { + this.set$({ isClickable: asBoolean(value) }); + } + ); + this.defineAccessor( + 'with-icon', + /** @param {unknown} value */ (value) => { + this.set$({ withIcon: asBoolean(value) }); + } + ); + this.defineAccessor( + 'fullscreen', + /** @param {unknown} value */ (value) => { + this.set$({ isFullscreen: asBoolean(value) }); + } + ); + + this.defineAccessor( + 'text', + /** @param {unknown} value */ (value) => { + if (typeof value === 'string') { + this.set$({ text: this.l10n(value) || value }); + } else { + this.set$({ text: this.l10n('drop-files-here') }); + } + } + ); /** @private */ this._destroyDropzone = addDropzone({ element: this, shouldIgnore: () => this._shouldIgnore(), + /** @param {DropzoneState} state */ onChange: (state) => { this.$.state = state; }, - /** @param {(File | String)[]} items */ + /** @param {import('./getDropItems.js').DropItem[]} items */ onItems: (items) => { if (!items.length) { return; } - items.forEach((/** @type {File | String} */ item) => { - if (typeof item === 'string') { - this.addFileFromUrl(item, { source: UploadSource.DROP_AREA }); - return; + items.forEach((/** @type {import('./getDropItems.js').DropItem} */ item) => { + if (item.type === 'url') { + this.addFileFromUrl(item.url, { source: UploadSource.DROP_AREA }); + } else if (item.type === 'file') { + this.addFileFromObject(item.file, { source: UploadSource.DROP_AREA, fullPath: item.fullPath }); } - this.addFileFromObject(item, { source: UploadSource.DROP_AREA }); }); if (this.uploadCollection.size) { this.set$({ @@ -98,6 +120,7 @@ export class DropArea extends UploaderBlock { if (contentWrapperEl) { this._destroyContentWrapperDropzone = addDropzone({ element: contentWrapperEl, + /** @param {DropzoneState} state */ onChange: (state) => { const stateText = Object.entries(DropzoneState) .find(([, value]) => value === state)?.[0] @@ -206,9 +229,14 @@ DropArea.template = /* HTML */ ` `; DropArea.bindAttributes({ + // @ts-expect-error TODO: fix types inside symbiote 'with-icon': null, + // @ts-expect-error TODO: fix types inside symbiote clickable: null, + // @ts-expect-error TODO: fix types inside symbiote text: null, + // @ts-expect-error TODO: fix types inside symbiote fullscreen: null, + // @ts-expect-error TODO: fix types inside symbiote disabled: null, }); diff --git a/blocks/DropArea/getDropItems.js b/blocks/DropArea/getDropItems.js index 13bb65280..49fa0c550 100644 --- a/blocks/DropArea/getDropItems.js +++ b/blocks/DropArea/getDropItems.js @@ -1,3 +1,17 @@ +// @ts-check + +/** + * @typedef {| { + * type: 'file'; + * file: File; + * fullPath?: string; + * } + * | { + * type: 'url'; + * url: string; + * }} DropItem + */ + /** * @param {File} file * @returns {Promise} @@ -13,6 +27,7 @@ function checkIsDirectory(file) { reader.onerror = () => { resolve(true); }; + /** @param {Event} e */ let onLoad = (e) => { if (e.type !== 'loadend') { reader.abort(); @@ -29,33 +44,45 @@ function checkIsDirectory(file) { }); } +/** + * @param {FileSystemEntry} webkitEntry + * @param {string} dataTransferItemType + * @returns {Promise} + */ function readEntryContentAsync(webkitEntry, dataTransferItemType) { return new Promise((resolve) => { let reading = 0; - let contents = []; + /** @type {DropItem[]} */ + const dropItems = []; - let readEntry = (entry) => { + /** @param {FileSystemEntry} entry */ + const readEntry = (entry) => { if (!entry) { console.warn('Unexpectedly received empty content entry', { scope: 'drag-and-drop' }); resolve(null); } if (entry.isFile) { reading++; - entry.file((file) => { + /** @type {FileSystemFileEntry} */ (entry).file((file) => { reading--; // webkitGetAsEntry don't provide type for HEIC images at least, so we use type value from dataTransferItem const clonedFile = new File([file], file.name, { type: file.type || dataTransferItemType }); - contents.push(clonedFile); + dropItems.push({ + type: 'file', + file: clonedFile, + fullPath: entry.fullPath, + }); if (reading === 0) { - resolve(contents); + resolve(dropItems); } }); } else if (entry.isDirectory) { - readReaderContent(entry.createReader()); + readReaderContent(/** @type {FileSystemDirectoryEntry} */ (entry).createReader()); } }; + /** @param {FileSystemDirectoryReader} reader */ let readReaderContent = (reader) => { reading++; @@ -66,7 +93,7 @@ function readEntryContentAsync(webkitEntry, dataTransferItemType) { } if (reading === 0) { - resolve(contents); + resolve(dropItems); } }); }; @@ -79,11 +106,12 @@ function readEntryContentAsync(webkitEntry, dataTransferItemType) { * Note: dataTransfer will be destroyed outside of the call stack. So, do not try to process it asynchronous. * * @param {DataTransfer} dataTransfer - * @returns {Promise<(File | String)[]>} + * @returns {Promise} */ export function getDropItems(dataTransfer) { - let files = []; - let promises = []; + /** @type {DropItem[]} */ + const dropItems = []; + const promises = []; for (let i = 0; i < dataTransfer.items.length; i++) { let item = dataTransfer.items[i]; if (!item) { @@ -97,34 +125,43 @@ export function getDropItems(dataTransfer) { ? item.webkitGetAsEntry() : /** @type {any} */ (item).getAsEntry(); promises.push( - readEntryContentAsync(entry, itemType).then((entryContent) => { - files.push(...entryContent); + readEntryContentAsync(entry, itemType).then((items) => { + if (items) { + dropItems.push(...items); + } }) ); continue; } - let file = item.getAsFile(); - promises.push( - checkIsDirectory(file).then((isDirectory) => { - if (isDirectory) { - // we can't get directory files, so we'll skip it - } else { - files.push(file); - } - }) - ); + const file = item.getAsFile(); + file && + promises.push( + checkIsDirectory(file).then((isDirectory) => { + if (isDirectory) { + // we can't get directory files, so we'll skip it + } else { + dropItems.push({ + type: 'file', + file, + }); + } + }) + ); } else if (item.kind === 'string' && item.type.match('^text/uri-list')) { promises.push( new Promise((resolve) => { item.getAsString((value) => { - files.push(value); - resolve(); + dropItems.push({ + type: 'url', + url: value, + }); + resolve(undefined); }); }) ); } } - return Promise.all(promises).then(() => files); + return Promise.all(promises).then(() => dropItems); }