diff --git a/__tests__/AnnotationCreation.test.js b/__tests__/AnnotationCreation.test.js index b3bfeb053dec48de7da38712f0bcc5bf5be2e867..5f79a8291da2447385a5c2ff82ac5120400d13bf 100644 --- a/__tests__/AnnotationCreation.test.js +++ b/__tests__/AnnotationCreation.test.js @@ -36,4 +36,16 @@ describe('AnnotationCreation', () => { wrapper = createWrapper(); expect(wrapper.dive().find(TextEditor).length).toBe(1); }); + it('can handle annotations without target selector', () => { + wrapper = createWrapper({ + annotation: { + body: { + purpose: 'commenting', + value: 'Foo bar', + }, + target: {}, + }, + }); + wrapper.dive(); + }); }); diff --git a/__tests__/AnnotationExportDialog.test.js b/__tests__/AnnotationExportDialog.test.js new file mode 100644 index 0000000000000000000000000000000000000000..21d9e5c2373c5be35675ce29ddd74e684e605bdc --- /dev/null +++ b/__tests__/AnnotationExportDialog.test.js @@ -0,0 +1,53 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import MenuItem from '@material-ui/core/MenuItem'; +import AnnotationExportDialog from '../src/AnnotationExportDialog'; + +window.URL.createObjectURL = jest.fn((data) => ('downloadurl')); + +const adapter = jest.fn(() => ( + { + all: jest.fn().mockResolvedValue( + { + id: 'pageId/3', + items: [ + { id: 'anno/2' }, + ], + type: 'AnnotationPage', + }, + ), + annotationPageId: 'pageId/3', + } +)); + +/** */ +function createWrapper(props) { + return shallow( + <AnnotationExportDialog + canvases={[]} + config={{ annotation: { adapter } }} + handleClose={jest.fn()} + open + {...props} + />, + ); +} + +describe('AnnotationExportDialog', () => { + it('renders download link for every annotation page', async () => { + let wrapper = createWrapper({ + canvases: [ + { id: 'canvas/1' }, + { id: 'canvas/2' }, + ], + }).dive(); + expect(wrapper.text()).toEqual(expect.stringContaining('No annotations stored yet.')); + + wrapper.instance().componentDidUpdate({ open: false }); + await new Promise((resolve) => setTimeout(resolve, 50)); + wrapper = wrapper.update(); + expect(wrapper.text()).toEqual(expect.not.stringContaining('No annotations stored yet.')); + expect(wrapper.find(MenuItem).some({ 'aria-label': 'Export annotations for canvas/1' })).toBe(true); + expect(wrapper.find(MenuItem).some({ 'aria-label': 'Export annotations for canvas/2' })).toBe(true); + }); +}); diff --git a/__tests__/miradorAnnotationPlugin.test.js b/__tests__/miradorAnnotationPlugin.test.js index c9402a77a906e01ae33572a4fd26ea6575b58495..2ea9032469f3da38279c6c0dc4c11b316ed62ab9 100644 --- a/__tests__/miradorAnnotationPlugin.test.js +++ b/__tests__/miradorAnnotationPlugin.test.js @@ -1,12 +1,14 @@ import React from 'react'; import { shallow } from 'enzyme'; import { MiradorMenuButton } from 'mirador/dist/es/src/components/MiradorMenuButton'; +import LocalStorageAdapter from '../src/LocalStorageAdapter'; import miradorAnnotationPlugin from '../src/plugins/miradorAnnotationPlugin'; /** */ function createWrapper(props) { return shallow( <miradorAnnotationPlugin.component + canvases={[]} config={{}} TargetComponent="<div>hello</div>" targetProps={{}} @@ -48,4 +50,38 @@ describe('MiradorAnnotation', () => { wrapper.find(MiradorMenuButton).simulate('click'); expect(wrapper.instance().state.singleCanvasDialogOpen).toBe(true); }); + it('renders no export button if export or LocalStorageAdapter are not configured', () => { + wrapper = createWrapper(); + expect(wrapper.find(MiradorMenuButton).some({ 'aria-label': 'Export local annotations for visible items' })).toBe(false); + + wrapper = createWrapper({ + config: { + annotation: { + adapter: () => () => {}, + exportLocalStorageAnnotations: true, + }, + }, + }); + expect(wrapper.find(MiradorMenuButton).some({ 'aria-label': 'Export local annotations for visible items' })).toBe(false); + + wrapper = createWrapper({ + config: { + annotation: { + adapter: (canvasId) => new LocalStorageAdapter(`test://?canvasId=${canvasId}`), + }, + }, + }); + expect(wrapper.find(MiradorMenuButton).some({ 'aria-label': 'Export local annotations for visible items' })).toBe(false); + }); + it('renders export button if export and LocalStorageAdapter are configured', () => { + wrapper = createWrapper({ + config: { + annotation: { + adapter: (canvasId) => new LocalStorageAdapter(`test://?canvasId=${canvasId}`), + exportLocalStorageAnnotations: true, + }, + }, + }); + expect(wrapper.find(MiradorMenuButton).some({ 'aria-label': 'Export local annotations for visible items' })).toBe(true); + }); }); diff --git a/demo/src/index.js b/demo/src/index.js index 3f7793bdd0274e9d20e8096a9784c9ee96fa847c..ef6831b6f8582d6d933a54005469f9c38414986d 100644 --- a/demo/src/index.js +++ b/demo/src/index.js @@ -9,6 +9,7 @@ const config = { annotation: { adapter: (canvasId) => new LocalStorageAdapter(`localStorage://?canvasId=${canvasId}`), // adapter: (canvasId) => new AnnototAdapter(canvasId, endpointUrl), + exportLocalStorageAnnotations: false, // display annotation JSON export button }, id: 'demo', window: { diff --git a/src/AnnotationCreation.js b/src/AnnotationCreation.js index c58f4452c61aa3ecdb7e05ae82b8a0d31dceedd2..54d09c628acf4da5703376bc93a83887a3584e21 100644 --- a/src/AnnotationCreation.js +++ b/src/AnnotationCreation.js @@ -50,16 +50,18 @@ class AnnotationCreation extends Component { } else { annoState.annoBody = props.annotation.body.value; } - if (Array.isArray(props.annotation.target.selector)) { - props.annotation.target.selector.forEach((selector) => { - if (selector.type === 'SvgSelector') { - annoState.svg = selector.value; - } else if (selector.type === 'FragmentSelector') { - annoState.xywh = selector.value.replace('xywh=', ''); - } - }); - } else { - annoState.svg = props.annotation.target.selector.value; + if (props.annotation.target.selector) { + if (Array.isArray(props.annotation.target.selector)) { + props.annotation.target.selector.forEach((selector) => { + if (selector.type === 'SvgSelector') { + annoState.svg = selector.value; + } else if (selector.type === 'FragmentSelector') { + annoState.xywh = selector.value.replace('xywh=', ''); + } + }); + } else { + annoState.svg = props.annotation.target.selector.value; + } } } this.state = { diff --git a/src/AnnotationExportDialog.js b/src/AnnotationExportDialog.js new file mode 100644 index 0000000000000000000000000000000000000000..9202209b55518038cf67acb5cd3b40c0b973ccaa --- /dev/null +++ b/src/AnnotationExportDialog.js @@ -0,0 +1,140 @@ +import React, { Component } from 'react'; +import Dialog from '@material-ui/core/Dialog'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import GetAppIcon from '@material-ui/icons/GetApp'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import MenuList from '@material-ui/core/MenuList'; +import MenuItem from '@material-ui/core/MenuItem'; +import Typography from '@material-ui/core/Typography'; +import PropTypes, { bool } from 'prop-types'; +import { withStyles } from '@material-ui/core'; + +/** */ +const styles = (theme) => ({ + listitem: { + '&:focus': { + backgroundColor: theme.palette.action.focus, + }, + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + }, +}); + +/** */ +class AnnotationExportDialog extends Component { + /** */ + constructor(props) { + super(props); + this.state = { + exportLinks: [], + }; + this.closeDialog = this.closeDialog.bind(this); + } + + /** */ + componentDidUpdate(prevProps) { + const { canvases, config, open } = this.props; + const { open: prevOpen } = prevProps || {}; + if (prevOpen !== open && open) { + /** */ + const reducer = async (acc, canvas) => { + const store = config.annotation.adapter(canvas.id); + const resolvedAcc = await acc; + const content = await store.all(); + if (content) { + // eslint-disable-next-line no-underscore-dangle + const label = (canvas.__jsonld && canvas.__jsonld.label) || canvas.id; + const data = new Blob([JSON.stringify(content)], { type: 'application/json' }); + const url = window.URL.createObjectURL(data); + return [...resolvedAcc, { + canvasId: canvas.id, + id: content.id || content['@id'], + label, + url, + }]; + } + return resolvedAcc; + }; + if (canvases && canvases.length > 0) { + canvases.reduce(reducer, []).then((exportLinks) => { + this.setState({ exportLinks }); + }); + } + } + } + + /** */ + closeDialog() { + const { handleClose } = this.props; + this.setState({ exportLinks: [] }); + handleClose(); + } + + /** */ + render() { + const { classes, handleClose, open } = this.props; + const { exportLinks } = this.state; + return ( + <Dialog + aria-labelledby="annotation-export-dialog-title" + id="annotation-export-dialog" + onClose={handleClose} + onEscapeKeyDown={this.closeDialog} + open={open} + > + <DialogTitle id="annotation-export-dialog-title" disableTypography> + <Typography variant="h2">Export Annotations</Typography> + </DialogTitle> + <DialogContent> + { exportLinks === undefined || exportLinks.length === 0 ? ( + <Typography variant="body1">No annotations stored yet.</Typography> + ) : ( + <MenuList> + { exportLinks.map((dl) => ( + <MenuItem + button + className={classes.listitem} + component="a" + key={dl.canvasId} + aria-label={`Export annotations for ${dl.label}`} + href={dl.url} + download={`${dl.id}.json`} + > + <ListItemIcon> + <GetAppIcon /> + </ListItemIcon> + <ListItemText> + {`Export annotations for "${dl.label}"`} + </ListItemText> + </MenuItem> + ))} + </MenuList> + )} + </DialogContent> + </Dialog> + ); + } +} + +AnnotationExportDialog.propTypes = { + canvases: PropTypes.arrayOf( + PropTypes.shape({ id: PropTypes.string }), + ).isRequired, + classes: PropTypes.objectOf(PropTypes.string), + config: PropTypes.shape({ + annotation: PropTypes.shape({ + adapter: PropTypes.func, + }), + }).isRequired, + handleClose: PropTypes.func.isRequired, + open: bool.isRequired, +}; + +AnnotationExportDialog.defaultProps = { + classes: {}, +}; + +export default withStyles(styles)(AnnotationExportDialog); diff --git a/src/plugins/miradorAnnotationPlugin.js b/src/plugins/miradorAnnotationPlugin.js index 17b09dce48833f0b46f84d83498722bbfa5d5a6b..e3a06fc3ddb7200ff602bc605ca96e28fa8c8f45 100644 --- a/src/plugins/miradorAnnotationPlugin.js +++ b/src/plugins/miradorAnnotationPlugin.js @@ -3,8 +3,12 @@ import PropTypes from 'prop-types'; import * as actions from 'mirador/dist/es/src/state/actions'; import { getWindowViewType } from 'mirador/dist/es/src/state/selectors'; import AddBoxIcon from '@material-ui/icons/AddBox'; +import GetAppIcon from '@material-ui/icons/GetApp'; import { MiradorMenuButton } from 'mirador/dist/es/src/components/MiradorMenuButton'; +import { getVisibleCanvases } from 'mirador/dist/es/src/state/selectors/canvases'; import SingleCanvasDialog from '../SingleCanvasDialog'; +import AnnotationExportDialog from '../AnnotationExportDialog'; +import LocalStorageAdapter from '../LocalStorageAdapter'; /** */ class MiradorAnnotation extends Component { @@ -12,9 +16,11 @@ class MiradorAnnotation extends Component { constructor(props) { super(props); this.state = { + annotationExportDialogOpen: false, singleCanvasDialogOpen: false, }; this.openCreateAnnotationCompanionWindow = this.openCreateAnnotationCompanionWindow.bind(this); + this.toggleCanvasExportDialog = this.toggleCanvasExportDialog.bind(this); this.toggleSingleCanvasDialogOpen = this.toggleSingleCanvasDialogOpen.bind(this); } @@ -37,15 +43,29 @@ class MiradorAnnotation extends Component { }); } + /** */ + toggleCanvasExportDialog(e) { + const { annotationExportDialogOpen } = this.state; + const newState = { + annotationExportDialogOpen: !annotationExportDialogOpen, + }; + this.setState(newState); + } + /** */ render() { const { + canvases, + config, switchToSingleCanvasView, TargetComponent, targetProps, windowViewType, } = this.props; - const { singleCanvasDialogOpen } = this.state; + const { annotationExportDialogOpen, singleCanvasDialogOpen } = this.state; + const storageAdapter = config.annotation && config.annotation.adapter('poke'); + const offerExportDialog = config.annotation && storageAdapter instanceof LocalStorageAdapter + && config.annotation.exportLocalStorageAnnotations; return ( <div> <TargetComponent @@ -58,15 +78,30 @@ class MiradorAnnotation extends Component { > <AddBoxIcon /> </MiradorMenuButton> - { - singleCanvasDialogOpen && ( - <SingleCanvasDialog - open={singleCanvasDialogOpen} - handleClose={this.toggleSingleCanvasDialogOpen} - switchToSingleCanvasView={switchToSingleCanvasView} - /> - ) - } + { singleCanvasDialogOpen && ( + <SingleCanvasDialog + open={singleCanvasDialogOpen} + handleClose={this.toggleSingleCanvasDialogOpen} + switchToSingleCanvasView={switchToSingleCanvasView} + /> + )} + { offerExportDialog && ( + <MiradorMenuButton + aria-label="Export local annotations for visible items" + onClick={this.toggleCanvasExportDialog} + size="small" + > + <GetAppIcon /> + </MiradorMenuButton> + )} + { offerExportDialog && ( + <AnnotationExportDialog + canvases={canvases} + config={config} + handleClose={this.toggleCanvasExportDialog} + open={annotationExportDialogOpen} + /> + )} </div> ); } @@ -74,6 +109,15 @@ class MiradorAnnotation extends Component { MiradorAnnotation.propTypes = { addCompanionWindow: PropTypes.func.isRequired, + canvases: PropTypes.arrayOf( + PropTypes.shape({ id: PropTypes.string, index: PropTypes.number }), + ).isRequired, + config: PropTypes.shape({ + annotation: PropTypes.shape({ + adapter: PropTypes.func, + exportLocalStorageAnnotations: PropTypes.bool, + }), + }).isRequired, switchToSingleCanvasView: PropTypes.func.isRequired, TargetComponent: PropTypes.oneOfType([ PropTypes.func, @@ -94,8 +138,10 @@ const mapDispatchToProps = (dispatch, props) => ({ }); /** */ -const mapStateToProps = (state, props) => ({ - windowViewType: getWindowViewType(state, { windowId: props.targetProps.windowId }), +const mapStateToProps = (state, { targetProps: { windowId } }) => ({ + canvases: getVisibleCanvases(state, { windowId }), + config: state.config, + windowViewType: getWindowViewType(state, { windowId }), }); export default {