Support drag-and-drop file publishing (#4170)

This commit is contained in:
Baltazar Gomez 2020-05-25 09:27:36 -05:00 committed by GitHub
parent 23848dd37a
commit ca4bbf53df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 598 additions and 23 deletions

View file

@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Expose reflector status for publishes ([#4148](https://github.com/lbryio/lbry-desktop/pull/4148)) - Expose reflector status for publishes ([#4148](https://github.com/lbryio/lbry-desktop/pull/4148))
- More tooltip help texts _community pr!_ ([#4185](https://github.com/lbryio/lbry-desktop/pull/4185)) - More tooltip help texts _community pr!_ ([#4185](https://github.com/lbryio/lbry-desktop/pull/4185))
- Add footer on web ([#4159](https://github.com/lbryio/lbry-desktop/pull/4159)) - Add footer on web ([#4159](https://github.com/lbryio/lbry-desktop/pull/4159))
- Support drag-and-drop file publishing _community pr!_ ([#4170](https://github.com/lbryio/lbry-desktop/pull/4170))
### Changed ### Changed

View file

@ -1218,4 +1218,4 @@
"Check your rewards page to see if you qualify for paid content reimbursement. Only content in this section qualifies.": "Check your rewards page to see if you qualify for paid content reimbursement. Only content in this section qualifies.", "Check your rewards page to see if you qualify for paid content reimbursement. Only content in this section qualifies.": "Check your rewards page to see if you qualify for paid content reimbursement. Only content in this section qualifies.",
"blocked channels": "blocked channels", "blocked channels": "blocked channels",
"%count% %channels%. ": "%count% %channels%. " "%count% %channels%. ": "%count% %channels%. "
} }

View file

@ -16,6 +16,7 @@ import usePrevious from 'effects/use-previous';
import Nag from 'component/common/nag'; import Nag from 'component/common/nag';
import { rewards as REWARDS } from 'lbryinc'; import { rewards as REWARDS } from 'lbryinc';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import FileDrop from 'component/fileDrop';
// @if TARGET='web' // @if TARGET='web'
import OpenInAppLink from 'lbrytv/component/openInAppLink'; import OpenInAppLink from 'lbrytv/component/openInAppLink';
import YoutubeWelcome from 'lbrytv/component/youtubeReferralWelcome'; import YoutubeWelcome from 'lbrytv/component/youtubeReferralWelcome';
@ -284,6 +285,7 @@ function App(props: Props) {
<React.Fragment> <React.Fragment>
<Router /> <Router />
<ModalRouter /> <ModalRouter />
<FileDrop />
<FileRenderFloating /> <FileRenderFloating />
{isEnhancedLayout && <Yrbl className="yrbl--enhanced" />} {isEnhancedLayout && <Yrbl className="yrbl--enhanced" />}

View file

@ -0,0 +1,74 @@
// @flow
import React from 'react';
import { useRadioState, Radio, RadioGroup } from 'reakit/Radio';
type Props = {
files: Array<WebFile>,
onChange: (WebFile | void) => void,
};
type RadioProps = {
id: string,
label: string,
};
// Same as FormField type="radio" but it works with reakit:
const ForwardedRadio = React.forwardRef((props: RadioProps, ref) => (
<span className="radio">
<input {...props} type="radio" ref={ref} />
<label htmlFor={props.id}>{props.label}</label>
</span>
));
function FileList(props: Props) {
const { files, onChange } = props;
const radio = useRadioState();
const getFile = (value?: string) => {
if (files && files.length) {
return files.find((file: WebFile) => file.name === value);
}
};
React.useEffect(() => {
if (radio.stops.length) {
if (!radio.currentId) {
radio.first();
} else {
const first = radio.stops[0].ref.current;
// First auto-selection
if (first && first.id === radio.currentId && !radio.state) {
const file = getFile(first.value);
// Update state and select new file
onChange(file);
radio.setState(first.value);
}
if (radio.state) {
// Find selected element
const stop = radio.stops.find(item => item.id === radio.currentId);
const element = stop && stop.ref.current;
// Only update state if new item is selected
if (element && element.value !== radio.state) {
const file = getFile(element.value);
// Sselect new file and update state
onChange(file);
radio.setState(element.value);
}
}
}
}
}, [radio, onChange]);
return (
<div className={'file-list'}>
<RadioGroup {...radio} aria-label="files">
{files.map(({ name }) => {
return <Radio key={name} {...radio} value={name} label={name} as={ForwardedRadio} />;
})}
</RadioGroup>
</div>
);
}
export default FileList;

View file

@ -623,4 +623,10 @@ export const icons = {
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" /> <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
</g> </g>
), ),
[ICONS.COMPLETED]: buildIcon(
<g>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</g>
),
}; };

View file

@ -0,0 +1,20 @@
import { connect } from 'react-redux';
import { doUpdatePublishForm, makeSelectPublishFormValue } from 'lbry-redux';
import { selectModal } from 'redux/selectors/app';
import { doOpenModal } from 'redux/actions/app';
import FileDrop from './view';
const select = state => ({
modal: selectModal(state),
filePath: makeSelectPublishFormValue('filePath')(state),
});
const perform = dispatch => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
updatePublishForm: value => dispatch(doUpdatePublishForm(value)),
});
export default connect(select, perform)(FileDrop);

View file

@ -0,0 +1,162 @@
// @flow
import React from 'react';
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import * as MODALS from 'constants/modal_types';
import classnames from 'classnames';
import useDragDrop from 'effects/use-drag-drop';
import { getTree } from 'util/web-file-system';
import { withRouter } from 'react-router';
import Icon from 'component/common/icon';
type Props = {
modal: { id: string, modalProps: {} },
filePath: string | WebFile,
clearPublish: () => void,
updatePublishForm: ({}) => void,
openModal: (id: string, { files: Array<WebFile> }) => void,
// React router
history: {
entities: {}[],
goBack: () => void,
goForward: () => void,
index: number,
length: number,
location: { pathname: string },
push: string => void,
},
};
const HIDE_TIME_OUT = 600;
const TARGET_TIME_OUT = 300;
const NAVIGATE_TIME_OUT = 400;
const PUBLISH_URL = `/$/${PAGES.PUBLISH}`;
function FileDrop(props: Props) {
const { modal, history, openModal, updatePublishForm } = props;
const { drag, dropData } = useDragDrop();
const [files, setFiles] = React.useState([]);
const [error, setError] = React.useState(false);
const [target, setTarget] = React.useState(false);
const hideTimer = React.useRef(null);
const targetTimer = React.useRef(null);
const navigationTimer = React.useRef(null);
const navigateToPublish = React.useCallback(() => {
// Navigate only if location is not publish area:
// - Prevent spam in history
if (history.location.pathname !== PUBLISH_URL) {
history.push(PUBLISH_URL);
}
}, [history]);
// Delay hide and navigation for a smooth transition
const hideDropArea = () => {
hideTimer.current = setTimeout(() => {
setFiles([]);
// Navigate to publish area
navigationTimer.current = setTimeout(() => {
navigateToPublish();
}, NAVIGATE_TIME_OUT);
}, HIDE_TIME_OUT);
};
const handleFileSelected = selectedFile => {
updatePublishForm({ filePath: selectedFile });
hideDropArea();
};
const getFileIcon = type => {
// Not all files have a type
if (!type) {
return ICONS.FILE;
}
// Detect common types
const contentType = type.split('/')[0];
if (contentType === 'text') {
return ICONS.TEXT;
} else if (contentType === 'image') {
return ICONS.IMAGE;
} else if (contentType === 'video') {
return ICONS.VIDEO;
} else if (contentType === 'audio') {
return ICONS.AUDIO;
}
// Binary file
return ICONS.FILE;
};
// Clear timers
React.useEffect(() => {
return () => {
// Clear hide timer
if (hideTimer.current) {
clearTimeout(hideTimer.current);
}
// Clear target timer
if (targetTimer.current) {
clearTimeout(targetTimer.current);
}
// Clear navigation timer
if (navigationTimer.current) {
clearTimeout(navigationTimer.current);
}
};
}, []);
React.useEffect(() => {
// Clear selected file after modal closed
if ((target && !files) || !files.length) {
// Small delay for a better transition
targetTimer.current = setTimeout(() => {
setTarget(null);
}, TARGET_TIME_OUT);
}
}, [files, target]);
React.useEffect(() => {
// Handle drop...
if (dropData && !files.length && (!modal || modal.id !== MODALS.FILE_SELECTION)) {
getTree(dropData)
.then(entries => {
if (entries && entries.length) {
setFiles(entries);
}
})
.catch(error => {
setError(error || true);
});
}
}, [dropData, files, modal]);
React.useEffect(() => {
// Files or directory dropped:
if (!drag && files.length) {
// Handle multiple files selection
if (files.length > 1) {
openModal(MODALS.FILE_SELECTION, { files: files });
setFiles([]);
} else if (files.length === 1) {
// Handle single file selection
setTarget(files[0]);
handleFileSelected(files[0]);
}
}
}, [drag, files, error, openModal]);
// Show icon based on file type
const icon = target ? getFileIcon(target.type) : ICONS.PUBLISH;
// Show drop area when files are dragged over or processing dropped file
const show = files.length === 1 || (!target && drag && (!modal || modal.id !== MODALS.FILE_SELECTION));
return (
<div className={classnames('file-drop', show && 'file-drop--show')}>
<div className={classnames('card', 'file-drop__area')}>
<Icon size={64} icon={icon} className={'main-icon'} />
<p>{target ? target.name : __(`Drop here to publish!`)} </p>
</div>
</div>
);
}
export default withRouter(FileDrop);

View file

@ -47,6 +47,8 @@ function PublishFile(props: Props) {
const { available } = ffmpegStatus; const { available } = ffmpegStatus;
const [oversized, setOversized] = useState(false); const [oversized, setOversized] = useState(false);
const [currentFile, setCurrentFile] = useState(null);
const RECOMMENDED_BITRATE = 6000000; const RECOMMENDED_BITRATE = 6000000;
const TV_PUBLISH_SIZE_LIMIT: number = 1073741824; const TV_PUBLISH_SIZE_LIMIT: number = 1073741824;
const UPLOAD_SIZE_MESSAGE = 'Lbry.tv uploads are limited to 1 GB. Download the app for unrestricted publishing.'; const UPLOAD_SIZE_MESSAGE = 'Lbry.tv uploads are limited to 1 GB. Download the app for unrestricted publishing.';
@ -60,19 +62,16 @@ function PublishFile(props: Props) {
// clear warnings // clear warnings
useEffect(() => { useEffect(() => {
if (!filePath || filePath === '') { if (!filePath || filePath === '') {
updateOptimizeState(0, 0, false); setCurrentFile('');
setOversized(false); setOversized(false);
updateOptimizeState(0, 0, false);
} else if (typeof filePath !== 'string') {
// Update currentFile file
if (filePath.name !== currentFile && filePath.path !== currentFile) {
handleFileChange(filePath);
}
} }
}, [filePath]); }, [filePath, currentFile, handleFileChange, updateOptimizeState]);
let currentFile = '';
if (filePath) {
if (typeof filePath === 'string') {
currentFile = filePath;
} else {
currentFile = filePath.name;
}
}
function updateOptimizeState(duration, size, isvid) { function updateOptimizeState(duration, size, isvid) {
updatePublishForm({ fileDur: duration, fileSize: size, fileVid: isvid }); updatePublishForm({ fileDur: duration, fileSize: size, fileVid: isvid });
@ -179,9 +178,6 @@ function PublishFile(props: Props) {
function handleFileChange(file: WebFile) { function handleFileChange(file: WebFile) {
const { showToast } = props; const { showToast } = props;
window.URL = window.URL || window.webkitURL; window.URL = window.URL || window.webkitURL;
// if electron, we'll set filePath to the path string because SDK is handling publishing.
// if web, we set the filePath (dumb name) to the File() object
// File.path will be undefined from web due to browser security, so it will default to the File Object.
setOversized(false); setOversized(false);
// select file, start to select a new one, then cancel // select file, start to select a new one, then cancel
@ -189,7 +185,8 @@ function PublishFile(props: Props) {
updatePublishForm({ filePath: '', name: '' }); updatePublishForm({ filePath: '', name: '' });
return; return;
} }
// if video, extract duration so we can warn about bitrate
// if video, extract duration so we can warn about bitrateif (typeof file !== 'string') {
const contentType = file.type.split('/'); const contentType = file.type.split('/');
const isVideo = contentType[0] === 'video'; const isVideo = contentType[0] === 'video';
const isMp4 = contentType[1] === 'mp4'; const isMp4 = contentType[1] === 'mp4';
@ -212,17 +209,17 @@ function PublishFile(props: Props) {
// @if TARGET='web' // @if TARGET='web'
// we only need to enforce file sizes on 'web' // we only need to enforce file sizes on 'web'
if (typeof file !== 'string') { if (file.size && Number(file.size) > TV_PUBLISH_SIZE_LIMIT) {
if (file && file.size && Number(file.size) > TV_PUBLISH_SIZE_LIMIT) { setOversized(true);
setOversized(true); showToast(__(UPLOAD_SIZE_MESSAGE));
showToast(__(UPLOAD_SIZE_MESSAGE)); updatePublishForm({ filePath: '', name: '' });
updatePublishForm({ filePath: '', name: '' }); return;
return;
}
} }
// @endif // @endif
const publishFormParams: { filePath: string | WebFile, name?: string, optimize?: boolean } = { const publishFormParams: { filePath: string | WebFile, name?: string, optimize?: boolean } = {
// if electron, we'll set filePath to the path string because SDK is handling publishing.
// File.path will be undefined from web due to browser security, so it will default to the File Object.
filePath: file.path || file, filePath: file.path || file,
}; };
// Strip off extention and replace invalid characters // Strip off extention and replace invalid characters
@ -232,6 +229,8 @@ function PublishFile(props: Props) {
if (!isStillEditing) { if (!isStillEditing) {
publishFormParams.name = parsedFileName; publishFormParams.name = parsedFileName;
} }
// File path is not supported on web for security reasons so we use the name instead.
setCurrentFile(file.path || file.name);
updatePublishForm(publishFormParams); updatePublishForm(publishFormParams);
} }

View file

@ -100,3 +100,4 @@ export const SLIDERS = 'Sliders';
export const SCIENCE = 'Science'; export const SCIENCE = 'Science';
export const ANALYTICS = 'BarChart2'; export const ANALYTICS = 'BarChart2';
export const PURCHASED = 'Key'; export const PURCHASED = 'Key';
export const CIRCLE = 'Circle';

View file

@ -3,6 +3,7 @@ export const CONFIRM_EXTERNAL_RESOURCE = 'confirm_external_resource';
export const COMMENT_ACKNOWEDGEMENT = 'comment_acknowlegement'; export const COMMENT_ACKNOWEDGEMENT = 'comment_acknowlegement';
export const INCOMPATIBLE_DAEMON = 'incompatible_daemon'; export const INCOMPATIBLE_DAEMON = 'incompatible_daemon';
export const FILE_TIMEOUT = 'file_timeout'; export const FILE_TIMEOUT = 'file_timeout';
export const FILE_SELECTION = 'file_selection';
export const DOWNLOADING = 'downloading'; export const DOWNLOADING = 'downloading';
export const AUTO_GENERATE_THUMBNAIL = 'auto_generate_thumbnail'; export const AUTO_GENERATE_THUMBNAIL = 'auto_generate_thumbnail';
export const AUTO_UPDATE_DOWNLOADED = 'auto_update_downloaded'; export const AUTO_UPDATE_DOWNLOADED = 'auto_update_downloaded';

View file

@ -0,0 +1,97 @@
import React from 'react';
const LISTENER = {
ADD: 'add',
REMOVE: 'remove',
};
const DRAG_TYPES = {
END: 'dragend',
START: 'dragstart',
ENTER: 'dragenter',
LEAVE: 'dragleave',
};
const DRAG_SCORE = {
[DRAG_TYPES.ENTER]: 1,
[DRAG_TYPES.LEAVE]: -1,
};
const DRAG_STATE = {
[DRAG_TYPES.END]: false,
[DRAG_TYPES.START]: true,
};
// Returns simple detection for global drag-drop
export default function useFetched() {
const [drag, setDrag] = React.useState(false);
const [dropData, setDropData] = React.useState(null);
React.useEffect(() => {
let dragCount = 0;
let draggingElement = false;
// Handle file drop
const handleDropEvent = event => {
// Ignore non file types ( html elements / text )
if (!draggingElement) {
event.stopPropagation();
event.preventDefault();
// Get files
const files = event.dataTransfer.files;
// Check for files
if (files.length > 0) {
setDropData(event.dataTransfer);
}
}
// Reset state ( hide drop zone )
dragCount = 0;
setDrag(false);
};
// Drag event for non files type ( html elements / text )
const handleDragElementEvent = event => {
draggingElement = DRAG_STATE[event.type];
};
// Drag events
const handleDragEvent = event => {
event.stopPropagation();
event.preventDefault();
// Prevent multiple drop areas
dragCount += DRAG_SCORE[event.type];
// Dragged file enters the drop area
if (dragCount === 1 && !draggingElement && event.type === DRAG_TYPES.ENTER) {
setDrag(true);
}
// Dragged file leaves the drop area
if (dragCount === 0 && event.type === DRAG_TYPES.LEAVE) {
setDrag(false);
}
};
// Register / Unregister listeners
const handleEventListeners = event => {
const action = `${event}EventListener`;
// Handle drop event
document[action]('drop', handleDropEvent);
// Handle drag events
document[action](DRAG_TYPES.ENTER, handleDragEvent);
document[action](DRAG_TYPES.LEAVE, handleDragEvent);
// Handle non files drag events
document[action](DRAG_TYPES.END, handleDragElementEvent);
document[action](DRAG_TYPES.START, handleDragElementEvent);
};
// On component mounted:
// Register event listeners
handleEventListeners(LISTENER.ADD);
// On component unmounted:
return () => {
// Unregister event listeners
handleEventListeners(LISTENER.REMOVE);
};
}, []);
return { drag, dropData };
}

View file

@ -0,0 +1,12 @@
import { connect } from 'react-redux';
import { doHideModal } from 'redux/actions/app';
import { doUpdatePublishForm } from 'lbry-redux';
import ModaFileSelection from './view';
const perform = dispatch => ({
hideModal: props => dispatch(doHideModal(props)),
updatePublishForm: value => dispatch(doUpdatePublishForm(value)),
});
export default connect(null, perform)(ModaFileSelection);

View file

@ -0,0 +1,77 @@
// @flow
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import React from 'react';
import { Modal } from 'modal/modal';
import { withRouter } from 'react-router';
import Card from 'component/common/card';
import Button from 'component/button';
import FileList from 'component/common/file-list';
type Props = {
files: Array<WebFile>,
hideModal: () => void,
updatePublishForm: ({}) => void,
history: {
location: { pathname: string },
push: string => void,
},
};
const PUBLISH_URL = `/$/${PAGES.PUBLISH}`;
const ModalFileSelection = (props: Props) => {
const { history, files, hideModal, updatePublishForm } = props;
const [selectedFile, setSelectedFile] = React.useState(null);
const navigateToPublish = React.useCallback(() => {
// Navigate only if location is not publish area:
// - Prevent spam in history
if (history.location.pathname !== PUBLISH_URL) {
history.push(PUBLISH_URL);
}
}, [history]);
function handleCloseModal() {
hideModal();
setSelectedFile(null);
}
function handleSubmit() {
updatePublishForm({ filePath: selectedFile });
handleCloseModal();
navigateToPublish();
}
const handleFileChange = (file?: WebFile) => {
setSelectedFile(file);
};
return (
<Modal isOpen type="card" onAborted={handleCloseModal} onConfirmed={handleCloseModal}>
<Card
icon={ICONS.PUBLISH}
title={__('Choose a file')}
subtitle={__('Only one file is allowed, choose wisely:')}
actions={
<div>
<div>
<FileList files={files} onChange={handleFileChange} />
</div>
<div className="section__actions">
<Button
disabled={!selectedFile || !files || !files.length}
button="primary"
label={__('Accept')}
onClick={handleSubmit}
/>
<Button button="link" label={__('Cancel')} onClick={handleCloseModal} />
</div>
</div>
}
/>
</Modal>
);
};
export default withRouter(ModalFileSelection);

View file

@ -39,6 +39,7 @@ import ModalRepost from 'modal/modalRepost';
import ModalSignOut from 'modal/modalSignOut'; import ModalSignOut from 'modal/modalSignOut';
import ModalLiquidateSupports from 'modal/modalSupportsLiquidate'; import ModalLiquidateSupports from 'modal/modalSupportsLiquidate';
import ModalConfirmAge from 'modal/modalConfirmAge'; import ModalConfirmAge from 'modal/modalConfirmAge';
import ModalFileSelection from 'modal/modalFileSelection';
type Props = { type Props = {
modal: { id: string, modalProps: {} }, modal: { id: string, modalProps: {} },
@ -140,6 +141,8 @@ function ModalRouter(props: Props) {
return <ModalLiquidateSupports {...modalProps} />; return <ModalLiquidateSupports {...modalProps} />;
case MODALS.CONFIRM_AGE: case MODALS.CONFIRM_AGE:
return <ModalConfirmAge {...modalProps} />; return <ModalConfirmAge {...modalProps} />;
case MODALS.FILE_SELECTION:
return <ModalFileSelection {...modalProps} />;
default: default:
return null; return null;
} }

View file

@ -18,6 +18,8 @@
@import 'component/embed-player'; @import 'component/embed-player';
@import 'component/expandable'; @import 'component/expandable';
@import 'component/expanding-details'; @import 'component/expanding-details';
@import 'component/file-drop';
@import 'component/file-list';
@import 'component/file-properties'; @import 'component/file-properties';
@import 'component/file-render'; @import 'component/file-render';
@import 'component/footer'; @import 'component/footer';

View file

@ -0,0 +1,39 @@
.file-drop {
top: 0;
left: 0;
position: fixed;
background: var(--color-background-overlay);
opacity: 0;
height: 100%;
width: 100%;
overflow: hidden;
pointer-events: none;
z-index: 5;
transition: opacity 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
&.file-drop--show {
opacity: 1;
pointer-events: all;
transition: opacity 0.3s ease;
}
.file-drop__area {
display: flex;
min-width: 400px;
align-items: center;
flex-direction: column;
padding: var(--spacing-large);
.main-icon {
margin: var(--spacing-small);
}
p {
margin-bottom: var(--spacing-large);
}
}
}

View file

@ -0,0 +1,6 @@
.file-list {
width: 100%;
overflow: auto;
max-height: 220px;
padding: var(--spacing-small);
}

View file

@ -0,0 +1,73 @@
// Some functions to work with the new html5 file system API:
// Wrapper for webkitGetAsEntry
// Note: webkitGetAsEntry might be renamed to GetAsEntry
const getAsEntry = item => {
if (item.kind === 'file' && item.webkitGetAsEntry) {
return item.webkitGetAsEntry();
}
};
// Get file object from fileEntry
const getFile = fileEntry => new Promise((resolve, reject) => fileEntry.file(resolve, reject));
// Read entries from directory
const readDirectory = directory => {
// Some browsers don't support this
if (directory.createReader !== undefined) {
let dirReader = directory.createReader();
return new Promise((resolve, reject) => dirReader.readEntries(resolve, reject));
}
};
// Get file system entries from the dataTransfer items list:
const getFiles = (items, directoryEntries = false) => {
let entries = [];
for (let item of items) {
const entry = directoryEntries ? item : getAsEntry(item);
if (entry && entry.isFile) {
const file = getFile(entry);
entries.push(file);
}
}
return Promise.all(entries);
};
// Generate a valid file tree from dataTransfer:
// - Ignores directory entries
// - Ignores recursive search
export const getTree = async dataTransfer => {
if (dataTransfer) {
const { items, files } = dataTransfer;
// Handle single item drop
if (files.length === 1) {
const root = getAsEntry(items[0]);
// Handle entry
if (root) {
// Handle directory
if (root.isDirectory) {
const directoryEntries = await readDirectory(root);
// Get each file from the list
return getFiles(directoryEntries, true);
}
// Hanlde file
if (root.isFile) {
const file = await getFile(root);
return [file];
}
} else {
// Some files have hidden dataTransfer items:
// Use the default file object instead
return files;
}
}
// Handle multiple items drop
if (files.length > 1) {
// Convert items to fileEntry and filter files
return getFiles(items);
}
}
};