diff --git a/client/src/app/App.js b/client/src/app/App.js
index 6e9e9eb477..dcff924913 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 this.readFileList(filePaths);
await this.openFiles(files);
}
@@ -593,6 +585,45 @@ export class App extends PureComponent {
return openedTabs.reverse();
}
+ readFileList = async filePaths => {
+ const readOperations = filePaths.map(this.readFileFromPath);
+
+ const rawFiles = await Promise.all(readOperations);
+
+ const files = rawFiles.filter(Boolean);
+
+ return files;
+ }
+
+ readFileFromPath = async (filePath) => {
+ const {
+ globals,
+ tabsProvider
+ } = this.props;
+
+ const fileType = getFileTypeFromExtension(filePath);
+
+ const provider = tabsProvider.getProvider(fileType);
+
+ const encoding = provider.encoding ? provider.encoding : ENCODING_UTF8;
+
+ let file = null;
+
+ try {
+ file = await globals.fileSystem.readFile(filePath, {
+ encoding
+ });
+ } catch (error) {
+ if (error.code === 'EISDIR') {
+ return this.handleError(new Error(`Cannot open directory: ${filePath}`));
+ }
+
+ this.handleError(error);
+ }
+
+ return file;
+ }
+
findOpenTab(file) {
const {
@@ -1309,6 +1340,18 @@ export class App extends PureComponent {
this.triggerAction('close-tab', { tabId: tab.id }).catch(console.error);
}
+ handleDrop = async (files) => {
+ const filePaths = Array.from(files).map(({ path }) => path);
+
+ try {
+ const files = await this.readFileList(filePaths);
+
+ await this.openFiles(files);
+ } catch (error) {
+ this.handleError(error);
+ }
+ }
+
loadConfig = (key, ...args) => {
return this.props.globals.config.get(key, this.state.activeTab, ...args);
}
@@ -1367,145 +1410,152 @@ export class App extends PureComponent {
const canSave = this.isUnsaved(activeTab) || this.isDirty(activeTab);
return (
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- tabState.exportAs &&
+
+
- }
-
-
+ {
+ tabState.exportAs &&
+
+
+ }
-
- {
-
- }
-
-
-
-
+
+
+
+ {
+
+ }
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
);
}
}
@@ -1534,6 +1584,8 @@ function missingProvider(providerType) {
class LoadingTab extends PureComponent {
+ triggerAction() {}
+
render() {
return (
diff --git a/client/src/app/__tests__/AppSpec.js b/client/src/app/__tests__/AppSpec.js
index d6b8ac977c..1f5aa92813 100644
--- a/client/src/app/__tests__/AppSpec.js
+++ b/client/src/app/__tests__/AppSpec.js
@@ -2134,6 +2134,52 @@ describe('', function() {
});
+
+ describe('#handleDrop', function() {
+
+ it('should try to open each dropped file', async function() {
+
+ // given
+ const directoryReadError = new Error();
+ directoryReadError.code = 'EISDIR';
+
+ const files = [
+ {
+ path: '/dev/null/'
+ },
+ {
+ path: './CamundaModeler'
+ },
+ {
+ path: './diagram.bpmn'
+ }
+ ];
+
+ const fileSystem = new FileSystem();
+
+ const readFileStub = sinon.stub(fileSystem, 'readFile')
+ .onFirstCall().rejects(directoryReadError)
+ .onSecondCall().rejects(directoryReadError)
+ .onThirdCall().resolves({ contents: '' });
+
+ const {
+ app
+ } = createApp({
+ globals: {
+ fileSystem
+ }
+ });
+
+ // when
+ await app.handleDrop(files);
+
+ // then
+ expect(readFileStub).to.be.calledThrice;
+
+ });
+
+ });
+
});
diff --git a/client/src/app/__tests__/mocks/index.js b/client/src/app/__tests__/mocks/index.js
index 9c457e3ffd..f2176b33aa 100644
--- a/client/src/app/__tests__/mocks/index.js
+++ b/client/src/app/__tests__/mocks/index.js
@@ -88,6 +88,16 @@ class FakeTab extends Component {
}
+
+const noopProvider = {
+ getComponent() {
+ return null;
+ },
+ getInitialContents() {
+ return null;
+ }
+};
+
export class TabsProvider {
constructor(resolveTab) {
@@ -187,7 +197,7 @@ export class TabsProvider {
}
getProvider(type) {
- return this.providers[type];
+ return this.providers[type] || noopProvider;
}
getTabComponent(type) {
diff --git a/client/src/app/drop-zone/DropZone.js b/client/src/app/drop-zone/DropZone.js
new file mode 100644
index 0000000000..a7a1a57c15
--- /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 === '' && 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.dataTransfer.files);
+ }
+
+ render() {
+ return (
+
+ { this.state.draggingOver ? : null }
+ { this.props.children }
+
+ );
+ }
+}
+
+DropZone.defaultProps = {
+ onDrop: () => {}
+};
+
+function DropOverlay() {
+ return (
+
+ );
+}
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..778102ec75
--- /dev/null
+++ b/client/src/app/drop-zone/__tests__/DropZoneSpec.js
@@ -0,0 +1,227 @@
+/* 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 not render overlay during drag with a GIF', function() {
+
+ // given
+ const wrapper = shallow();
+
+ // when
+ const event = new MockDragEvent({
+ type: 'image/gif',
+ kind: 'file'
+ });
+
+ 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: '',
+ kind: '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: '',
+ kind: 'file'
+ });
+ const dragLeaveEvent = new MockDragEvent({
+ type: '',
+ kind: '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: '',
+ kind: 'file'
+ });
+ const dragLeaveEvent = new MockDragEvent({
+ type: '',
+ kind: '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: '',
+ kind: 'file'
+ });
+ const dropEvent = new MockDragEvent({
+ type: '',
+ kind: 'file'
+ });
+
+ // when
+ wrapper.simulate('dragover', dragOverEvent);
+ wrapper.simulate('drop', dropEvent);
+
+ // then
+ expect(wrapper.find('DropOverlay').exists()).to.be.false;
+
+ });
+
+
+ it('should not call passed onDrop prop with event if no file is dragged', function() {
+
+ // given
+ const dropSpy = sinon.spy();
+
+ const wrapper = shallow();
+
+ const dragOverEvent = new MockDragEvent();
+ const dropEvent = new MockDragEvent();
+
+ // when
+ wrapper.simulate('dragover', dragOverEvent);
+ wrapper.simulate('drop', dropEvent);
+
+ // then
+ expect(dropSpy).to.have.not.been.called;
+
+ });
+
+
+ it('should call passed onDrop prop with files', function() {
+
+ // given
+ const dropSpy = sinon.spy();
+
+ const wrapper = shallow();
+
+ const dragOverEvent = new MockDragEvent({
+ type: '',
+ kind: 'file'
+ });
+ const dropEvent = new MockDragEvent({
+ type: '',
+ kind: '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.be.an('Array').with.lengthOf(1);
+
+ });
+
+ });
+
+});
+
+
+
+// helper /////
+class MockDragEvent {
+ constructor(...items) {
+ this.dataTransfer = {
+ items,
+ files: 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';
diff --git a/client/src/app/tabs/xml/CodeMirror.js b/client/src/app/tabs/xml/CodeMirror.js
index 2019c2d1ce..8629737a5d 100644
--- a/client/src/app/tabs/xml/CodeMirror.js
+++ b/client/src/app/tabs/xml/CodeMirror.js
@@ -31,6 +31,8 @@ export default function create(options) {
el = _el;
}, {
autoCloseTags: true,
+ dragDrop: true,
+ allowDropFileTypes: ['text/plain'],
lineWrapping: true,
lineNumbers: true,
mode: {