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, +}; /** *