From 6296a2c0f8254848b65c0ecb55e52dcbe4717135 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 | 321 ++++++++++-------- 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, 478 insertions(+), 147 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 c9fd1aa636..5076abd579 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, @@ -13,11 +8,19 @@ import { forEach } 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'; @@ -420,8 +423,7 @@ export class App extends PureComponent { } = this.state; const { - dialog, - fileSystem + dialog } = globals; const providers = tabsProvider.getProviders(); @@ -437,17 +439,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); } @@ -587,6 +579,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 { @@ -1295,6 +1304,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); } @@ -1351,144 +1368,152 @@ export class App extends PureComponent { const isDirty = this.isDirty(activeTab); return ( -
+ + +
+ + + + + + + + + - + + - + + + + - - - - - - - - - - - - - - - - - - - { - tabState.exportAs && + + - } -
- + + + } + +
+ + + + { + + } + +
+ + + - - { - - } - -
- - -
- - -
+ +
+ + ); } } @@ -1517,6 +1542,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..8561e7b323 --- /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.only('', 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('dataTransferg'); + + }); + + }); + +}); + + + +// 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';