diff --git a/__tests__/src/components/IIIFDropTarget.test.js b/__tests__/src/components/IIIFDropTarget.test.js
new file mode 100644
index 0000000000..f59e472588
--- /dev/null
+++ b/__tests__/src/components/IIIFDropTarget.test.js
@@ -0,0 +1,69 @@
+import { handleDrop } from '../../../src/components/IIIFDropTarget';
+
+const monitor = jest.fn();
+
+describe('handleDrop', () => {
+ let onDrop;
+
+ beforeEach(() => {
+ onDrop = jest.fn();
+ });
+
+ it('handles url lists', () => {
+ const item = {
+ urls: [
+ 'http://example.com/?manifest=http://example.com/iiif/1.json',
+ 'http://example.com/?manifest=http://example.com/iiif/2.json&canvas=url',
+ ],
+ };
+ const props = { onDrop };
+
+ handleDrop(item, monitor, props);
+ expect(onDrop).toHaveBeenCalledWith({ canvasId: null, manifestId: 'http://example.com/iiif/1.json' }, props, monitor);
+ expect(onDrop).toHaveBeenCalledWith({ canvasId: 'url', manifestId: 'http://example.com/iiif/2.json' }, props, monitor);
+ });
+
+ it('handles manifests', () => {
+ const file = new File(['{ "data": 123 }'], 'manifest.json', { type: 'application/json' });
+ const item = {
+ files: [
+ file,
+ ],
+ };
+
+ const props = { onDrop };
+
+ const promise = handleDrop(item, monitor, props);
+
+ return promise.then(() => {
+ expect(onDrop).toHaveBeenCalledWith(
+ expect.objectContaining({ manifestJson: '{ "data": 123 }' }),
+ props,
+ monitor,
+ );
+ });
+ });
+
+ // jsdom doesn't load images.
+ xit('handles images by fabricating a temporary manifest', () => {
+ const arrayBuffer = Uint8Array.from(window.atob('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='), c => c.charCodeAt(0));
+ const file = new File(arrayBuffer, 'image.gif', { type: 'image/gif' });
+ const item = {
+ files: [
+ file,
+ ],
+ };
+
+ const props = { onDrop };
+
+ const promise = handleDrop(item, monitor, props);
+
+ return promise.then(() => {
+ expect(onDrop).toHaveBeenCalledWith(
+ expect.objectContaining({ manifestJson: '{}' }),
+ props,
+ monitor,
+ );
+ });
+ });
+});
diff --git a/__tests__/src/components/Workspace.test.js b/__tests__/src/components/Workspace.test.js
index 2487b3c03e..927378c22b 100644
--- a/__tests__/src/components/Workspace.test.js
+++ b/__tests__/src/components/Workspace.test.js
@@ -5,6 +5,7 @@ import WorkspaceMosaic from '../../../src/containers/WorkspaceMosaic';
import WorkspaceElastic from '../../../src/containers/WorkspaceElastic';
import Window from '../../../src/containers/Window';
import { Workspace } from '../../../src/components/Workspace';
+import { IIIFDropTarget } from '../../../src/components/IIIFDropTarget';
/**
* Utility function to create a Worksapce
@@ -30,10 +31,12 @@ describe('Workspace', () => {
const wrapper = createWrapper({ workspaceType: 'elastic' });
expect(wrapper.matchesElement(
-
- miradorViewer
-
-
,
+
+
+ miradorViewer
+
+
+ ,
)).toBe(true);
});
});
@@ -42,10 +45,12 @@ describe('Workspace', () => {
const wrapper = createWrapper();
expect(wrapper.matchesElement(
-
- miradorViewer
-
-
,
+
+
+ miradorViewer
+
+
+ ,
)).toBe(true);
});
});
@@ -53,11 +58,13 @@ describe('Workspace', () => {
it('should render components as list', () => {
const wrapper = createWrapper({ workspaceType: 'bubu' });
expect(wrapper.matchesElement(
-
- miradorViewer
-
-
-
,
+
+
+ miradorViewer
+
+
+
+ ,
)).toBe(true);
});
});
@@ -65,10 +72,12 @@ describe('Workspace', () => {
it('should render only maximized components', () => {
const wrapper = createWrapper({ maximizedWindowIds: ['1'] });
expect(wrapper.matchesElement(
-
- miradorViewer
-
-
,
+
+
+ miradorViewer
+
+
+ ,
)).toBe(true);
});
});
@@ -98,4 +107,20 @@ describe('Workspace', () => {
expect(wrapper.find('.mirador-workspace-with-control-panel').length).toBe(0);
});
});
+
+ describe('drag and drop', () => {
+ it('adds a new window', () => {
+ const canvasId = 'canvasId';
+ const manifestId = 'manifest.json';
+ const manifestJson = { data: '123' };
+
+ const addWindow = jest.fn();
+
+ const wrapper = createWrapper({ addWindow });
+
+ wrapper.find(IIIFDropTarget).simulate('drop', { canvasId, manifestId, manifestJson });
+
+ expect(addWindow).toHaveBeenCalledWith({ canvasId, manifest: manifestJson, manifestId });
+ });
+ });
});
diff --git a/__tests__/src/components/WorkspaceAdd.test.js b/__tests__/src/components/WorkspaceAdd.test.js
index 69658bab2a..398de1facb 100644
--- a/__tests__/src/components/WorkspaceAdd.test.js
+++ b/__tests__/src/components/WorkspaceAdd.test.js
@@ -7,6 +7,7 @@ import Typography from '@material-ui/core/Typography';
import { WorkspaceAdd } from '../../../src/components/WorkspaceAdd';
import ManifestListItem from '../../../src/containers/ManifestListItem';
import ManifestForm from '../../../src/containers/ManifestForm';
+import { IIIFDropTarget } from '../../../src/components/IIIFDropTarget';
/** create wrapper */
function createWrapper(props) {
@@ -82,4 +83,31 @@ describe('WorkspaceAdd', () => {
wrapper.find(Drawer).find(ManifestForm).props().onCancel();
expect(wrapper.find(Drawer).props().open).toBe(false);
});
+
+ describe('drag and drop', () => {
+ it('adds a new catalog entry from a manifest', () => {
+ const manifestId = 'manifest.json';
+ const manifestJson = { data: '123' };
+
+ const addResource = jest.fn();
+
+ const wrapper = createWrapper({ addResource });
+
+ wrapper.find(IIIFDropTarget).simulate('drop', { manifestId, manifestJson });
+
+ expect(addResource).toHaveBeenCalledWith(manifestId, manifestJson, { provider: 'file' });
+ });
+
+ it('adds a new catalog entry from a manifestId', () => {
+ const manifestId = 'manifest.json';
+
+ const addResource = jest.fn();
+
+ const wrapper = createWrapper({ addResource });
+
+ wrapper.find(IIIFDropTarget).simulate('drop', { manifestId });
+
+ expect(addResource).toHaveBeenCalledWith(manifestId);
+ });
+ });
});
diff --git a/src/components/IIIFDropTarget.js b/src/components/IIIFDropTarget.js
new file mode 100644
index 0000000000..90ca7e7cb8
--- /dev/null
+++ b/src/components/IIIFDropTarget.js
@@ -0,0 +1,139 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { v4 as uuid } from 'uuid';
+import { NativeTypes } from 'react-dnd-html5-backend';
+import { useDrop } from 'react-dnd';
+
+/** */
+export const handleDrop = (item, monitor, props) => {
+ const { onDrop } = props;
+
+ if (item.urls) {
+ item.urls.forEach((str) => {
+ const url = new URL(str);
+ const manifestId = url.searchParams.get('manifest');
+ const canvasId = url.searchParams.get('canvas');
+
+ if (manifestId) onDrop({ canvasId, manifestId }, props, monitor);
+ });
+ }
+
+ if (item.files) {
+ const manifestFiles = item.files.filter(f => f.type === 'application/json');
+ const manifestPromises = manifestFiles.map(file => (
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.addEventListener('load', () => {
+ const manifestJson = reader.result;
+ const manifestId = uuid();
+
+ if (manifestJson) onDrop({ manifestId, manifestJson }, props, monitor);
+ resolve();
+ });
+ reader.readAsText(file);
+ })
+ ));
+
+ const imageFiles = item.files.filter(({ type }) => type.startsWith('image/'));
+
+ let imagePromise;
+
+ if (imageFiles.length > 0) {
+ const id = uuid();
+ const imageData = imageFiles.map(file => (
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ console.log(file);
+ reader.addEventListener('load', () => {
+ const image = new Image();
+ image.addEventListener('load', () => {
+ resolve({
+ height: image.height,
+ name: file.name,
+ type: file.type,
+ url: reader.result,
+ width: image.width,
+ });
+ });
+ image.src = reader.result;
+ });
+ reader.readAsDataURL(file);
+ })
+ ));
+
+ imagePromise = Promise.all(imageData).then((images) => {
+ const manifestJson = {
+ '@context': 'http://iiif.io/api/presentation/3/context.json',
+ id,
+ items: images.map(({
+ name, type, width, height, url,
+ }, index) => ({
+ height,
+ id: `${id}/canvas/${index}`,
+ items: [
+ {
+ id: `${id}/canvas/${index}/1`,
+ items: [{
+ body: {
+ format: type,
+ id: url,
+ type: 'Image',
+ },
+ height,
+ id: `${id}/canvas/${index}/1/image`,
+ motivation: 'painting',
+ target: `${id}/canvas/${index}/1`,
+ type: 'Annotation',
+ width,
+ }],
+ type: 'AnnotationPage',
+ },
+ ],
+ label: name,
+ type: 'Canvas',
+ width,
+ })),
+ label: images[0].name,
+ type: 'Manifest',
+ };
+
+ const manifestId = uuid();
+ if (manifestJson) onDrop({ manifestId, manifestJson }, props, monitor);
+ });
+ }
+
+ return Promise.all([...manifestPromises, imagePromise]);
+ }
+
+ return undefined;
+};
+
+/** */
+export const IIIFDropTarget = (props) => {
+ const { children, onDrop } = props;
+ const [{ canDrop, isOver }, drop] = useDrop({
+ accept: [NativeTypes.URL, NativeTypes.FILE],
+ collect: monitor => ({
+ canDrop: monitor.canDrop(),
+ isOver: monitor.isOver(),
+ }),
+ /** */
+ drop(item, monitor) {
+ if (!onDrop) return;
+ handleDrop(item, monitor, props);
+ },
+ });
+ // TODO: give some indication the app receives drops
+ const isActive = canDrop && isOver; // eslint-disable-line no-unused-vars
+
+ return (
+
+ {children}
+
+ );
+};
+
+IIIFDropTarget.propTypes = {
+ children: PropTypes.node.isRequired,
+ onDrop: PropTypes.func.isRequired,
+};
diff --git a/src/components/Workspace.js b/src/components/Workspace.js
index feaf4aa4ed..b0844eba93 100644
--- a/src/components/Workspace.js
+++ b/src/components/Workspace.js
@@ -7,6 +7,7 @@ import Window from '../containers/Window';
import WorkspaceMosaic from '../containers/WorkspaceMosaic';
import WorkspaceElastic from '../containers/WorkspaceElastic';
import ns from '../config/css-ns';
+import { IIIFDropTarget } from './IIIFDropTarget';
/**
* Represents a work area that contains any number of windows
@@ -14,6 +15,20 @@ import ns from '../config/css-ns';
* @private
*/
export class Workspace extends React.Component {
+ /** */
+ constructor(props) {
+ super(props);
+
+ this.handleDrop = this.handleDrop.bind(this);
+ }
+
+ /** */
+ handleDrop({ canvasId, manifestId, manifestJson }, props, monitor) {
+ const { addWindow } = this.props;
+
+ addWindow({ canvasId, manifest: manifestJson, manifestId });
+ }
+
/**
* Determine which workspace to render by configured type
*/
@@ -93,24 +108,27 @@ export class Workspace extends React.Component {
const { classes, isWorkspaceControlPanelVisible, t } = this.props;
return (
-
- {t('miradorViewer')}
- {this.workspaceByType()}
-
+
+
+ {t('miradorViewer')}
+ {this.workspaceByType()}
+
+
);
}
}
Workspace.propTypes = {
+ addWindow: PropTypes.func,
classes: PropTypes.objectOf(PropTypes.string).isRequired,
isWorkspaceControlPanelVisible: PropTypes.bool.isRequired,
maximizedWindowIds: PropTypes.arrayOf(PropTypes.string),
@@ -121,6 +139,7 @@ Workspace.propTypes = {
};
Workspace.defaultProps = {
+ addWindow: () => {},
maximizedWindowIds: [],
windowIds: [],
};
diff --git a/src/components/WorkspaceAdd.js b/src/components/WorkspaceAdd.js
index 182ab6e4dd..4174baf391 100644
--- a/src/components/WorkspaceAdd.js
+++ b/src/components/WorkspaceAdd.js
@@ -15,6 +15,7 @@ import ns from '../config/css-ns';
import ManifestForm from '../containers/ManifestForm';
import ManifestListItem from '../containers/ManifestListItem';
import MiradorMenuButton from '../containers/MiradorMenuButton';
+import { IIIFDropTarget } from './IIIFDropTarget';
/**
* An area for managing manifests and adding them to workspace
@@ -29,6 +30,7 @@ export class WorkspaceAdd extends React.Component {
this.state = { addResourcesOpen: false };
this.setAddResourcesVisibility = this.setAddResourcesVisibility.bind(this);
+ this.handleDrop = this.handleDrop.bind(this);
}
/**
@@ -38,6 +40,17 @@ export class WorkspaceAdd extends React.Component {
this.setState({ addResourcesOpen: bool });
}
+ /** */
+ handleDrop({ manifestId, manifestJson }, props, monitor) {
+ const { addResource } = this.props;
+
+ if (manifestJson) {
+ addResource(manifestId, manifestJson, { provider: 'file' });
+ } else {
+ addResource(manifestId);
+ }
+ }
+
/**
* render
*/
@@ -57,91 +70,94 @@ export class WorkspaceAdd extends React.Component {
));
return (
-
- {catalog.length < 1 ? (
-
+
+
+ {catalog.length < 1 ? (
-
- {t('emptyResourceList')}
-
+
+ {t('emptyResourceList')}
+
+
-
- ) : (
-
- {t('miradorResources')}
-
- {manifestList}
-
-
- )}
-
(this.setAddResourcesVisibility(true))}
- >
-
- {t('addResource')}
-
+ ) : (
+
+ {t('miradorResources')}
+
+ {manifestList}
+
+
+ )}
+
(this.setAddResourcesVisibility(true))}
+ >
+
+ {t('addResource')}
+
-
-
- (this.setAddResourcesVisibility(false))}>
-
-
-
-
-
- {t('addResource')}
-
-
-
- (this.setAddResourcesVisibility(false))}
- onCancel={() => (this.setAddResourcesVisibility(false))}
- />
-
-
-
+
+ (this.setAddResourcesVisibility(false))}>
+
+
+
+
+
+ {t('addResource')}
+
+
+
+ (this.setAddResourcesVisibility(false))}
+ onCancel={() => (this.setAddResourcesVisibility(false))}
+ />
+
+
+
+
);
}
}
WorkspaceAdd.propTypes = {
+ addResource: PropTypes.func,
catalog: PropTypes.arrayOf(PropTypes.object),
classes: PropTypes.objectOf(PropTypes.string),
setWorkspaceAddVisibility: PropTypes.func.isRequired,
@@ -149,6 +165,7 @@ WorkspaceAdd.propTypes = {
};
WorkspaceAdd.defaultProps = {
+ addResource: () => {},
catalog: [],
classes: {},
t: key => key,
diff --git a/src/containers/Workspace.js b/src/containers/Workspace.js
index f4331bf1e4..91d10c1870 100644
--- a/src/containers/Workspace.js
+++ b/src/containers/Workspace.js
@@ -5,6 +5,7 @@ import { withStyles } from '@material-ui/core/styles';
import { withPlugins } from '../extend/withPlugins';
import { Workspace } from '../components/Workspace';
import { getMaximizedWindowsIds, getWindowIds, getWorkspaceType } from '../state/selectors';
+import * as actions from '../state/actions';
/**
* mapStateToProps - to hook up connect
@@ -21,6 +22,15 @@ const mapStateToProps = state => (
}
);
+/**
+ * mapDispatchToProps - used to hook up connect to action creators
+ * @memberof Workspace
+ * @private
+ */
+const mapDispatchToProps = {
+ addWindow: actions.addWindow,
+};
+
/**
* @param theme
*/
@@ -50,7 +60,7 @@ const styles = theme => ({
const enhance = compose(
withTranslation(),
withStyles(styles),
- connect(mapStateToProps),
+ connect(mapStateToProps, mapDispatchToProps),
withPlugins('Workspace'),
// further HOC go here
);
diff --git a/src/containers/WorkspaceAdd.js b/src/containers/WorkspaceAdd.js
index 85f66246f9..9fd38e4023 100644
--- a/src/containers/WorkspaceAdd.js
+++ b/src/containers/WorkspaceAdd.js
@@ -18,7 +18,10 @@ const mapStateToProps = state => ({ catalog: state.catalog });
* @memberof Workspace
* @private
*/
-const mapDispatchToProps = { setWorkspaceAddVisibility: actions.setWorkspaceAddVisibility };
+const mapDispatchToProps = {
+ addResource: actions.addResource,
+ setWorkspaceAddVisibility: actions.setWorkspaceAddVisibility,
+};
/**
*