diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000000000000000000000000000000000000..2b7bafa5fa47672487a998df854e36347be43f58 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["@babel/preset-env", "@babel/preset-react"] +} diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000000000000000000000000000000000..0cdbd5053baa592f554a6970d34509f8c6964fa6 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +dist/ +config/ +coverage/ diff --git a/.eslintrc b/.eslintrc index b8f48e4c3b0426f3960e245402a3ddc870552458..4e5af05be9a51f0f56fa8316f5dd7eee98fe5823 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,17 +1,26 @@ { - "extends": "airbnb-base/legacy", + "env": { + "jest/globals": true + }, + "extends": ["airbnb","react-app"], "globals": { - "Mirador": true, - "jQuery": true + "page": true, + "document": true }, + "parser": "babel-eslint", + "plugins": ["jest"], "rules": { - "no-param-reassign": [ - 2, { - "props": true, - "ignorePropertyModificationsFor": [ - "$" - ] + "no-console": "off", + "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], + "require-jsdoc": ["error", { + "require": { + "FunctionDeclaration": true, + "MethodDefinition": true, + "ClassDeclaration": true, + "ArrowFunctionExpression": true, + "FunctionExpression": true } - ] + }], + "react/prefer-stateless-function": "off" } } diff --git a/.gitignore b/.gitignore index 70eb458983ac168d5f566e6ce8677c2b89607f5f..e7275d549b84616856341947282a2b97a73beb97 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,4 @@ -.grunt/ -_SpecRunner.html -bkp -css/discovery.css -css/mirador.css -css/jquery-ui-scoped.css -dev.html -discovery.html -js/discovery -node_modules/ -npm-debug.log +dist/ coverage/ -data/ecodices -data/BnF -data/Harvard -build/ -.idea/ -bower_components/ +node_modules/ +package-lock.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..b108f546f889bb2445d813a1f6ed6aa306860421 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2018 The Board of Trustees of the Leland Stanford Junior University + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index 091b80ee92f20b4abca6219606a0861bfdb42a54..3081cc045533e031045332c39568d5a1f6ef1e82 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,64 @@ -[](https://travis-ci.org/ProjectMirador/mirador?branch=master) -[](http://waffle.io/iiif/mirador) +## Running Mirador locally -# Mirador - -**Mirador is a multi-repository, configurable, extensible, and easy-to-integrate viewer and annotation creation and comparison environment for IIIF resources, ranging from deep-zooming artwork, to complex manuscript objects. It provides a tiling windowed environment for comparing multiple image-based resources, synchronised structural and visual navigation of content using openSeadragon, Open Annotation compliant annotation creation and viewing on deep-zoomable canvases, metadata display, bookreading, and bookmarking.** +1. Run `npm install` to install the dependencies. -### [See a Demo](http://projectmirador.org/demo/) -### [Getting Started](http://projectmirador.org/docs/docs/getting-started.html) +## Starting the project -### Run in Development -Mirador uses [Node.js](https://nodejs.org/) and a build system to assemble, test, and manage the development resources. If you have never used these tools before, you may need to install them. +```sh +$ npm start +``` - 1. Install [Node.js](https://nodejs.org/) - 2. Install the Grunt command line runner i.e. `npm install -g grunt-cli` - 1. Clone the Mirador repository - 1. Change into the Mirador directory - 1. Install all dependencies with `npm install`. Run `npm start`. - -### Run Tests -`npm test` +Then navigate to [http://127.0.0.1:4444/__tests__/integration/mirador/](http://127.0.0.1:4444/__tests__/integration/mirador/) -For more information, see the [Docs](http://projectmirador.org/docs/docs/getting-started.html), submit an [issue](https://github.com/projectmirador/mirador/issues), or ask on [Slack](http://bit.ly/iiif-slack). +### Instantiating Mirador -### Project Diagnostics - [](https://coveralls.io/github/ProjectMirador/mirador?branch=master&upToDate=true) +```javascript +var miradorInstance = Mirador.viewer({ + id: 'mirador' // id selector where Mirador should be instantiated +}); + +> miradorInstance +{ actions, store } +``` + +### Example Action + +Add a window: +```javascript +store.dispatch(actions.addWindow()); +``` + +To focus a window run: + +```javascript +store.dispatch(actions.focusWindow('window-1')) +``` + +### Check current state + +```javascript +store.getState() +``` + +## Running the tests + +```sh +$ npm test # For headless CI=true npm test +``` + +or to continually watch the source files + +```sh +$ npm run test:watch +``` + +## Linting the project + +```sh +$ npm run lint +``` + +## Debugging +Useful browser extensions for debugging/development purposes + - [React DevTools](https://github.com/facebook/react-devtools) + - [Redux DevTools](https://github.com/zalmoxisus/redux-devtools-extension) diff --git a/__tests__/fixtures/2.json b/__tests__/fixtures/2.json new file mode 100644 index 0000000000000000000000000000000000000000..b62a7061aaacec6c3117906d07b1ac7688a9c18c --- /dev/null +++ b/__tests__/fixtures/2.json @@ -0,0 +1,41 @@ +{ + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "http://iiif.io/api/presentation/2.0/example/fixtures/2/manifest.json", + "@type": "sc:Manifest", + "label": "Test 2 Manifest: Metadata Pairs", + "metadata": [ + { + "label": "date", + "value": "some date" + } + ], + "within": "http://iiif.io/api/presentation/2.0/example/fixtures/collection.json", + "sequences": [ + { + "@type": "sc:Sequence", + "label": "Test 2 Sequence 1", + "canvases": [ + { + "@id": "http://iiif.io/api/presentation/2.0/example/fixtures/canvas/2/c1.json", + "@type": "sc:Canvas", + "label": "Test 2 Canvas: 1", + "height": 1800, + "width": 1200, + "images": [ + { + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "http://iiif.io/api/presentation/2.0/example/fixtures/resources/page1-full.png", + "@type": "dctypes:Image", + "height": 1800, + "width": 1200 + }, + "on": "http://iiif.io/api/presentation/2.0/example/fixtures/canvas/2/c1.json" + } + ] + } + ] + } + ] +} diff --git a/__tests__/fixtures/24.json b/__tests__/fixtures/24.json new file mode 100644 index 0000000000000000000000000000000000000000..d307ecca6584a952a8e97722b20ef9a35c85c6df --- /dev/null +++ b/__tests__/fixtures/24.json @@ -0,0 +1,41 @@ +{ + "@context": "http://iiif.io/api/presentation/2/context.json", + "@id": "http://iiif.io/api/presentation/2.0/example/fixtures/24/manifest.json", + "@type": "sc:Manifest", + "label": "Test 24 Manifest: Image with IIIF Service - adapted with real image", + "within": "http://iiif.io/api/presentation/2.0/example/fixtures/collection.json", + "sequences": [ + { + "@type": "sc:Sequence", + "label": "Test 24 Sequence 1", + "canvases": [ + { + "@id": "http://iiif.io/api/presentation/2.0/example/fixtures/canvas/24/c1.json", + "@type": "sc:Canvas", + "label": "Test 24 Canvas: 1", + "height": 1800, + "width": 1200, + "images": [ + { + "@type": "oa:Annotation", + "motivation": "sc:painting", + "resource": { + "@id": "https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44/full/full/0/default.jpg", + "@type": "dctypes:Image", + "format": "image/jpeg", + "height": 3820, + "width": 5426, + "service": { + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44", + "profile": "http://iiif.io/api/image/2/level2.json" + } + }, + "on": "http://iiif.io/api/presentation/2.0/example/fixtures/canvas/24/c1.json" + } + ] + } + ] + } + ] +} diff --git a/__tests__/integration/mirador/basic.test.js b/__tests__/integration/mirador/basic.test.js new file mode 100644 index 0000000000000000000000000000000000000000..421aa78d4cebe045b39d12569f2b1573f4ee0ecc --- /dev/null +++ b/__tests__/integration/mirador/basic.test.js @@ -0,0 +1,21 @@ +describe('Basic end to end Mirador', () => { + beforeAll(async () => { + await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/'); + }); + it('has the correct page title', async () => { + const title = await page.title(); + expect(title).toBe('Mirador'); + }); + it('loads a manifest and displays it', async () => { + await expect(page).toFill('#manifestURL', 'https://purl.stanford.edu/sn904cj3429/iiif/manifest'); + await expect(page).toClick('#fetchBtn'); + // TODO: Refactor the app so we get rid of the wait + await page.waitFor(1000); + await expect(page).toMatchElement('li', { text: 'https://purl.stanford.edu/sn904cj3429/iiif/manifest' }); + await expect(page).toMatchElement( + 'h3', + "Peter's San Francisco Locator. The Birds-Eye-View Map of the Exposition City. Published by Locator Publishing Co", + ); + await expect(page).toMatchElement('div', /Color/); + }); +}); diff --git a/__tests__/integration/mirador/config_updating_from_instance.test.js b/__tests__/integration/mirador/config_updating_from_instance.test.js new file mode 100644 index 0000000000000000000000000000000000000000..6190e905366e9b79e6a1db58045b0307d6c52667 --- /dev/null +++ b/__tests__/integration/mirador/config_updating_from_instance.test.js @@ -0,0 +1,17 @@ +/* global miradorInstance */ + +describe('Config updating from instance', () => { + beforeAll(async () => { + await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/'); + }); + it('can modify the config via api', async () => { + await page.evaluate(() => { + const a = miradorInstance.actions.updateConfig({ foo: 'bat' }); + miradorInstance.store.dispatch(a); + }); + const config = await page.evaluate(() => ( + miradorInstance.store.getState().config + )); + await expect(config.foo).toBe('bat'); + }); +}); diff --git a/__tests__/integration/mirador/index.html b/__tests__/integration/mirador/index.html new file mode 100644 index 0000000000000000000000000000000000000000..baad43c8443d36f967984cbaf95e85cd4d12b662 --- /dev/null +++ b/__tests__/integration/mirador/index.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <meta name="theme-color" content="#000000"> + <title>Mirador</title> + </head> + <body> + <div id="mirador"></div> + <script>document.write("<script type='text/javascript' src='../../../dist/mirador.min.js?v=" + Date.now() + "'><\/script>");</script> + <script type="text/javascript"> + var miradorInstance = Mirador.viewer({ + id: 'mirador' + }); + </script> + </body> +</html> diff --git a/__tests__/integration/mirador/plugins.html b/__tests__/integration/mirador/plugins.html new file mode 100644 index 0000000000000000000000000000000000000000..d19bb7095af9a39367083da7f555e913b8c0ee13 --- /dev/null +++ b/__tests__/integration/mirador/plugins.html @@ -0,0 +1,100 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <meta name="theme-color" content="#000000"> + <title>Mirador</title> + </head> + <body> + <div id="mirador"></div> + <script src="../../../node_modules/react/umd/react.development.js"></script> + <script src="../../../node_modules/react-dom/umd/react-dom.development.js"></script> + <script>document.write("<script type='text/javascript' src='../../../dist/mirador.min.js?v=" + Date.now() + "'><\/script>");</script> + <script type="text/javascript"> + class MiradorShareButton extends React.Component { + constructor(props) { + super(props); + + this.handleClick = this.handleClick.bind(this); + } + handleClick() { + alert('Share this stuff') + } + render() { + return React.createElement('button', { className: 'share', onClick: this.handleClick}, 'Share'); + } + } + const miradorShareButton = { + name: 'miradorShareButton', + component: MiradorShareButton, + parent: 'WindowTopBarButtons', + } + Mirador.plugins.miradorShareButton = miradorShareButton; + class MiradorRuler extends React.Component { + constructor(props) { + super(props); + this._isMounted = false; + this.state = { + zooming: false, + } + this.zoomToColor = this.zoomToColor.bind(this); + } + componentDidMount() { + this._isMounted = true; + const that = this; + this.props.pluginParent().viewer.addHandler('zoom', (e) => { + if (that._isMounted) { + that.setState({ + zooming: true + }) + } + }) + // Super hacky don't do this for real + function resetStyle() { + if (that._isMounted) { + that.setState({ + zooming: false + }) + } + setTimeout(resetStyle, 750) + } + resetStyle(); + } + componentWillUnmount() { + this._isMounted = false; + if (this.props.pluginParent()) { + this.props.pluginParent().viewer.removeHandler('zoom'); + } + } + zoomToColor(zooming) { + if (zooming) { + return 'red' + } + return 'black' + } + render() { + return React.createElement('div', {className: 'mirador-ruler', style: { position: 'absolute', bottom: 0, color: this.zoomToColor(this.state.zooming)}}, 'I am a ruler') + } + } + const miradorRuler = { + name: 'miradorRuler', + component: MiradorRuler, + parent: 'OpenSeadragonViewer', + mapStateToProps: ({ manifests }, props) => { + return { + manifests // return the part of the state I need here. + } + }, + mapDispatchToProps: (dispatch) => { + return {} + }, + } + Mirador.plugins.miradorRuler = miradorRuler; + var miradorInstance = Mirador.viewer({ + id: 'mirador', + plugins: ['miradorShareButton', 'miradorRuler'] + }); + </script> + </body> +</html> diff --git a/__tests__/integration/mirador/plugins.test.js b/__tests__/integration/mirador/plugins.test.js new file mode 100644 index 0000000000000000000000000000000000000000..5c3e94634e4e9c79da7fbff2e60675fe68bde749 --- /dev/null +++ b/__tests__/integration/mirador/plugins.test.js @@ -0,0 +1,21 @@ +describe('Mirador plugin use', () => { + beforeAll(async () => { + await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/plugins.html'); + await expect(page).toFill('#manifestURL', 'https://purl.stanford.edu/sn904cj3429/iiif/manifest'); + await expect(page).toClick('#fetchBtn'); + // TODO: Refactor the app so we get rid of the wait + await page.waitFor(1000); + await expect(page).toClick('li button'); + }); + it('displays "Share Button" plugin', async () => { + await expect(page).toMatchElement('button', { text: 'Share' }); + page.on('dialog', async (dialog) => { + expect(dialog.message()).toBe('Share this stuff'); + await dialog.dismiss(); + }); + await expect(page).toClick('button.share'); + }); + it('displays "Ruler" plugin', async () => { + await expect(page).toMatchElement('.mirador-ruler'); + }); +}); diff --git a/__tests__/integration/mirador/window_actions.test.js b/__tests__/integration/mirador/window_actions.test.js new file mode 100644 index 0000000000000000000000000000000000000000..e16d8b4206dc738002ccb6c64f828c386f9bdf4b --- /dev/null +++ b/__tests__/integration/mirador/window_actions.test.js @@ -0,0 +1,15 @@ +describe('Window actions', () => { + beforeAll(async () => { + await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/'); + }); + it('opens a window and closes it', async () => { + await expect(page).toFill('#manifestURL', 'https://purl.stanford.edu/sn904cj3429/iiif/manifest'); + await expect(page).toClick('#fetchBtn'); + // TODO: Refactor the app so we get rid of the wait + await page.waitFor(1000); + await expect(page).toClick('li button'); + await expect(page).toMatchElement('.mirador-window'); + await expect(page).toClick('.mirador-window-close'); + await expect(page).not.toMatchElement('.mirador-window'); + }); +}); diff --git a/__tests__/integration/vanilla-js.test.js b/__tests__/integration/vanilla-js.test.js new file mode 100644 index 0000000000000000000000000000000000000000..e74fd045d388b8d05f90abdf623b6007a988af72 --- /dev/null +++ b/__tests__/integration/vanilla-js.test.js @@ -0,0 +1,17 @@ +describe('Plain JavaScript example', () => { + beforeAll(async () => { + await page.goto('http://127.0.0.1:4488/__tests__/integration/vanilla-js/'); + }); + it('has the correct page title', async () => { + const title = await page.title(); + expect(title).toBe('Examples'); + }); + it('loads a manifest and displays it', async () => { + await expect(page).toFill('#manifestURL', 'https://purl.stanford.edu/sn904cj3429/iiif/manifest'); + await expect(page).toClick('#fetchBtn'); + // TODO: Refactor the app so we get rid of the wait + await page.waitFor(1000); + const manifest = await page.$eval('#exampleManifest', e => e.innerHTML); + await expect(manifest).toMatch(/http:\/\/iiif\.io\/api\/presentation\/2\/context\.json/); + }); +}); diff --git a/__tests__/integration/vanilla-js/README.md b/__tests__/integration/vanilla-js/README.md new file mode 100644 index 0000000000000000000000000000000000000000..abe90a01e6af8c2423709eabc5bbdbc9c349897b --- /dev/null +++ b/__tests__/integration/vanilla-js/README.md @@ -0,0 +1,8 @@ +## Notes +To load external manifests, you will need to run the example pages from a serve. One easy way to do this is to run PythonHTTPServer: +`python -m SimpleHTTPServer` + +To inspect the Mirador 3 prototype in the web console with (code completion), you can refer to it as: +`m3core` + +This name is found in the `minimal_redux_poc/webpack.config.js` as the value of the `library` property. diff --git a/__tests__/integration/vanilla-js/index.html b/__tests__/integration/vanilla-js/index.html new file mode 100644 index 0000000000000000000000000000000000000000..432e8d0558ed56ebbf57b6a1758edea77066d11d --- /dev/null +++ b/__tests__/integration/vanilla-js/index.html @@ -0,0 +1,44 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <title>Examples</title> + <script src="../../../dist/m3core.umd.js"></script> + </head> + <body> + + <input id="manifestURL" type="text"/><button id="fetchBtn" type="submit">Fetch Manifest</button> + <pre id="exampleManifest"> + + </pre> + + <script> + document.getElementById("fetchBtn").addEventListener("click", function(){ + let val = document.getElementById("manifestURL").value; + let f = m3core.actions.fetchManifest(val); + m3core.store.dispatch(f); + }); + + m3core.store.subscribe(() => { + let contents = '' + let state = m3core.store.getState(); + let manifest = state.manifests[document.getElementById("manifestURL").value]; + switch (manifest.isFetching) { + case true: + contents = 'spinner'; + break; + case false: + if(manifest.error){ + contents = manifest.error.message; + } else { + contents = JSON.stringify(manifest.manifestation.__jsonld, 0, 3); + } + break; + default: contents = '' + } + document.getElementById("exampleManifest").innerHTML = contents; + }); + + </script> + </body> +</html> diff --git a/__tests__/src/actions/index.test.js b/__tests__/src/actions/index.test.js new file mode 100644 index 0000000000000000000000000000000000000000..1dd4c73127380b22a29b4a904e46290115d45955 --- /dev/null +++ b/__tests__/src/actions/index.test.js @@ -0,0 +1,209 @@ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import * as actions from '../../../src/actions/index'; +import ActionTypes from '../../../src/action-types'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +describe('actions', () => { + describe('addWindow', () => { + it('should create a new window with merged defaults', () => { + const options = { + id: 'helloworld', + canvasIndex: 1, + }; + + const expectedAction = { + type: ActionTypes.ADD_WINDOW, + payload: { + id: 'helloworld', + canvasIndex: 1, + collectionIndex: 0, + manifestId: null, + rangeId: null, + xywh: [0, 0, 400, 400], + rotation: null, + }, + }; + expect(actions.addWindow(options)).toEqual(expectedAction); + }); + }); + describe('removeWindow', () => { + it('removes the window and returns windowId', () => { + const id = 'abc123'; + const expectedAction = { + type: ActionTypes.REMOVE_WINDOW, + windowId: id, + }; + expect(actions.removeWindow(id)).toEqual(expectedAction); + }); + }); + describe('nextCanvas', () => { + it('moves to the next canvas', () => { + const id = 'abc123'; + const expectedAction = { + type: ActionTypes.NEXT_CANVAS, + windowId: id, + }; + expect(actions.nextCanvas(id)).toEqual(expectedAction); + }); + }); + describe('previousCanvas', () => { + it('moves to the previous canvas', () => { + const id = 'abc123'; + const expectedAction = { + type: ActionTypes.PREVIOUS_CANVAS, + windowId: id, + }; + expect(actions.previousCanvas(id)).toEqual(expectedAction); + }); + }); + describe('requestManifest', () => { + it('requests a manifest given a url', () => { + const id = 'abc123'; + const expectedAction = { + type: ActionTypes.REQUEST_MANIFEST, + manifestId: id, + }; + expect(actions.requestManifest(id)).toEqual(expectedAction); + }); + }); + describe('receiveManifest', () => { + it('moves to the previous canvas', () => { + const id = 'abc123'; + const json = { + id, + content: 'lots of metadata, canvases, and other IIIFy things', + }; + const expectedAction = { + type: ActionTypes.RECEIVE_MANIFEST, + manifestId: id, + manifestJson: json, + }; + expect(actions.receiveManifest(id, json)).toEqual(expectedAction); + }); + }); + describe('fetchManifest', () => { + let store = null; + beforeEach(() => { + store = mockStore({}); + }); + describe('success response', () => { + beforeEach(() => { + fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); // eslint-disable-line no-undef + }); + it('dispatches the REQUEST_MANIFEST action', () => { + store.dispatch(actions.fetchManifest('https://purl.stanford.edu/sn904cj3429/iiif/manifest')); + expect(store.getActions()).toEqual([ + { manifestId: 'https://purl.stanford.edu/sn904cj3429/iiif/manifest', type: 'REQUEST_MANIFEST' }, + ]); + }); + it('dispatches the REQUEST_MANIFEST and then RECEIVE_MANIFEST', () => { + store.dispatch(actions.fetchManifest('https://purl.stanford.edu/sn904cj3429/iiif/manifest')) + .then(() => { + const expectedActions = store.getActions(); + expect(expectedActions).toEqual([ + { manifestId: 'https://purl.stanford.edu/sn904cj3429/iiif/manifest', type: 'REQUEST_MANIFEST' }, + { manifestId: 'https://purl.stanford.edu/sn904cj3429/iiif/manifest', manifestJson: { data: '12345' }, type: 'RECEIVE_MANIFEST' }, + ]); + }); + }); + }); + describe('error response', () => { + it('dispatches the REQUEST_MANIFEST and then RECEIVE_MANIFEST', () => { + store.dispatch(actions.fetchManifest('https://purl.stanford.edu/sn904cj3429/iiif/manifest')) + .then(() => { + const expectedActions = store.getActions(); + expect(expectedActions).toEqual([ + { manifestId: 'https://purl.stanford.edu/sn904cj3429/iiif/manifest', type: 'REQUEST_MANIFEST' }, + { manifestId: 'https://purl.stanford.edu/sn904cj3429/iiif/manifest', error: new Error('invalid json response body at undefined reason: Unexpected end of JSON input'), type: 'RECEIVE_MANIFEST_FAILURE' }, + ]); + }); + }); + }); + }); + describe('removeManifest', () => { + it('removes an existing manifest', () => { + const expectedAction = { + type: ActionTypes.REMOVE_MANIFEST, + manifestId: 'foo', + }; + expect(actions.removeManifest('foo')).toEqual(expectedAction); + }); + }); + describe('requestInfoResponse', () => { + it('requests an infoResponse from given a url', () => { + const id = 'abc123'; + const expectedAction = { + type: ActionTypes.REQUEST_INFO_RESPONSE, + infoId: id, + }; + expect(actions.requestInfoResponse(id)).toEqual(expectedAction); + }); + }); + describe('receiveInfoResponse', () => { + it('recieves an infoResponse', () => { + const id = 'abc123'; + const json = { + id, + content: 'image information request', + }; + const expectedAction = { + type: ActionTypes.RECEIVE_INFO_RESPONSE, + infoId: id, + infoJson: json, + }; + expect(actions.receiveInfoResponse(id, json)).toEqual(expectedAction); + }); + }); + describe('fetchInfoResponse', () => { + let store = null; + beforeEach(() => { + store = mockStore({}); + }); + describe('success response', () => { + beforeEach(() => { + fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); // eslint-disable-line no-undef + }); + it('dispatches the REQUEST_MANIFEST action', () => { + store.dispatch(actions.fetchInfoResponse('https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json')); + expect(store.getActions()).toEqual([ + { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', type: 'REQUEST_INFO_RESPONSE' }, + ]); + }); + it('dispatches the REQUEST_MANIFEST and then RECEIVE_MANIFEST', () => { + store.dispatch(actions.fetchInfoResponse('https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json')) + .then(() => { + const expectedActions = store.getActions(); + expect(expectedActions).toEqual([ + { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', type: 'REQUEST_INFO_RESPONSE' }, + { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', infoJson: { data: '12345' }, type: 'RECEIVE_INFO_RESPONSE' }, + ]); + }); + }); + }); + describe('error response', () => { + it('dispatches the REQUEST_INFO_RESPONSE and then RECEIVE_INFO_RESPONSE', () => { + store.dispatch(actions.fetchInfoResponse('https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json')) + .then(() => { + const expectedActions = store.getActions(); + expect(expectedActions).toEqual([ + { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', type: 'REQUEST_INFO_RESPONSE' }, + { infoId: 'https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/info.json', error: new Error('invalid json response body at undefined reason: Unexpected end of JSON input'), type: 'RECEIVE_INFO_RESPONSE_FAILURE' }, + ]); + }); + }); + }); + }); + describe('removeInfoResponse', () => { + it('removes an existing infoResponse', () => { + const expectedAction = { + type: ActionTypes.REMOVE_INFO_RESPONSE, + infoId: 'foo', + }; + expect(actions.removeInfoResponse('foo')).toEqual(expectedAction); + }); + }); +}); diff --git a/__tests__/src/components/Display.test.js b/__tests__/src/components/Display.test.js new file mode 100644 index 0000000000000000000000000000000000000000..0ef95154d5265e706f9bf8b22d5789373d80e88a --- /dev/null +++ b/__tests__/src/components/Display.test.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import Display from '../../../src/components/Display'; +import ManifestMetadata from '../../../src/components/ManifestMetadata'; +import fixture from '../../fixtures/2.json'; + +describe('Display', () => { + it('renders without an error', () => { + const wrapper = shallow(<Display manifest={{}} />); + expect(wrapper.contains(<div className="Display"><div id="exampleManifest" className=""><ManifestMetadata manifest={{}} /></div></div>)).toBe(true); + }); + it('sets class based on manifest state', () => { + let wrapper = shallow(<Display manifest={{ isFetching: true }} />); + expect(wrapper.find('.mirador-fetching').length).toBe(1); + + wrapper = shallow(<Display manifest={{ error: true }} />); + expect(wrapper.find('.mirador-error').length).toBe(1); + }); + it('displays content', () => { + let wrapper = shallow(<Display manifest={{ isFetching: true }} />); + expect(wrapper.text()).toBe('☕'); + + wrapper = shallow(<Display manifest={{ error: { message: 'bad things' } }} />); + expect(wrapper.text()).toBe('bad things'); + + wrapper = shallow(<Display manifest={{ json: fixture }} />); + expect(wrapper.find(ManifestMetadata).length).toBe(1); + }); +}); diff --git a/__tests__/src/components/ManifestListItem.test.js b/__tests__/src/components/ManifestListItem.test.js new file mode 100644 index 0000000000000000000000000000000000000000..2be1cd2218fd179df06542eefbea5ead915ca256 --- /dev/null +++ b/__tests__/src/components/ManifestListItem.test.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { store } from '../../../src/store'; +import ManifestListItem from '../../../src/components/ManifestListItem'; + +describe('ManifestListItem', () => { + it('renders without an error', () => { + const wrapper = shallow(<ManifestListItem manifest="http://example.com" store={store} />).dive(); + expect(wrapper.find('li.mirador-manifest-list-item').length).toBe(1); + expect(wrapper.find('button').length).toBe(1); + expect(wrapper.find('button').text()).toEqual('http://example.com'); + }); + it('updates and adds window when button clicked', () => { + const wrapper = shallow(<ManifestListItem manifest="http://example.com" store={store} />).dive(); + expect(store.getState().windows.length).toEqual(0); + wrapper.find('button').simulate('click'); + expect(store.getState().windows.length).toEqual(1); + }); +}); diff --git a/__tests__/src/components/ManifestMetadata.test.js b/__tests__/src/components/ManifestMetadata.test.js new file mode 100644 index 0000000000000000000000000000000000000000..404ab7c47741ce9b68a86ee7f0761446e3e86352 --- /dev/null +++ b/__tests__/src/components/ManifestMetadata.test.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { actions, store } from '../../../src/store'; +import ManifestMetadata from '../../../src/components/ManifestMetadata'; +import fixture from '../../fixtures/2.json'; + +describe('ManifestMetadata', () => { + let wrapper; + let manifest; + beforeEach(() => { + store.dispatch(actions.receiveManifest('foo', fixture)); + manifest = store.getState().manifests.foo; + wrapper = shallow(<ManifestMetadata manifest={manifest} />); + }); + + it('renders without an error', () => { + expect(wrapper.find('h3').text()).toBe('Test 2 Manifest: Metadata Pairs'); + expect(wrapper.find('.mirador-description').length).toBe(1); + }); +}); diff --git a/__tests__/src/components/Window.test.js b/__tests__/src/components/Window.test.js new file mode 100644 index 0000000000000000000000000000000000000000..deedb4a02ed0cdc508df53b18d5d9f8a46c01608 --- /dev/null +++ b/__tests__/src/components/Window.test.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { actions, store } from '../../../src/store'; +import Window from '../../../src/components/Window'; +import fixture from '../../fixtures/24.json'; + +describe('Window', () => { + let wrapper; + let window; + describe('with a manifest present', () => { + beforeEach(() => { + store.dispatch(actions.receiveManifest('foo', fixture)); + store.dispatch(actions.addWindow({ manifestId: 'foo' })); + [window] = store.getState().windows; + wrapper = mount( + <Window store={store} id={window.id} />, + // We need to attach this to something created by our JSDOM instance. + // Also need to provide context of the store so that connected sub components + // can render effectively. + { attachTo: document.getElementById('main'), context: { store } }, + ); + }); + + afterEach(() => { + store.dispatch(actions.removeManifest('foo')); + }); + + it('returns the width and height style attribute', () => { + wrapper = shallow(<Window store={store} id={window.id} />, { context: { store } }); + expect(wrapper.dive().instance().styleAttributes()) + .toEqual({ width: '400px', height: '400px' }); + }); + + it('renders without an error', () => { + expect(wrapper.find('.mirador-window').prop('style')).toHaveProperty('width', '400px'); + expect(wrapper.find('.mirador-window').prop('style')).toHaveProperty('height', '400px'); + expect(wrapper.find('div.mirador-window').length).toBe(1); + expect(wrapper.find('div.mirador-window img').prop('src')).toBe('http://placekitten.com/200/300'); + }); + + it('renders the viewer', () => { + expect(wrapper.find('WindowViewer').length).toBe(1); + }); + }); + describe('without a manifest present', () => { + beforeEach(() => { + store.dispatch(actions.addWindow({ manifestId: 'foo' })); + [window] = store.getState().windows; + wrapper = shallow(<Window store={store} id={window.id} />, { context: { store } }).dive(); + }); + + it('returns the width and height style attribute', () => { + expect(wrapper.instance().styleAttributes()) + .toEqual({ width: '400px', height: '400px' }); + }); + + it('renders without an error', () => { + expect(wrapper.find('.mirador-window').prop('style')).toHaveProperty('width', '400px'); + expect(wrapper.find('.mirador-window').prop('style')).toHaveProperty('height', '400px'); + expect(wrapper.find('div.mirador-window img').length).toBe(0); + }); + + it('does not render the viewer', () => { + expect(wrapper.find('WindowViewer').length).toBe(0); + }); + }); +}); diff --git a/__tests__/src/components/WindowBackground.test.js b/__tests__/src/components/WindowBackground.test.js new file mode 100644 index 0000000000000000000000000000000000000000..f3fe49abf9c40a65321ae5c3756756aaea7f392c --- /dev/null +++ b/__tests__/src/components/WindowBackground.test.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { actions, store } from '../../../src/store'; +import WindowBackground from '../../../src/components/WindowBackground'; +import fixture from '../../fixtures/24.json'; + +describe('WindowBackground', () => { + let wrapper; + beforeEach(() => { + store.dispatch(actions.receiveManifest('foo', fixture)); + store.dispatch(actions.addWindow({ manifestId: 'foo' })); + const manifest = store.getState().manifests.foo; + wrapper = shallow(<WindowBackground manifest={manifest} />); + }); + + it('renders without an error', () => { + expect(wrapper.find('img').prop('src')).toBe('http://placekitten.com/200/300'); + }); + + it('without a manifest, renders an empty', () => { + expect(shallow(<WindowBackground manifest={null} />).html()).toBe('<div></div>'); + }); +}); diff --git a/__tests__/src/components/WindowTopBar.test.js b/__tests__/src/components/WindowTopBar.test.js new file mode 100644 index 0000000000000000000000000000000000000000..c03aeae9695da95d4bf727b3e77d1ba55ccc543f --- /dev/null +++ b/__tests__/src/components/WindowTopBar.test.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { actions, store } from '../../../src/store'; +import WindowTopBar from '../../../src/components/WindowTopBar'; +import fixture from '../../fixtures/24.json'; + +describe('Window', () => { + let wrapper; + let window; + beforeEach(() => { + store.dispatch(actions.receiveManifest('foo', fixture)); + store.dispatch(actions.addWindow({ manifestId: 'foo' })); + const manifest = store.getState().manifests.foo; + [window] = store.getState().windows; + wrapper = mount( + <WindowTopBar store={store} manifest={manifest} windowId={window.id} />, + // We need to attach this to something created by our JSDOM instance. + // Also need to provide context of the store so that connected sub components + // can render effectively. + { attachTo: document.getElementById('main'), context: { store } }, + ); + }); + + it('renders without an error', () => { + expect(wrapper.find('div.mirador-window-top-bar h3').text()).toBe('Test 24 Manifest: Image with IIIF Service - adapted with real image'); + expect(wrapper.find('button.mirador-window-close')); + }); +}); diff --git a/__tests__/src/components/WindowViewer.test.js b/__tests__/src/components/WindowViewer.test.js new file mode 100644 index 0000000000000000000000000000000000000000..2bed4303aa1be9c591fa3df8d8a0b33db9605b0d --- /dev/null +++ b/__tests__/src/components/WindowViewer.test.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { actions, store } from '../../../src/store'; +import WindowViewer from '../../../src/components/WindowViewer'; +import fixture from '../../fixtures/24.json'; + +describe('WindowViewer', () => { + let wrapper; + let window; + beforeEach(() => { + store.dispatch(actions.receiveManifest('foo', fixture)); + store.dispatch(actions.addWindow({ manifestId: 'foo' })); + const manifest = store.getState().manifests.foo; + [window] = store.getState().windows; + wrapper = mount( + <WindowViewer manifest={manifest} window={window} />, + // We need to attach this to something created by our JSDOM instance. + // Also need to provide context of the store so that connected sub components + // can render effectively. + { attachTo: document.getElementById('main'), context: { store } }, + ); + }); + + it('OpenSeaDragon instantiates', () => { + // Hacky as heck thing we have to do, as `#find` (and other methods on ReactWrapper) + // do not effectively find elements (even though they are there) + expect(wrapper.render().find('.openseadragon-canvas').length).toBe(1); + }); + it('has navigation controls', () => { + expect(wrapper.find('.mirador-osd-navigation').length).toBe(1); + }); +}); diff --git a/__tests__/src/components/Workspace.test.js b/__tests__/src/components/Workspace.test.js new file mode 100644 index 0000000000000000000000000000000000000000..c93549885a40ad040d261befa94b8ae94aa825a5 --- /dev/null +++ b/__tests__/src/components/Workspace.test.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { actions, store } from '../../../src/store'; +import Workspace from '../../../src/components/Workspace'; +import fixture from '../../fixtures/2.json'; + +describe('Workspace', () => { + let wrapper; + beforeEach(() => { + store.dispatch(actions.receiveManifest('foo', fixture)); + store.dispatch(actions.addWindow({ manifestId: 'foo' })); + wrapper = shallow(<Workspace store={store} />).dive(); + }); + + it('renders without an error', () => { + const window = store.getState().windows[0]; + expect(wrapper.find('div.mirador-workspace').length).toBe(1); + expect(wrapper.find(`#${window.id}`).length).toBe(1); + }); +}); diff --git a/__tests__/src/reducers/config.test.js b/__tests__/src/reducers/config.test.js new file mode 100644 index 0000000000000000000000000000000000000000..11836e06ef338f4cf4272d39f594a07e0d7c2f71 --- /dev/null +++ b/__tests__/src/reducers/config.test.js @@ -0,0 +1,42 @@ +import reducer from '../../../src/reducers/config'; +import ActionTypes from '../../../src/action-types'; + +describe('config reducer', () => { + describe('SET_CONFIG', () => { + it('should handle SET_CONFIG', () => { + expect(reducer({}, { + type: ActionTypes.SET_CONFIG, + config: { manifestVersion: 'v3' }, + })).toEqual({ + manifestVersion: 'v3', + }); + }); + it('does not deep merge', () => { + expect(reducer({ stuff: { foo: 'bar' } }, { + type: ActionTypes.SET_CONFIG, + config: { stuff: { foo: 'bat' } }, + })).toEqual({ + stuff: { foo: 'bat' }, + }); + }); + }); + describe('UPDATE_CONFIG', () => { + it('should handle UPDATE_CONFIG', () => { + expect(reducer({}, { + type: ActionTypes.UPDATE_CONFIG, + config: { manifestVersion: 'v3' }, + })).toEqual({ + manifestVersion: 'v3', + }); + }); + it('does a deep merge', () => { + expect(reducer({ stuff: { foo: 'bar' }, hello: 'world' }, { + type: ActionTypes.UPDATE_CONFIG, + config: { stuff: { foo: 'bat' } }, + })).toEqual({ + stuff: { foo: 'bat' }, + hello: 'world', + }); + }); + }); +}); diff --git a/__tests__/src/reducers/infoResponse.test.js b/__tests__/src/reducers/infoResponse.test.js new file mode 100644 index 0000000000000000000000000000000000000000..7841eb8b5e882383bbc88822d720f92afc38558c --- /dev/null +++ b/__tests__/src/reducers/infoResponse.test.js @@ -0,0 +1,70 @@ +import reducer from '../../../src/reducers/infoResponses'; +import ActionTypes from '../../../src/action-types'; + +describe('manifests reducer', () => { + it('should handle REQUEST_INFO_RESPONSE', () => { + expect(reducer({}, { + type: ActionTypes.REQUEST_INFO_RESPONSE, + infoId: 'abc123', + })).toEqual({ + abc123: { + isFetching: true, + }, + }); + }); + it('should handle RECEIVE_INFO_RESPONSE', () => { + expect(reducer( + { + abc123: { + isFetching: true, + }, + }, + { + type: ActionTypes.RECEIVE_INFO_RESPONSE, + infoId: 'abc123', + infoJson: { + id: 'abc123', + '@type': 'sc:Manifest', + content: 'lots of canvases and metadata and such', + }, + }, + )).toMatchObject({ + abc123: { + isFetching: false, + json: {}, + }, + }); + }); + it('should handle RECEIVE_INFO_RESPONSE_FAILURE', () => { + expect(reducer( + { + abc123: { + isFetching: true, + }, + }, + { + type: ActionTypes.RECEIVE_INFO_RESPONSE_FAILURE, + infoId: 'abc123', + error: "This institution didn't enable CORS.", + }, + )).toEqual({ + abc123: { + isFetching: false, + error: "This institution didn't enable CORS.", + }, + }); + }); + it('should handle REMOVE_INFO_RESPONSE', () => { + expect(reducer( + { + abc123: { + stuff: 'foo', + }, + }, + { + type: ActionTypes.REMOVE_INFO_RESPONSE, + infoId: 'abc123', + }, + )).toEqual({}); + }); +}); diff --git a/__tests__/src/reducers/manifests.test.js b/__tests__/src/reducers/manifests.test.js new file mode 100644 index 0000000000000000000000000000000000000000..88e14f61bb441e076d6b4b1057e35261af9cc3a4 --- /dev/null +++ b/__tests__/src/reducers/manifests.test.js @@ -0,0 +1,70 @@ +import reducer from '../../../src/reducers/manifests'; +import ActionTypes from '../../../src/action-types'; + +describe('manifests reducer', () => { + it('should handle REQUEST_MANIFEST', () => { + expect(reducer({}, { + type: ActionTypes.REQUEST_MANIFEST, + manifestId: 'abc123', + })).toEqual({ + abc123: { + isFetching: true, + }, + }); + }); + it('should handle RECEIVE_MANIFEST', () => { + expect(reducer( + { + abc123: { + isFetching: true, + }, + }, + { + type: ActionTypes.RECEIVE_MANIFEST, + manifestId: 'abc123', + manifestJson: { + id: 'abc123', + '@type': 'sc:Manifest', + content: 'lots of canvases and metadata and such', + }, + }, + )).toMatchObject({ + abc123: { + isFetching: false, + manifestation: {}, + }, + }); + }); + it('should handle RECEIVE_MANIFEST_FAILURE', () => { + expect(reducer( + { + abc123: { + isFetching: true, + }, + }, + { + type: ActionTypes.RECEIVE_MANIFEST_FAILURE, + manifestId: 'abc123', + error: "This institution didn't enable CORS.", + }, + )).toEqual({ + abc123: { + isFetching: false, + error: "This institution didn't enable CORS.", + }, + }); + }); + it('should handle REMOVE_MANIFEST', () => { + expect(reducer( + { + abc123: { + stuff: 'foo', + }, + }, + { + type: ActionTypes.REMOVE_MANIFEST, + manifestId: 'abc123', + }, + )).toEqual({}); + }); +}); diff --git a/__tests__/src/reducers/windows.test.js b/__tests__/src/reducers/windows.test.js new file mode 100644 index 0000000000000000000000000000000000000000..a4d485654395cbe83efa46e7533113bb572e4221 --- /dev/null +++ b/__tests__/src/reducers/windows.test.js @@ -0,0 +1,54 @@ +import reducer from '../../../src/reducers/windows'; +import ActionTypes from '../../../src/action-types'; + +describe('windows reducer', () => { + it('should handle ADD_WINDOW', () => { + expect(reducer([], { + type: ActionTypes.ADD_WINDOW, + })).toEqual([ + {}, + ]); + }); + it('should handle REMOVE_WINDOW', () => { + expect(reducer([ + { + id: 'abc123', + }, + ], { + type: ActionTypes.REMOVE_WINDOW, + windowId: 'abc123', + })).toEqual([]); + }); + it('should handle NEXT_CANVAS', () => { + expect(reducer([ + { + id: 'abc123', + canvasIndex: 1, + }, + ], { + type: ActionTypes.NEXT_CANVAS, + windowId: 'abc123', + })).toEqual([ + { + id: 'abc123', + canvasIndex: 2, + }, + ]); + }); + it('should handle PREVIOUS_CANVAS', () => { + expect(reducer([ + { + id: 'abc123', + canvasIndex: 4, + }, + ], { + type: ActionTypes.PREVIOUS_CANVAS, + windowId: 'abc123', + })).toEqual([ + { + id: 'abc123', + canvasIndex: 3, + }, + ]); + }); +}); diff --git a/__tests__/src/reducers/workspace.test.js b/__tests__/src/reducers/workspace.test.js new file mode 100644 index 0000000000000000000000000000000000000000..e1e24a7fd857c4254e389bfd9f0ba42e91d5f26f --- /dev/null +++ b/__tests__/src/reducers/workspace.test.js @@ -0,0 +1,13 @@ +import reducer from '../../../src/reducers/workspace'; +import ActionTypes from '../../../src/action-types'; + +describe('workspace reducer', () => { + it('should handle FOCUS_WINDOW', () => { + expect(reducer([], { + type: ActionTypes.FOCUS_WINDOW, + windowId: 'abc123', + })).toEqual({ + focusedWindowId: 'abc123', + }); + }); +}); diff --git a/config/paths.js b/config/paths.js new file mode 100644 index 0000000000000000000000000000000000000000..2060badce016cbe023d2af06e9a9015114a77858 --- /dev/null +++ b/config/paths.js @@ -0,0 +1,98 @@ +const path = require('path'); +const fs = require('fs'); +const url = require('url'); + +// Make sure any symlinks in the project folder are resolved: +// https://github.com/facebook/create-react-app/issues/637 +const appDirectory = fs.realpathSync(process.cwd()); + +/** + * + * @param relativePath + * @returns {string} + */ +const resolveApp = relativePath => path.resolve(appDirectory, relativePath); + +const envPublicUrl = process.env.PUBLIC_URL; + +/** + * + * @param inputPath + * @param needsSlash + * @returns {*} + */ +function ensureSlash(inputPath, needsSlash) { + const hasSlash = inputPath.endsWith('/'); + if (hasSlash && !needsSlash) { + return inputPath.substr(0, inputPath.length - 1); + } else if (!hasSlash && needsSlash) { + return `${inputPath}/`; + } else { + return inputPath; + } +} + +/** + * + * @param appPackageJson + * @returns {string | *} + */ +const getPublicUrl = appPackageJson => envPublicUrl || require(appPackageJson).homepage; + +/** + * + * @param appPackageJson + * @returns {*} + */ +function getServedPath(appPackageJson) { + const publicUrl = getPublicUrl(appPackageJson); + const servedUrl = + envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : '/'); + return ensureSlash(servedUrl, true); +} + +const moduleFileExtensions = [ + 'web.mjs', + 'mjs', + 'web.js', + 'js', + 'web.ts', + 'ts', + 'web.tsx', + 'tsx', + 'json', + 'web.jsx', + 'jsx', +]; + +/** + * + * @param resolveFn + * @param filePath + * @returns {*} + */ +const resolveModule = (resolveFn, filePath) => { + const extension = moduleFileExtensions.find(extension => fs.existsSync(resolveFn(`${filePath}.${extension}`))); + + if (extension) { + return resolveFn(`${filePath}.${extension}`); + } + + return resolveFn(`${filePath}.js`); +}; + +module.exports = { + dotenv: resolveApp('.env'), + appPath: resolveApp('.'), + appBuild: resolveApp('build'), + appDist: resolveApp('dist'), + appIndexJs: resolveModule(resolveApp, 'src/index'), + appPackageJson: resolveApp('package.json'), + appSrc: resolveApp('src'), + testsSetup: resolveModule(resolveApp, 'src/setupTests'), + appNodeModules: resolveApp('node_modules'), + publicUrl: getPublicUrl(resolveApp('package.json')), + servedPath: getServedPath(resolveApp('package.json')), +}; + +module.exports.moduleFileExtensions = moduleFileExtensions; diff --git a/jest-puppeteer.config.js b/jest-puppeteer.config.js new file mode 100644 index 0000000000000000000000000000000000000000..e8fa2f0c870485fc52504f2d9988144620d6cb1c --- /dev/null +++ b/jest-puppeteer.config.js @@ -0,0 +1,9 @@ +module.exports = { + launch: { + headless: process.env.CI === 'true', + }, + server: { + command: 'npm run server -- -p 4488', + port: 4488, + }, +}; diff --git a/jest.json b/jest.json new file mode 100644 index 0000000000000000000000000000000000000000..433054597fb59f8bb7db77770ec184b049572868 --- /dev/null +++ b/jest.json @@ -0,0 +1,15 @@ +{ + "collectCoverageFrom": [ + "src/**/*.{js,jsx}" + ], + "coverageDirectory": "<rootDir>/coverage", + "coverageReporters": ["html"], + "setupFiles": [ + "<rootDir>/setupJest.js" + ], + "testMatch": [ + "<rootDir>/**/__tests__/**/*.{js,jsx}", + "<rootDir>/src/**/?(*.)(spec|test|unit).{js,jsx}" + ], + "preset": "jest-puppeteer" +} diff --git a/package.json b/package.json index 929e59f379186b063ad0154ffd4e202f75cb2461..255066f6dbe593c0782533ffd7e00212908216e1 100644 --- a/package.json +++ b/package.json @@ -1,90 +1,73 @@ { - "name": "mirador", - "version": "2.6.1", - "description": "Multi-window image viewer, a web-based tool to support researcher goals", - "files": ["dist/*"], - "repository": { - "type": "git", - "url": "https://github.com/ProjectMirador/mirador" - }, - "license": "Apache-2.0", - "devDependencies": { - "eslint": "^3.17.1", - "eslint-config-airbnb-base": "^11.1.1", - "eslint-plugin-import": "^2.2.0", - "gitbook": "3.2.0", - "gitbook-cli": "2.3.0", - "gitbook-plugin-search": "2.2.1", - "grunt": ">=0.4.5 <1.0.0", - "grunt-cli": "*", - "grunt-contrib-clean": "*", - "grunt-contrib-compress": "*", - "grunt-contrib-concat": "*", - "grunt-contrib-connect": "*", - "grunt-contrib-copy": "*", - "grunt-contrib-cssmin": "*", - "grunt-contrib-jasmine": "^1.2.0", - "grunt-contrib-jshint": "*", - "grunt-contrib-less": "^1.4.0", - "grunt-contrib-uglify": "*", - "grunt-contrib-watch": "*", - "grunt-css-selectors": "^1.3.0", - "grunt-git-describe": "*", - "grunt-githooks": "^0.3.1", - "grunt-template-jasmine-istanbul": "^0.3.3", - "gruntify-eslint": "^3.1.0", - "jasmine-core": "^2.1.3", - "jasmine-jquery": "^2.0.5", - "jstree": "^3.3.4", - "karma": "^1.5", - "karma-chrome-launcher": "^0.1.7", - "karma-coverage": "^0.2.7", - "karma-coveralls": "^1.1.2", - "karma-firefox-launcher": "^0.1.3", - "karma-jasmine": "^0.3.5", - "karma-json-fixtures-preprocessor": "0.0.6", - "karma-phantomjs-launcher": "^1.0", - "karma-safari-launcher": "^0.1.1", - "karma-sauce-launcher": "^0.2.10", - "karma-spec-reporter": "^0.0.16", - "sinon": "^1.17.6" - }, + "name": "minimal_redux_poc", + "version": "1.0.0", + "description": "", + "main": "index.js", "scripts": { - "start": "./node_modules/.bin/grunt serve", - "test": "./node_modules/.bin/karma start ./karma.conf.js", - "travis": "./node_modules/.bin/grunt ci --verbose --force & npm run test", - "update_demo": "./bin/update_demo.sh", - "release": "./bin/create_release.sh", - "lint": "./node_modules/.bin/eslint js/src" + "lint": "node_modules/.bin/eslint ./", + "server": "node_modules/.bin/http-server", + "test": "npm run build && npm run lint && jest -c jest.json", + "test:coverage": "jest -c jest.json --coverage", + "test:watch": "jest -c jest.json --watch", + "build": "webpack --mode=production", + "build:dev": "webpack --mode=development", + "build:watch": "webpack --watch --mode=development", + "start": "npm run build:dev && concurrently \"npm run build:watch\" \"npm run server -- -p 4444\"" }, + "license": "Apache-2.0", + "contributors": [ + "Drew Winget <scipioaffricanus@gmail.com> (https://aeschylus.net/)", + "Jack Reed <phillipjreed@gmail.com> (https://www.jack-reed.com)" + ], + "repository": "https://github.com/ProjectMirador/research-and-demos/tree/master/minimal_redux_poc", "dependencies": { - "bootbox": "^4.4.0", - "bootstrap": "^3.3.7", - "d3": "^3.5.17", - "font-awesome": "^4.7.0", - "handlebars": "^4.0.11", - "i18next": "^4.2.0", - "i18next-browser-languagedetector": "^1.0.1", - "i18next-xhr-backend": "^1.3.0", - "iiif-evented-canvas": "^0.0.3-beta", - "iiif-layout-functions": "0.0.2-alpha", - "javascript-state-machine": "^2.3.4", - "jquery": "^3.1.1", - "jquery-migrate": "^3.0.0", - "jquery-plugin": "git+https://github.com/cowboy/jquery-tiny-pubsub.git", - "jquery-ui-dist": "^1.12.1", - "jquery.scrollto": "^1.4.12", - "material-design-icons": "^3.0.1", - "mousetrap": "^1.4.6", - "openseadragon": "^2.2.1", - "paper": "^0.10.2", - "qtip2": "^3.0.3", - "sanitize-html": "practicefusion/sanitize-html#include-dist-files", - "select2": "^4.0.3", - "simple-pagination.js": "^1.6.0", - "spectrum-colorpicker": "^1.8.0", - "tinymce": "^4.1.7", - "urijs": "^1.16.1" + "css-ns": "^1.2.2", + "deepmerge": "^3.0.0", + "manifesto.js": "^3.0.9", + "node-fetch": "2.1.1", + "node-sass": "^4.9.2", + "openseadragon": "^2.4.0", + "prop-types": "^15.6.2", + "react": "^16.4.0", + "react-dom": "^16.4.0", + "react-redux": "^5.0.7", + "redux": "3.7.2", + "redux-devtools-extension": "^2.13.2", + "redux-thunk": "2.2.0", + "sass-loader": "^7.1.0" }, - "engine": "node < 7.0.0" + "devDependencies": { + "@babel/core": "^7.2.0", + "@babel/preset-env": "^7.2.0", + "@babel/preset-react": "^7.0.0", + "babel-core": "^7.0.0-bridge.0", + "babel-eslint": "9.0.0", + "babel-jest": "^23.6.0", + "babel-loader": "^8.0.4", + "babel-plugin-named-asset-import": "^0.2.3", + "concurrently": "^4.0.1", + "css-loader": "^1.0.0", + "enzyme": "^3.4.4", + "enzyme-adapter-react-16": "^1.2.0", + "eslint": "^5.10.0", + "eslint-config-airbnb": "^17.1.0", + "eslint-config-react-app": "^3.0.5", + "eslint-loader": "^2.1.1", + "eslint-plugin-flowtype": "^3.2.0", + "eslint-plugin-import": "^2.14.0", + "eslint-plugin-jest": "^22.1.2", + "eslint-plugin-jsx-a11y": "^6.1.2", + "eslint-plugin-react": "^7.11.1", + "http-server": "^0.11.1", + "jest": "^22.4.4", + "jest-fetch-mock": "^1.5.0", + "jest-puppeteer": "^3.0.1", + "puppeteer": "^1.4.0", + "react-dev-utils": "^6.1.1", + "redux-mock-store": "^1.5.1", + "style-loader": "^0.22.1", + "terser-webpack-plugin": "^1.2.1", + "webpack": "^4.27.1", + "webpack-cli": "^3.1.2" + } } diff --git a/setupJest.js b/setupJest.js new file mode 100644 index 0000000000000000000000000000000000000000..d5399cbfdb67ee85a39a98a555cb8c2275f5d535 --- /dev/null +++ b/setupJest.js @@ -0,0 +1,21 @@ +// Setup Jest to mock fetch + +import { JSDOM } from 'jsdom'; // eslint-disable-line import/no-extraneous-dependencies +import fetch from 'jest-fetch-mock'; // eslint-disable-line import/no-extraneous-dependencies +import Enzyme from 'enzyme'; // eslint-disable-line import/no-extraneous-dependencies +import Adapter from 'enzyme-adapter-react-16'; // eslint-disable-line import/no-extraneous-dependencies + +const jsdom = new JSDOM('<!doctype html><html><body><div id="main"></div></body></html>'); +const { window } = jsdom; + +window.HTMLCanvasElement.prototype.getContext = () => {}; +jest.setMock('node-fetch', fetch); +global.fetch = require('jest-fetch-mock'); // eslint-disable-line import/no-extraneous-dependencies + +global.window = window; +global.document = window.document; +global.navigator = { + userAgent: 'node.js', +}; + +Enzyme.configure({ adapter: new Adapter() }); diff --git a/src/action-types.js b/src/action-types.js new file mode 100644 index 0000000000000000000000000000000000000000..9783cb967f276c8c5974510e9448cb22646dc649 --- /dev/null +++ b/src/action-types.js @@ -0,0 +1,21 @@ +const ActionTypes = { + FOCUS_WINDOW: 'FOCUS_WINDOW', + ADD_MANIFEST: 'ADD_MANIFEST', + ADD_WINDOW: 'ADD_WINDOW', + NEXT_CANVAS: 'NEXT_CANVAS', + PREVIOUS_CANVAS: 'PREVIOUS_CANVAS', + REMOVE_WINDOW: 'REMOVE_WINDOW', + PICK_WINDOWING_SYSTEM: 'PICK_WINDOWING_SYSTEM', + REQUEST_MANIFEST: 'REQUEST_MANIFEST', + RECEIVE_MANIFEST: 'RECEIVE_MANIFEST', + RECEIVE_MANIFEST_FAILURE: 'RECEIVE_MANIFEST_FAILURE', + SET_CONFIG: 'SET_CONFIG', + UPDATE_CONFIG: 'UPDATE_CONFIG', + REMOVE_MANIFEST: 'REMOVE_MANIFEST', + REQUEST_INFO_RESPONSE: 'REQUEST_INFO_RESPONSE', + RECEIVE_INFO_RESPONSE: 'RECEIVE_INFO_RESPONSE', + RECEIVE_INFO_RESPONSE_FAILURE: 'RECEIVE_INFO_RESPONSE_FAILURE', + REMOVE_INFO_RESPONSE: 'REMOVE_INFO_RESPONSE', +}; + +export default ActionTypes; diff --git a/src/actions/index.js b/src/actions/index.js new file mode 100644 index 0000000000000000000000000000000000000000..ec56ae64a9757709b08108efe0f7ad38a45055b9 --- /dev/null +++ b/src/actions/index.js @@ -0,0 +1,227 @@ +import fetch from 'node-fetch'; +import ActionTypes from '../action-types'; + +/** + * Action Creators for Mirador + * @namespace ActionCreators + */ + + +/** + * setConfig - action creator + * + * @param {Object} config +* @memberof ActionCreators + */ +export function setConfig(config) { + return { type: ActionTypes.SET_CONFIG, config }; +} + +/** + * updateConfig - action creator + * + * @param {Object} config +* @memberof ActionCreators + */ +export function updateConfig(config) { + return { type: ActionTypes.UPDATE_CONFIG, config }; +} + +/** + * focusWindow - action creator + * + * @param {String} windowId + * @memberof ActionCreators + */ +export function focusWindow(windowId) { + return { type: ActionTypes.FOCUS_WINDOW, windowId }; +} + +/** + * addWindow - action creator + * + * @param {Object} options + * @memberof ActionCreators + */ +export function addWindow(options) { + const defaultOptions = { + // TODO: Windows should be a hash with id's as keys for easy lookups + // https://redux.js.org/faq/organizing-state#how-do-i-organize-nested-or-duplicate-data-in-my-state + id: `window-${new Date().valueOf()}`, + canvasIndex: 0, + collectionIndex: 0, + manifestId: null, + rangeId: null, + xywh: [0, 0, 400, 400], + rotation: null, + }; + return { type: ActionTypes.ADD_WINDOW, payload: Object.assign({}, defaultOptions, options) }; +} + +/** + * removeWindow - action creator + * + * @param {String} windowId + * @memberof ActionCreators + */ +export function removeWindow(windowId) { + return { type: ActionTypes.REMOVE_WINDOW, windowId }; +} + +/** + * nextCanvas - action creator + * + * @param {String} windowId + * @memberof ActionCreators + */ +export function nextCanvas(windowId) { + return { type: ActionTypes.NEXT_CANVAS, windowId }; +} + +/** + * previousCanvas - action creator + * + * @param {String} windowId + * @memberof ActionCreators + */ +export function previousCanvas(windowId) { + return { type: ActionTypes.PREVIOUS_CANVAS, windowId }; +} + +/** + * requestManifest - action creator + * + * @param {String} manifestId + * @memberof ActionCreators + */ +export function requestManifest(manifestId) { + return { + type: ActionTypes.REQUEST_MANIFEST, + manifestId, + }; +} + +/** + * receiveManifest - action creator + * + * @param {String} windowId + * @param {Object} manifestJson + * @memberof ActionCreators + */ +export function receiveManifest(manifestId, manifestJson) { + return { + type: ActionTypes.RECEIVE_MANIFEST, + manifestId, + manifestJson, + }; +} + +/** + * receiveManifestFailure - action creator + * + * @param {String} windowId + * @param {String} error + * @memberof ActionCreators + */ +export function receiveManifestFailure(manifestId, error) { + return { + type: ActionTypes.RECEIVE_MANIFEST_FAILURE, + manifestId, + error, + }; +} + +/** + * fetchManifest - action creator + * + * @param {String} manifestId + * @memberof ActionCreators + */ +export function fetchManifest(manifestId) { + return ((dispatch) => { + dispatch(requestManifest(manifestId)); + return fetch(manifestId) + .then(response => response.json()) + .then(json => dispatch(receiveManifest(manifestId, json))) + .catch(error => dispatch(receiveManifestFailure(manifestId, error))); + }); +} + +/** + * removeManifest - action creator + * + * @param {String} manifestId + * @memberof ActionCreators + */ +export function removeManifest(manifestId) { + return { type: ActionTypes.REMOVE_MANIFEST, manifestId }; +} + +/** + * requestInfoResponse - action creator + * + * @param {String} infoId + * @memberof ActionCreators + */ +export function requestInfoResponse(infoId) { + return { + type: ActionTypes.REQUEST_INFO_RESPONSE, + infoId, + }; +} + +/** + * receiveInfoResponse - action creator + * + * @param {String} infoId + * @param {Object} manifestJson + * @memberof ActionCreators + */ +export function receiveInfoResponse(infoId, infoJson) { + return { + type: ActionTypes.RECEIVE_INFO_RESPONSE, + infoId, + infoJson, + }; +} + +/** + * receiveInfoResponseFailure - action creator + * + * @param {String} infoId + * @param {String} error + * @memberof ActionCreators + */ +export function receiveInfoResponseFailure(infoId, error) { + return { + type: ActionTypes.RECEIVE_INFO_RESPONSE_FAILURE, + infoId, + error, + }; +} + +/** + * fetchInfoResponse - action creator + * + * @param {String} infoId + * @memberof ActionCreators + */ +export function fetchInfoResponse(infoId) { + return ((dispatch) => { + dispatch(requestInfoResponse(infoId)); + return fetch(infoId) + .then(response => response.json()) + .then(json => dispatch(receiveInfoResponse(infoId, json))) + .catch(error => dispatch(receiveInfoResponseFailure(infoId, error))); + }); +} + +/** + * removeInfoResponse - action creator + * + * @param {String} infoId + * @memberof ActionCreators + */ +export function removeInfoResponse(infoId) { + return { type: ActionTypes.REMOVE_INFO_RESPONSE, infoId }; +} diff --git a/src/components/App.js b/src/components/App.js new file mode 100644 index 0000000000000000000000000000000000000000..1c9743ca35587e370f9a13301d37639b229e2d9f --- /dev/null +++ b/src/components/App.js @@ -0,0 +1,119 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { actions } from '../store'; +import Display from './Display'; +import ManifestForm from './ManifestForm'; +import ManifestListItem from './ManifestListItem'; +import Workspace from './Workspace'; +import ns from '../config/css-ns'; + +/** + * This is the top level Mirador component. + * @prop {Object} manifests + */ +class App extends Component { + /** + * constructor - + */ + constructor(props) { + super(props); + this.state = { + lastRequested: '', + }; + + this.setLastRequested = this.setLastRequested.bind(this); + } + + /** + * setLastRequested - Sets the state lastRequested + * + * @private + */ + setLastRequested(requested) { + this.setState({ + lastRequested: requested, + }); + } + + /** + * computedContent - computes the content to be displayed based on logic + * + * @return {type} description + * @private + */ + computedContent() { + const { manifests } = this.props; + const { lastRequested } = this.state; + const manifest = manifests[lastRequested]; + if (manifest) { + if (manifest.isFetching) { + return '☕'; + } + if (manifest.error) { + return manifest.error.message; + } + return JSON.stringify(manifest.json, 0, 2); + } + return 'Nothing Selected Yet'; + } + + /** + * render + * @return {String} - HTML markup for the component + */ + render() { + const { manifests } = this.props; + const { lastRequested } = this.state; + const manifestList = Object.keys(manifests).map(manifest => ( + <ManifestListItem + key={manifest} + manifest={manifest} + /> + )); + return ( + <div className={ns('app')}> + <Workspace /> + <div className={ns('control-panel')}> + <ManifestForm setLastRequested={this.setLastRequested} /> + <ul>{manifestList}</ul> + + <Display + manifest={manifests[lastRequested]} + /> + </div> + </div> + ); + } +} + +App.propTypes = { + manifests: PropTypes.instanceOf(Object).isRequired, +}; + +/** + * mapStateToProps - to hook up connect + * @memberof App + * @private + */ +const mapStateToProps = state => ( + { + manifests: state.manifests, + } +); + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof App + * @private + */ +const mapDispatchToProps = dispatch => ({ + fetchManifest: manifestUrl => ( + dispatch(actions.fetchManifest(manifestUrl)) + ), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(App); diff --git a/src/components/Display.js b/src/components/Display.js new file mode 100644 index 0000000000000000000000000000000000000000..f9db0dd00071959859696844e527ac7f0f7cd039 --- /dev/null +++ b/src/components/Display.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ManifestMetadata from './ManifestMetadata'; +import ns from '../config/css-ns'; + +/** + * Determines how to best display the content (or lack thereof) the manifest + * @private + */ +const displayContent = (manifest) => { + if (manifest) { + if (manifest.isFetching) { + return '☕'; + } + if (manifest.error) { + return manifest.error.message; + } + return <ManifestMetadata manifest={manifest} />; + } + return 'Nothing Selected Yet'; +}; + +/** + * Determines which classes should be used for display, based on the state of + * the manifest + * @memberof Display + * @private + */ +const stateClass = (manifest) => { + if (manifest) { + if (manifest.isFetching) { + return 'fetching'; + } + if (manifest.error) { + return 'error'; + } + return ''; + } + return 'empty'; +}; + + +/** + * Displays a manifest + * @param {object} props + * @param {object} [props.manifest = undefined] + */ +const Display = ({ manifest }) => ( + <div className="Display"> + <div id="exampleManifest" className={ns(stateClass(manifest))}> + {displayContent(manifest)} + </div> + </div> +); + +Display.propTypes = { + manifest: PropTypes.object, // eslint-disable-line react/forbid-prop-types +}; + +Display.defaultProps = { + manifest: undefined, +}; + +export default Display; diff --git a/src/components/ManifestForm.js b/src/components/ManifestForm.js new file mode 100644 index 0000000000000000000000000000000000000000..54f1557e96ec08d11bc9792c6c526d7cdd132d69 --- /dev/null +++ b/src/components/ManifestForm.js @@ -0,0 +1,99 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { actions } from '../store'; + +/** + * Provides a form for user input of a manifest url + * @prop {Function} fetchManifest + * @prop {Function} setLastRequested + */ +class ManifestForm extends Component { + /** + * constructor - + */ + constructor(props) { + super(props); + this.state = { + formValue: '', + }; + + this.formSubmit = this.formSubmit.bind(this); + this.handleInputChange = this.handleInputChange.bind(this); + } + + /** + * formSubmit - triggers manifest update and sets lastRequested + * @param {Event} event + * @private + */ + formSubmit(event) { + const { fetchManifest, setLastRequested } = this.props; + const { formValue } = this.state; + event.preventDefault(); + fetchManifest(formValue); + setLastRequested(formValue); + } + + /** + * handleInputChange - sets state based on input change. + * @param {Event} event + * @private + */ + handleInputChange(event) { + const that = this; + event.preventDefault(); + that.setState({ + formValue: event.target.value, + }); + } + + /** + * render + * @return {String} - HTML markup for the component + */ + render() { + const { formValue } = this.state; + return ( + <form onSubmit={this.formSubmit}> + <input + value={formValue} + id="manifestURL" + type="text" + onChange={this.handleInputChange} + /> + <button id="fetchBtn" type="submit">FetchManifest</button> + </form> + ); + } +} + +ManifestForm.propTypes = { + fetchManifest: PropTypes.func.isRequired, + setLastRequested: PropTypes.func.isRequired, +}; + +/** + * mapStateToProps - to hook up connect + * @memberof ManifestForm + * @private + */ +const mapStateToProps = () => ( + {} +); + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof ManifestForm + * @private + */ +const mapDispatchToProps = dispatch => ({ + fetchManifest: manifestUrl => ( + dispatch(actions.fetchManifest(manifestUrl)) + ), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ManifestForm); diff --git a/src/components/ManifestListItem.js b/src/components/ManifestListItem.js new file mode 100644 index 0000000000000000000000000000000000000000..b67886e8d869abb540943b088ef0246b6dcef84e --- /dev/null +++ b/src/components/ManifestListItem.js @@ -0,0 +1,62 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { actions } from '../store'; +import ns from '../config/css-ns'; + + +/** + * Handling open button click + */ +const handleOpenButtonClick = (event, manifest, addWindow) => { + addWindow({ manifestId: manifest }); +}; +/** + * Represents an item in a list of currently-loaded or loading manifests + * @param {object} props + * @param {object} [props.manifest = string] + */ + +/** + * Determines which classes should be used for display, based on the state of + * the manifest + * @memberof ManifestListItem + * @private + */ +const ManifestListItem = ({ manifest, addWindow }) => ( + <li className={ns('manifest-list-item')}> + <button type="button" onClick={event => handleOpenButtonClick(event, manifest, addWindow)}> + {manifest} + </button> + </li> +); + +ManifestListItem.propTypes = { + manifest: PropTypes.string.isRequired, // eslint-disable-line react/forbid-prop-types + addWindow: PropTypes.func.isRequired, +}; + +/** + * mapStateToProps - to hook up connect + * @memberof ManifestListItem + * @private + */ +const mapStateToProps = () => ( + {} +); + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof ManifestListItem + * @private + */ +const mapDispatchToProps = dispatch => ({ + addWindow: options => ( + dispatch(actions.addWindow(options)) + ), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ManifestListItem); diff --git a/src/components/ManifestMetadata.js b/src/components/ManifestMetadata.js new file mode 100644 index 0000000000000000000000000000000000000000..2f79254a7dfc59c14e0ba3acb81d939237cb6261 --- /dev/null +++ b/src/components/ManifestMetadata.js @@ -0,0 +1,35 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import ns from '../config/css-ns'; + +/** + * ManifestMetadata + * @param {object} window + */ +export default class ManifestMetadata extends Component { + /** + * Renders things + * @param {object} props + */ + render() { + const { manifest } = this.props; + return ( + <div> + <h3> + {manifest.manifestation.getLabel().map(label => label.value)[0]} + </h3> + <div className={ns('description')}> + {manifest.manifestation.getDescription().map(label => label.value)} + </div> + </div> + ); + } +} + +ManifestMetadata.propTypes = { + manifest: PropTypes.object, // eslint-disable-line react/forbid-prop-types +}; + +ManifestMetadata.defaultProps = { + manifest: null, +}; diff --git a/src/components/OpenSeadragonViewer.js b/src/components/OpenSeadragonViewer.js new file mode 100644 index 0000000000000000000000000000000000000000..3709ab33b168ac179a9c375ab803db1fa3d09016 --- /dev/null +++ b/src/components/OpenSeadragonViewer.js @@ -0,0 +1,89 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import OpenSeadragon from 'openseadragon'; +import miradorWithPlugins from '../lib/miradorWithPlugins'; +import ns from '../config/css-ns'; + +/** + * Represents a OpenSeadragonViewer in the mirador workspace. Responsible for mounting + * and rendering OSD. + */ +class OpenSeadragonViewer extends Component { + /** + * @param {Object} props + */ + constructor(props) { + super(props); + + this.viewer = null; + this.ref = React.createRef(); + } + + /** + * React lifecycle event + */ + componentDidMount() { + const { tileSources } = this.props; + if (!this.ref.current) { + return; + } + this.viewer = new OpenSeadragon({ + id: this.ref.current.id, + preserveViewport: true, + blendTime: 0.1, + alwaysBlend: false, + showNavigationControl: false, + }); + tileSources.forEach(tileSource => this.addTileSource(tileSource)); + } + + /** + */ + componentDidUpdate() { + const { tileSources } = this.props; + tileSources.forEach(tileSource => this.addTileSource(tileSource)); + } + + /** + */ + componentWillUnmount() { + this.viewer.removeAllHandlers(); + } + + /** + */ + addTileSource(tileSource) { + this.viewer.addTiledImage({ + tileSource, + success: (event) => { + }, + }); + } + + /** + * Renders things + */ + render() { + const { window } = this.props; + return ( + <Fragment> + <div + className={ns('osd-container')} + id={`${window.id}-osd`} + ref={this.ref} + /> + </Fragment> + ); + } +} + +OpenSeadragonViewer.defaultProps = { + tileSources: [], +}; + +OpenSeadragonViewer.propTypes = { + tileSources: PropTypes.arrayOf(PropTypes.object), + window: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; + +export default miradorWithPlugins(OpenSeadragonViewer); diff --git a/src/components/ViewerNavigation.js b/src/components/ViewerNavigation.js new file mode 100644 index 0000000000000000000000000000000000000000..a6d989f143db068813cd8de574dcf5242ede5b6a --- /dev/null +++ b/src/components/ViewerNavigation.js @@ -0,0 +1,91 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import miradorWithPlugins from '../lib/miradorWithPlugins'; +import { actions } from '../store'; +import ns from '../config/css-ns'; + +/** + */ +class ViewerNavigation extends Component { + /** + */ + constructor(props) { + super(props); + + this.nextCanvas = this.nextCanvas.bind(this); + this.previousCanvas = this.previousCanvas.bind(this); + } + + /** + */ + nextCanvas() { + const { window, nextCanvas } = this.props; + if (this.hasNextCanvas()) nextCanvas(window.id); + } + + /** + */ + hasNextCanvas() { + const { window, canvases } = this.props; + return window.canvasIndex < canvases.length - 1; + } + + /** + */ + previousCanvas() { + const { window, previousCanvas } = this.props; + if (this.hasPreviousCanvas()) previousCanvas(window.id); + } + + /** + */ + hasPreviousCanvas() { + const { window } = this.props; + return window.canvasIndex > 0; + } + + /** + * Renders things + */ + render() { + return ( + <div className={ns('osd-navigation')}> + <button + type="button" + disabled={!this.hasPreviousCanvas()} + onClick={this.previousCanvas} + > + ‹ + </button> + <button + type="button" + disabled={!this.hasNextCanvas()} + onClick={this.nextCanvas} + > + › + </button> + </div> + ); + } +} + +ViewerNavigation.propTypes = { + canvases: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types + nextCanvas: PropTypes.func.isRequired, + previousCanvas: PropTypes.func.isRequired, + window: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; + + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof ManifestForm + * @private + */ +const mapDispatchToProps = dispatch => ({ + nextCanvas: windowId => dispatch(actions.nextCanvas(windowId)), + previousCanvas: windowId => dispatch(actions.previousCanvas(windowId)), +}); + +export default connect(null, mapDispatchToProps)(miradorWithPlugins(ViewerNavigation)); diff --git a/src/components/Window.js b/src/components/Window.js new file mode 100644 index 0000000000000000000000000000000000000000..f28a3ddeff9f276da7380a7457e338c9dc1fa92d --- /dev/null +++ b/src/components/Window.js @@ -0,0 +1,82 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ns from '../config/css-ns'; +import WindowBackground from './WindowBackground'; +import WindowTopBar from './WindowTopBar'; +import WindowViewer from './WindowViewer'; + +/** + * Represents a Window in the mirador workspace + * @param {object} window + */ +class Window extends Component { + /** + * Return style attributes + */ + styleAttributes() { + const { window } = this.props; + return { width: `${window.xywh[2]}px`, height: `${window.xywh[3]}px` }; + } + + /** + * renderViewer + * + * @return {String, null} + */ + renderViewer() { + const { manifest, window } = this.props; + if (manifest) { + return ( + <WindowViewer + window={window} + manifest={manifest} + /> + ); + } + return null; + } + + /** + * Renders things + */ + render() { + const { manifest, window } = this.props; + return ( + <div className={ns('window')} style={this.styleAttributes()}> + <WindowTopBar + windowId={window.id} + manifest={manifest} + /> + <WindowBackground + manifest={manifest} + /> + {this.renderViewer()} + </div> + ); + } +} + +Window.propTypes = { + window: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + manifest: PropTypes.object, // eslint-disable-line react/forbid-prop-types +}; + +Window.defaultProps = { + manifest: null, +}; + +/** + * mapStateToProps - used to hook up connect to action creators + * @memberof Window + * @private + */ +const mapStateToProps = ({ windows, manifests }, props) => { + const window = windows.find(win => props.id === win.id); + return { + window, + manifest: manifests[window.manifestId], + }; +}; + +export default connect(mapStateToProps)(Window); diff --git a/src/components/WindowBackground.js b/src/components/WindowBackground.js new file mode 100644 index 0000000000000000000000000000000000000000..9ff70f1904aa9b247af5da7b9a03871ef7bdecb9 --- /dev/null +++ b/src/components/WindowBackground.js @@ -0,0 +1,61 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +/** + * Represents a WindowBackground in the mirador workspace + * @param {object} window + */ +class WindowBackground extends Component { + /** + * Fetches IIIF thumbnail URL + */ + thumbnail() { + const { manifest } = this.props; + const thumb = manifest.manifestation.getThumbnail() || { id: 'http://placekitten.com/200/300' }; + return thumb.id; + } + + + /** + * content - based off of manifest state + * + * @return {type} + */ + content(manifest) { + if (!manifest) return null; + switch (manifest.isFetching) { + case true: + return 'spinner'; + case false: + if (manifest.manifestation) { + return <img src={this.thumbnail()} alt="" />; + } + break; + default: + return null; + } + return null; + } + + /** + * Renders things + */ + render() { + const { manifest } = this.props; + return ( + <div> + {this.content(manifest)} + </div> + ); + } +} + +WindowBackground.propTypes = { + manifest: PropTypes.object, // eslint-disable-line react/forbid-prop-types +}; + +WindowBackground.defaultProps = { + manifest: null, +}; + +export default WindowBackground; diff --git a/src/components/WindowTopBar.js b/src/components/WindowTopBar.js new file mode 100644 index 0000000000000000000000000000000000000000..19b7e35664508b44497f789a65e4c051f9df3c55 --- /dev/null +++ b/src/components/WindowTopBar.js @@ -0,0 +1,63 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { actions } from '../store'; +import WindowTopBarButtons from './WindowTopBarButtons'; +import miradorWithPlugins from '../lib/miradorWithPlugins'; +import ns from '../config/css-ns'; + +/** + * WindowTopBar + */ +class WindowTopBar extends Component { + /** + * titleContent + * + * @return {String} + */ + titleContent() { + const { manifest } = this.props; + if (manifest && manifest.manifestation) { + return manifest.manifestation.getLabel().map(label => label.value)[0]; + } + return ''; + } + + /** + * render + * @return + */ + render() { + const { removeWindow, windowId } = this.props; + return ( + <div className={ns('window-top-bar')}> + <h3>{this.titleContent()}</h3> + <WindowTopBarButtons windowId={windowId} /> + <button type="button" className={ns('window-close')} aria-label="Close Window" onClick={() => removeWindow(windowId)}>×</button> + </div> + ); + } +} + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof ManifestListItem + * @private + */ +const mapDispatchToProps = dispatch => ({ + removeWindow: windowId => ( + dispatch(actions.removeWindow(windowId)) + ), +}); + +WindowTopBar.propTypes = { + manifest: PropTypes.object, // eslint-disable-line react/forbid-prop-types + removeWindow: PropTypes.func.isRequired, + windowId: PropTypes.string.isRequired, +}; + +WindowTopBar.defaultProps = { + manifest: null, +}; + +export default connect(null, mapDispatchToProps)(miradorWithPlugins(WindowTopBar)); diff --git a/src/components/WindowTopBarButtons.js b/src/components/WindowTopBarButtons.js new file mode 100644 index 0000000000000000000000000000000000000000..6bcdef6b45cd95ca78df88b7dd221d597ef62b65 --- /dev/null +++ b/src/components/WindowTopBarButtons.js @@ -0,0 +1,19 @@ +import React, { Component, Fragment } from 'react'; +import miradorWithPlugins from '../lib/miradorWithPlugins'; +/** + * + */ +class WindowTopBarButtons extends Component { + /** + * render + * + * @return {type} description + */ + render() { + return ( + <Fragment /> + ); + } +} + +export default miradorWithPlugins(WindowTopBarButtons); diff --git a/src/components/WindowViewer.js b/src/components/WindowViewer.js new file mode 100644 index 0000000000000000000000000000000000000000..77f77b7fd45e794a444861f6f1f7816f42e2f4b8 --- /dev/null +++ b/src/components/WindowViewer.js @@ -0,0 +1,121 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { actions } from '../store'; +import miradorWithPlugins from '../lib/miradorWithPlugins'; +import OpenSeadragonViewer from './OpenSeadragonViewer'; +import ViewerNavigation from './ViewerNavigation'; + +/** + * Represents a WindowViewer in the mirador workspace. Responsible for mounting + * OSD and Navigation + */ +class WindowViewer extends Component { + /** + * @param {Object} props + */ + constructor(props) { + super(props); + + const { manifest } = this.props; + this.canvases = manifest.manifestation.getSequences()[0].getCanvases(); + } + + /** + * componentDidMount - React lifecycle method + * Request the initial canvas on mount + */ + componentDidMount() { + const { fetchInfoResponse } = this.props; + fetchInfoResponse(this.imageInformationUri()); + } + + /** + * componentDidUpdate - React lifecycle method + * Request a new canvas if it is needed + */ + componentDidUpdate(prevProps) { + const { window, fetchInfoResponse } = this.props; + if (prevProps.window.canvasIndex !== window.canvasIndex && !this.infoResponseIsInStore()) { + fetchInfoResponse(this.imageInformationUri()); + } + } + + /** + * infoResponseIsInStore - checks whether or not an info response is already + * in the store. No need to request it again. + * @return [Boolean] + */ + infoResponseIsInStore() { + const { infoResponses } = this.props; + const currentInfoResponse = infoResponses[this.imageInformationUri()]; + return (currentInfoResponse !== undefined + && currentInfoResponse.isFetching === false + && currentInfoResponse.json !== undefined); + } + + /** + * Constructs an image information URI to request from a canvas + */ + imageInformationUri() { + const { window } = this.props; + return `${this.canvases[window.canvasIndex].getImages()[0].getResource().getServices()[0].id}/info.json`; + } + + /** + * Return an image information response from the store for the correct image + */ + tileInfoFetchedFromStore() { + const { infoResponses } = this.props; + return [infoResponses[this.imageInformationUri()]] + .filter(infoResponse => (infoResponse !== undefined + && infoResponse.isFetching === false + && infoResponse.error === undefined)) + .map(infoResponse => infoResponse.json); + } + + /** + * Renders things + */ + render() { + const { window } = this.props; + return ( + <Fragment> + <OpenSeadragonViewer + tileSources={this.tileInfoFetchedFromStore()} + window={window} + /> + <ViewerNavigation window={window} canvases={this.canvases} /> + </Fragment> + ); + } +} + +/** + * mapStateToProps - to hook up connect + * @memberof WindowViewer + * @private + */ +const mapStateToProps = state => ( + { + infoResponses: state.infoResponses, + } +); + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof WindowViewer + * @private + */ +const mapDispatchToProps = dispatch => ({ + fetchInfoResponse: infoId => dispatch(actions.fetchInfoResponse(infoId)), +}); + +WindowViewer.propTypes = { + infoResponses: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + fetchInfoResponse: PropTypes.func.isRequired, + manifest: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + window: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; + +export default connect(mapStateToProps, mapDispatchToProps)(miradorWithPlugins(WindowViewer)); diff --git a/src/components/Workspace.js b/src/components/Workspace.js new file mode 100644 index 0000000000000000000000000000000000000000..f9b36c36e59018a8b55199770500083e7e1c0eb8 --- /dev/null +++ b/src/components/Workspace.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import Window from './Window'; +import ns from '../config/css-ns'; + +/** + * Represents a work area that contains any number of windows + * @memberof Workspace + * @private + */ +const Workspace = ({ windows }) => ( + <div className={ns('workspace')}> + { + windows.map(window => ( + <Window + key={window.id} + id={window.id} + /> + )) + } + </div> +); + +Workspace.propTypes = { + windows: PropTypes.instanceOf(Array).isRequired, +}; + +/** + * mapStateToProps - to hook up connect + * @memberof Workspace + * @private + */ +const mapStateToProps = state => ( + { + windows: state.windows, + } +); + +export default connect(mapStateToProps)(Workspace); diff --git a/src/config/css-ns.js b/src/config/css-ns.js new file mode 100644 index 0000000000000000000000000000000000000000..77ad4b4c914bd35951b7f64799d611f67694d6be --- /dev/null +++ b/src/config/css-ns.js @@ -0,0 +1,10 @@ +import { createCssNs } from 'css-ns'; + +/** + * export ns - sets up css namespacing for everything to be `mirador-` + */ +const ns = className => createCssNs({ + namespace: 'mirador', +})(className); + +export default ns; diff --git a/src/config/settings.js b/src/config/settings.js new file mode 100644 index 0000000000000000000000000000000000000000..5532a6a728ebfed6ed0f3a52af166cdb483e25e0 --- /dev/null +++ b/src/config/settings.js @@ -0,0 +1,3 @@ +export default { + foo: 'bar', +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000000000000000000000000000000000000..406e2a89e6e94cb1a348350daa7a2b69bc41f4ab --- /dev/null +++ b/src/index.js @@ -0,0 +1,8 @@ +import init from './init'; + +const exports = { + viewer: init, + plugins: {}, +}; + +export default exports; diff --git a/src/init.js b/src/init.js new file mode 100644 index 0000000000000000000000000000000000000000..307b11a14a6953c72b223f66f0f3501f1a1b6c13 --- /dev/null +++ b/src/init.js @@ -0,0 +1,29 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import deepmerge from 'deepmerge'; +import App from './components/App'; +import { actions, store } from './store'; +import settings from './config/settings'; +import './styles/index.scss'; + +/** + * Default Mirador instantiation + */ +export default function (config) { + const viewer = { + actions, + store, + }; + const action = actions.setConfig(deepmerge(settings, config)); + store.dispatch(action); + + ReactDOM.render( + <Provider store={store}> + <App config={config} /> + </Provider>, + document.getElementById(config.id), + ); + + return viewer; +} diff --git a/src/lib/componentPlugins.js b/src/lib/componentPlugins.js new file mode 100644 index 0000000000000000000000000000000000000000..a97841f2a59387f62efa3d7a61dbf0e1256e9e4f --- /dev/null +++ b/src/lib/componentPlugins.js @@ -0,0 +1,14 @@ +/** + * componentPlugins - gets window plugins based on a component parent + */ +export default function componentPlugins(componentName, plugins = []) { + // TODO: Figure out how to handle when not running under window. Probably not + // a pressing priority, but relevant for tests + return plugins.map((pluginName) => { + if (window.Mirador.plugins[pluginName] + && window.Mirador.plugins[pluginName].parent === componentName) { + return window.Mirador.plugins[pluginName]; + } + return null; + }).filter(plugin => (plugin !== (undefined || null))); +} diff --git a/src/lib/miradorWithPlugins.js b/src/lib/miradorWithPlugins.js new file mode 100644 index 0000000000000000000000000000000000000000..06efc98cb8473c1104df4b9734971cf215b4d672 --- /dev/null +++ b/src/lib/miradorWithPlugins.js @@ -0,0 +1,60 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import componentPlugins from './componentPlugins'; +/** + * miradorWithPlugins - renders and returns a component with provided plugins + * + * @param {type} WrappedComponent + */ +export default function miradorWithPlugins(WrappedComponent) { + /** + */ + class ConnectedComponent extends Component { + /** + * constructor - + */ + constructor(props) { + super(props); + + this.getPluginParent = this.getPluginParent.bind(this); + } + + /** + * pluginParent - access the plugin's "parent" + */ + getPluginParent() { + return this.pluginParent; + } + + /** + * render - renders the wrapped component with the plugins. + */ + render() { + const { config } = this.props; + const { plugins } = config; + return ( + <Fragment> + <WrappedComponent {...this.props} ref={(parent) => { this.pluginParent = parent; }} /> + { /* TODO: Refactor .name here in some way so we dont need to rely on it */} + {componentPlugins(WrappedComponent.name, plugins) + .map(component => React.createElement( + connect(component.mapStateToProps, component.mapDispatchToProps)(component.component), + { key: component.name, ...this.props, pluginParent: this.getPluginParent }, + )) + } + </Fragment> + ); + } + } + + ConnectedComponent.propTypes = { + config: PropTypes.instanceOf(Object).isRequired, + }; + + /** + */ + const mapStateToProps = state => ({ config: state.config }); + + return connect(mapStateToProps, null)(ConnectedComponent); +} diff --git a/src/reducers/config.js b/src/reducers/config.js new file mode 100644 index 0000000000000000000000000000000000000000..92df252e757a53160f7c3fe1305aa35cbb286a82 --- /dev/null +++ b/src/reducers/config.js @@ -0,0 +1,18 @@ +import deepmerge from 'deepmerge'; +import ActionTypes from '../action-types'; + +/** + * configReducer - does a deep merge of the config + */ +const configReducer = (state = {}, action) => { + switch (action.type) { + case ActionTypes.UPDATE_CONFIG: + return deepmerge(state, action.config); + case ActionTypes.SET_CONFIG: + return action.config; + default: + return state; + } +}; + +export { configReducer as default }; diff --git a/src/reducers/index.js b/src/reducers/index.js new file mode 100644 index 0000000000000000000000000000000000000000..a52f1c82bd3259b2a501689a603aad622bbe5c2d --- /dev/null +++ b/src/reducers/index.js @@ -0,0 +1,21 @@ +import { combineReducers } from 'redux'; +import workspaceReducer from './workspace'; +import windowsReducer from './windows'; +import manifestsReducer from './manifests'; +import configReducer from './config'; +import infoResponsesReducer from './infoResponses'; + +/** + * Action Creators for Mirador + * @namespace RootReducer + */ + +const rootReducer = combineReducers({ + workspace: workspaceReducer, + windows: windowsReducer, + manifests: manifestsReducer, + config: configReducer, + infoResponses: infoResponsesReducer, +}); + +export default rootReducer; diff --git a/src/reducers/infoResponses.js b/src/reducers/infoResponses.js new file mode 100644 index 0000000000000000000000000000000000000000..9954dcb9b4c7ad1480fe09062a0c1ef98cd0591f --- /dev/null +++ b/src/reducers/infoResponses.js @@ -0,0 +1,39 @@ +import ActionTypes from '../action-types'; + +/** + * infoResponsesReducer + */ +const infoResponsesReducer = (state = {}, action) => { + switch (action.type) { + case ActionTypes.REQUEST_INFO_RESPONSE: + return Object.assign({}, state, { + [action.infoId]: { + isFetching: true, + }, + }); + case ActionTypes.RECEIVE_INFO_RESPONSE: + return Object.assign({}, state, { + [action.infoId]: { + json: action.infoJson, + isFetching: false, + }, + }); + case ActionTypes.RECEIVE_INFO_RESPONSE_FAILURE: + return Object.assign({}, state, { + [action.infoId]: { + error: action.error, + isFetching: false, + }, + }); + case ActionTypes.REMOVE_INFO_RESPONSE: + return Object.keys(state).reduce((object, key) => { + if (key !== action.infoId) { + object[key] = state[key]; // eslint-disable-line no-param-reassign + } + return object; + }, {}); + default: return state; + } +}; + +export default infoResponsesReducer; diff --git a/src/reducers/manifests.js b/src/reducers/manifests.js new file mode 100644 index 0000000000000000000000000000000000000000..48041ac3a01a23b87e7666d4888e3158224fc6b7 --- /dev/null +++ b/src/reducers/manifests.js @@ -0,0 +1,40 @@ +import manifesto from 'manifesto.js'; +import ActionTypes from '../action-types'; + +/** + * manifestsReducer + */ +const manifestsReducer = (state = {}, action) => { + switch (action.type) { + case ActionTypes.REQUEST_MANIFEST: + return Object.assign({}, state, { + [action.manifestId]: { + isFetching: true, + }, + }); + case ActionTypes.RECEIVE_MANIFEST: + return Object.assign({}, state, { + [action.manifestId]: { + manifestation: manifesto.create(action.manifestJson), + isFetching: false, + }, + }); + case ActionTypes.RECEIVE_MANIFEST_FAILURE: + return Object.assign({}, state, { + [action.manifestId]: { + error: action.error, + isFetching: false, + }, + }); + case ActionTypes.REMOVE_MANIFEST: + return Object.keys(state).reduce((object, key) => { + if (key !== action.manifestId) { + object[key] = state[key]; // eslint-disable-line no-param-reassign + } + return object; + }, {}); + default: return state; + } +}; + +export default manifestsReducer; diff --git a/src/reducers/windows.js b/src/reducers/windows.js new file mode 100644 index 0000000000000000000000000000000000000000..508200fbdb3be9822833a37c10aa5d21d236e64a --- /dev/null +++ b/src/reducers/windows.js @@ -0,0 +1,31 @@ +import ActionTypes from '../action-types'; + +/** + * windowsReducer + */ +const windowsReducer = (state = [], action) => { + switch (action.type) { + case ActionTypes.ADD_WINDOW: + return state.concat(Object.assign({}, action.payload)); + case ActionTypes.REMOVE_WINDOW: + return state.filter(window => window.id !== action.windowId); + case ActionTypes.NEXT_CANVAS: + return state.map((window) => { + if (window.id === action.windowId) { + return Object.assign({}, window, { canvasIndex: window.canvasIndex + 1 }); + } + return window; + }); + case ActionTypes.PREVIOUS_CANVAS: + return state.map((window) => { + if (window.id === action.windowId) { + return Object.assign({}, window, { canvasIndex: window.canvasIndex - 1 }); + } + return window; + }); + default: + return state; + } +}; + +export default windowsReducer; diff --git a/src/reducers/workspace.js b/src/reducers/workspace.js new file mode 100644 index 0000000000000000000000000000000000000000..2219a7bc3fdbb7bc373c9ea0237ae3d7e25f4942 --- /dev/null +++ b/src/reducers/workspace.js @@ -0,0 +1,15 @@ +import ActionTypes from '../action-types'; + +/** + * workspaceReducer + */ +const workspaceReducer = (state = {}, action) => { + switch (action.type) { + case ActionTypes.FOCUS_WINDOW: + return Object.assign({}, state, { focusedWindowId: action.windowId }); + default: + return state; + } +}; + +export { workspaceReducer as default }; diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000000000000000000000000000000000000..bbb6c6e1cb30f7853ac971e599de3ba78bf1f321 --- /dev/null +++ b/src/store.js @@ -0,0 +1,20 @@ +// Topics for understanding +// redux modules for nested stores +// state normalisation +// (normalizer library) + +import thunkMiddleware from 'redux-thunk'; +import { createStore, applyMiddleware } from 'redux'; +import { composeWithDevTools } from 'redux-devtools-extension'; +import rootReducer from './reducers/index'; +import * as ActionCreators from './actions'; + +// Create a Redux store holding the state of your app. +// Its API is { subscribe, dispatch, getState }. +export const store = createStore( + rootReducer, + composeWithDevTools(applyMiddleware(thunkMiddleware)), +); + +export const actions = ActionCreators; +export default { actions, store }; diff --git a/src/styles/index.scss b/src/styles/index.scss new file mode 100644 index 0000000000000000000000000000000000000000..f37eb5dc2f4a4d823401ae7b2cf93931f407bbd7 --- /dev/null +++ b/src/styles/index.scss @@ -0,0 +1,64 @@ +body { + background: white; + height: 100%; +} + +.mirador { + &-control-panel { + position: absolute; + box-sizing: border-box; + padding: 15px; + margin:0px; + left:0; + top:0; + bottom:0; + width: 300px; + border-right: 2px solid black; + } + + &-workspace { + position: absolute; + box-sizing: border-box; + margin: 0; + padding-left: 300px; + top:0; + right:0; + bottom:0; + left:0; + overflow: scroll; + } + + &-window { + border: 1px solid black; + overflow: hidden; + position: relative; + } + + &-window-top-bar { + background: linear-gradient(to bottom, rgba(0, 0, 0, .65) 0%, rgba(0, 0, 0, 0) 100%); + color: white; + position: absolute; + z-index: 10; + + h3 { + margin: 0; + } + } + + &-osd-container { + background: gray; + bottom: 0; + height: 100%; + position: absolute; + top: 0; + width: 100%; + } + + &-osd-navigation { + bottom: 10px; + left: 0; + position: absolute; + right: 0; + text-align: center; + } +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000000000000000000000000000000000000..b643a0c157d3c7d4335bd4c45415fab4b7ba516d --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,94 @@ +const path = require('path'); +const TerserPlugin = require('terser-webpack-plugin'); +const paths = require('./config/paths'); + +const eslintLoaderConfig = { + test: /\.(js|mjs|jsx)$/, + enforce: 'pre', + use: [ + { + options: { + formatter: require.resolve('react-dev-utils/eslintFormatter'), + eslintPath: require.resolve('eslint'), + + }, + loader: require.resolve('eslint-loader'), + }, + ], + include: paths.appSrc, +}; + +const babelLoaderConfig = { + test: /\.(js|mjs|jsx)$/, + include: paths.appSrc, // CRL + loader: require.resolve('babel-loader'), + options: { + plugins: [ + [ + require.resolve('babel-plugin-named-asset-import'), + { + loaderMap: { + svg: { + ReactComponent: '@svgr/webpack?-prettier,-svgo![path]', + }, + }, + }, + ], + ], + cacheDirectory: true, + // Save disk space when time isn't as important + cacheCompression: true, + compact: true, + }, +}; + +module.exports = [ + { + entry: './src/store.js', + output: { + path: path.join(__dirname, 'dist'), + filename: 'm3core.umd.js', + libraryTarget: 'umd', + library: 'm3core', + }, + module: { + rules: [ + eslintLoaderConfig, + babelLoaderConfig, + ], + }, + }, + { + entry: './src/index.js', + output: { + path: path.join(__dirname, 'dist'), + filename: 'mirador.min.js', + libraryTarget: 'umd', + library: 'Mirador', + libraryExport: 'default', + }, + resolve: { extensions: ['.js'] }, + module: { + rules: [ + eslintLoaderConfig, + babelLoaderConfig, + { + test: /\.scss$/, + use: [ + 'style-loader', // creates style nodes from JS strings + 'css-loader', // translates CSS into CommonJS + 'sass-loader', // compiles Sass to CSS, using Node Sass by default + ], + }], + }, + optimization: { + minimizer: [ + new TerserPlugin({ + terserOptions: { + mangle: false, + }, + }), + ], + }, + }, +];