From a992d2ad761e23ef7f8e536e0702be9a03db7b4e Mon Sep 17 00:00:00 2001 From: Maciej Barelkowski Date: Mon, 21 Jan 2019 12:53:52 +0100 Subject: [PATCH] feat(client/App): add file drop feature Closes #1085 --- client/src/app/App.js | 326 ++++++++++-------- client/src/app/drop-zone/DropZone.js | 91 +++++ client/src/app/drop-zone/DropZone.less | 36 ++ .../app/drop-zone/__tests__/DropZoneSpec.js | 176 ++++++++++ client/src/app/drop-zone/index.js | 1 + 5 files changed, 480 insertions(+), 150 deletions(-) create mode 100644 client/src/app/drop-zone/DropZone.js create mode 100644 client/src/app/drop-zone/DropZone.less create mode 100644 client/src/app/drop-zone/__tests__/DropZoneSpec.js create mode 100644 client/src/app/drop-zone/index.js diff --git a/client/src/app/App.js b/client/src/app/App.js index 964d32848d..ef4046b476 100644 --- a/client/src/app/App.js +++ b/client/src/app/App.js @@ -1,11 +1,6 @@ import React, { PureComponent } from 'react'; -import { WithCache } from './cached'; - -import { - Fill, - SlotFillRoot -} from './slot-fill'; +import debug from 'debug'; import { assign, @@ -14,11 +9,19 @@ import { reduce } from 'min-dash'; +import { WithCache } from './cached'; + +import { DropZone } from './drop-zone'; + +import { + Fill, + SlotFillRoot +} from './slot-fill'; + import Toolbar from './Toolbar'; import Log from './Log'; -import debug from 'debug'; import { ModalConductor } from './modals'; @@ -426,8 +429,7 @@ export class App extends PureComponent { } = this.state; const { - dialog, - fileSystem + dialog } = globals; const providers = tabsProvider.getProviders(); @@ -443,17 +445,7 @@ export class App extends PureComponent { return; } - const files = await Promise.all(filePaths.map(async (filePath) => { - const fileType = getFileTypeFromExtension(filePath); - - const provider = tabsProvider.getProvider(fileType); - - const encoding = provider.encoding ? provider.encoding : ENCODING_UTF8; - - return await fileSystem.readFile(filePath, { - encoding - }); - })); + const files = await Promise.all(filePaths.map(this.readFileFromPath)); await this.openFiles(files); } @@ -593,6 +585,23 @@ export class App extends PureComponent { return openedTabs.reverse(); } + readFileFromPath = filePath => { + const { + globals, + tabsProvider + } = this.props; + + const fileType = getFileTypeFromExtension(filePath); + + const provider = tabsProvider.getProvider(fileType); + + const encoding = provider.encoding ? provider.encoding : ENCODING_UTF8; + + return globals.fileSystem.readFile(filePath, { + encoding + }); + } + findOpenTab(file) { const { @@ -1327,6 +1336,14 @@ export class App extends PureComponent { this.triggerAction('close-tab', { tabId: tab.id }).catch(console.error); } + handleDrop = async (event) => { + const filePaths = Array.from(event.dataTransfer.files).map(({ path }) => path); + + const files = await Promise.all(filePaths.map(this.readFileFromPath)); + + this.openFiles(files); + } + loadConfig = (key, ...args) => { return this.props.globals.config.get(key, this.state.activeTab, ...args); } @@ -1385,146 +1402,153 @@ export class App extends PureComponent { const canSave = this.isUnsaved(activeTab) || this.isDirty(activeTab); return ( -
+ + +
+ + + + + + + + + - + + - + + + + - - - - - - - - - - - - - - - - - - - { - tabState.exportAs && + + - } -
- + { + tabState.exportAs && + + + } - - { - - } - -
- - + + + + { + + } + +
+ + + + + - - - -
+ + + + ); } } @@ -1553,6 +1577,8 @@ function missingProvider(providerType) { class LoadingTab extends PureComponent { + triggerAction() {} + render() { return ( diff --git a/client/src/app/drop-zone/DropZone.js b/client/src/app/drop-zone/DropZone.js new file mode 100644 index 0000000000..216312c35e --- /dev/null +++ b/client/src/app/drop-zone/DropZone.js @@ -0,0 +1,91 @@ +import React from 'react'; + +import css from './DropZone.less'; + + +export class DropZone extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + draggingOver: false + }; + } + + handleDragOver = event => { + if (!this.isDragAllowed(event)) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + + if (this.state.draggingOver) { + return; + } + + event.stopPropagation(); + + this.setState({ draggingOver: true }); + } + + /** + * @param {DragEvent} event + */ + isDragAllowed(event) { + const { dataTransfer } = event; + + return Array.from(dataTransfer.items) + .some(({ kind, type }) => type === 'file' || kind === 'file'); + } + + handleDragLeave = event => { + event.preventDefault(); + event.stopPropagation(); + + if (this.state.draggingOver && !event.relatedTarget) { + this.setState({ draggingOver: false }); + } + } + + handleDrop = async (event) => { + if (!this.state.draggingOver) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + this.setState({ draggingOver: false }); + + this.props.onDrop(event); + } + + render() { + return ( +
+ { this.state.draggingOver ? : null } + { this.props.children } +
+ ); + } +} + +DropZone.defaultProps = { + onDrop: () => {} +}; + +function DropOverlay() { + return ( +
+
+
Drop diagrams here
+
+
+ ); +} diff --git a/client/src/app/drop-zone/DropZone.less b/client/src/app/drop-zone/DropZone.less new file mode 100644 index 0000000000..744eca9ad4 --- /dev/null +++ b/client/src/app/drop-zone/DropZone.less @@ -0,0 +1,36 @@ +:local(.DropZone) { + height: 100%; + width: 100%; +} + +:local(.DropOverlay) { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + + padding: 120px 50px; + + background: rgba(255, 255, 255, 0.8); + + pointer-events: none; + + z-index: 1000; + + .box { + text-align: center; + border: dashed 4px #DDD; + height: 100%; + vertical-align: middle; + + > div { + font-size: 26px; + color: #999; + margin: auto; + + top: 20%; + position: relative; + } + } +} \ No newline at end of file diff --git a/client/src/app/drop-zone/__tests__/DropZoneSpec.js b/client/src/app/drop-zone/__tests__/DropZoneSpec.js new file mode 100644 index 0000000000..5297e71eda --- /dev/null +++ b/client/src/app/drop-zone/__tests__/DropZoneSpec.js @@ -0,0 +1,176 @@ +/* global sinon */ + +import React from 'react'; + +import { + shallow +} from 'enzyme'; + +import { DropZone } from '../DropZone'; + +describe('', function() { + + describe('#render', function() { + + it('should render', function() { + shallow(); + }); + + }); + + + describe('#handleDragOver', function() { + + it('should not render overlay during drag without file', function() { + + // given + const wrapper = shallow(); + + // when + const event = new MockDragEvent(); + + wrapper.simulate('dragover', event); + + // then + expect(wrapper.find('DropOverlay').exists()).to.be.false; + + }); + + + it('should render overlay during drag with a file', function() { + + // given + const wrapper = shallow(); + + // when + const event = new MockDragEvent({ + type: 'file' + }); + + wrapper.simulate('dragover', event); + + // then + expect(wrapper.find('DropOverlay').exists()).to.be.true; + + }); + + }); + + + describe('#handleDragLeave', function() { + + it('should not render overlay when drag is over', function() { + + // given + const wrapper = shallow(); + + const dragOverEvent = new MockDragEvent({ + type: 'file' + }); + const dragLeaveEvent = new MockDragEvent({ + type: 'file' + }); + + dragLeaveEvent.relatedTarget = null; + + // when + wrapper.simulate('dragover', dragOverEvent); + wrapper.simulate('dragleave', dragLeaveEvent); + + // then + expect(wrapper.find('DropOverlay').exists()).to.be.false; + + }); + + + it('should render overlay when dragging over elements', function() { + + // given + const wrapper = shallow(); + + const dragOverEvent = new MockDragEvent({ + type: 'file' + }); + const dragLeaveEvent = new MockDragEvent({ + type: 'file' + }); + + dragLeaveEvent.relatedTarget = document.createElement('div'); + + // when + wrapper.simulate('dragover', dragOverEvent); + wrapper.simulate('dragleave', dragLeaveEvent); + + // then + expect(wrapper.find('DropOverlay').exists()).to.be.true; + + }); + + }); + + + describe('#handleDrop', function() { + + it('should not render overlay when file is dropped', function() { + + // given + const wrapper = shallow(); + + const dragOverEvent = new MockDragEvent({ + type: 'file' + }); + const dropEvent = new MockDragEvent({ + type: 'file' + }); + + // when + wrapper.simulate('dragover', dragOverEvent); + wrapper.simulate('drop', dropEvent); + + // then + expect(wrapper.find('DropOverlay').exists()).to.be.false; + + }); + + + it('should call passed onDrop prop with event', function() { + + // given + const dropSpy = sinon.spy(); + + const wrapper = shallow(); + + const dragOverEvent = new MockDragEvent({ + type: 'file' + }); + const dropEvent = new MockDragEvent({ + type: 'file' + }); + + // when + wrapper.simulate('dragover', dragOverEvent); + wrapper.simulate('drop', dropEvent); + + // then + expect(dropSpy).to.be.calledOnce; + expect(dropSpy.getCall(0).args).to.have.lengthOf(1); + expect(dropSpy.getCall(0).args[0]).to.have.property('dataTransfer'); + + }); + + }); + +}); + + + +// helper ///// +class MockDragEvent { + constructor(...items) { + this.dataTransfer = { items }; + } + + preventDefault() {} + + stopPropagation() {} +} diff --git a/client/src/app/drop-zone/index.js b/client/src/app/drop-zone/index.js new file mode 100644 index 0000000000..185ac896a7 --- /dev/null +++ b/client/src/app/drop-zone/index.js @@ -0,0 +1 @@ +export { DropZone } from './DropZone';