diff --git a/.eslintrc b/.eslintrc index b3caa59e110484e128762039cca9c1c5c0645099..485180dd80c60975a27b1dae0156b28c798f6344 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,7 @@ "env": { "jest/globals": true }, - "extends": ["airbnb","react-app"], + "extends": ["airbnb"], "globals": { "page": true, "document": true @@ -26,6 +26,8 @@ "sort-keys": ["error", "asc", { "caseSensitive": false, "natural": false - }] + }], + "jsx-a11y/click-events-have-key-events": "off", + "jsx-a11y/no-static-element-interactions": "off" } } diff --git a/__tests__/AnnotationCreation.test.js b/__tests__/AnnotationCreation.test.js index 5f79a8291da2447385a5c2ff82ac5120400d13bf..d0a355d746333ab1330cbdc119c99fa262a45b2e 100644 --- a/__tests__/AnnotationCreation.test.js +++ b/__tests__/AnnotationCreation.test.js @@ -10,7 +10,7 @@ function createWrapper(props) { return shallow( <AnnotationCreation id="x" - config={{}} + config={{ annotation: {} }} receiveAnnotation={jest.fn()} windowId="abc" {...props} diff --git a/babel.config.js b/babel.config.js index 67796265100b1c7423d4d4d4b3e0efd56000eef6..ef1fc3ba7850cebcaf41f9bd941d6b5e590cfc6b 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,4 +1,9 @@ module.exports = { + plugins: [ + // TODO loose: which options is ignored in depencies ? + ['@babel/plugin-proposal-private-methods', { loose: true }], + ['@babel/plugin-proposal-private-property-in-object', { loose: true }], + ], presets: [ [ '@babel/preset-env', diff --git a/nwb.config.js b/nwb.config.js index 509e5a75bdeb087340976ed93ffcbb21553e38e1..4281080296fa16739b80dd18df7b3beb97774969 100644 --- a/nwb.config.js +++ b/nwb.config.js @@ -4,12 +4,12 @@ module.exports = { type: 'react-component', npm: { esModules: true, - umd: { - global: 'MiradorAnnotation', - externals: { - react: 'React', - }, - }, + // umd: { + // global: 'MiradorAnnotation', + // externals: { + // react: 'React', + // }, + // }, }, webpack: { aliases: { diff --git a/package.json b/package.json index 6b8f48c97c4d97a32e9cb0033f74ff0df11233be..13103e178a3118c7a23f8fd87dd4b0ed308a8d05 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "umd" ], "scripts": { - "build": "nwb build-react-component", - "clean": "nwb clean-module && nwb clean-demo", + "build": "nwb build-react-component --no-demo", + "clean": "nwb clean-module", "lint": "eslint ./src ./__tests__", "prepublishOnly": "npm run build", "start": "nwb serve-react-demo", @@ -21,12 +21,11 @@ "test:watch": "jest --watch" }, "dependencies": { - "@psychobolt/react-paperjs": "^1.0.0", - "@psychobolt/react-paperjs-editor": "^0.0.14", + "@psychobolt/react-paperjs": "< 1.0", + "@psychobolt/react-paperjs-editor": "0.0.11", "draft-js": "^0.11.6", "draft-js-export-html": "^1.4.1", "draft-js-import-html": "^1.4.1", - "immutable": "^4.0.0-rc.12", "material-ui-color-components": "^0.3.0", "paper": "^0.12.11", "react-color": "^2.18.1" @@ -38,8 +37,8 @@ "lodash": "^4.17.11", "mirador": "^3.0.0-rc.5", "prop-types": "^15.7.2", - "react": "^17.0", - "react-dom": "^17.0", + "react": "^16.8", + "react-dom": "^16.8", "uuid": "^8.0.0" }, "devDependencies": { @@ -49,27 +48,24 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.56", - "@wojtekmaj/enzyme-adapter-react-17": "^0.6.0", "babel-eslint": "^10.1.0", "canvas": "^2.6.1", "enzyme": "^3.11.0", "eslint": "^7.2", "eslint-config-airbnb": "^18.2.0", - "eslint-config-react-app": "^6.0.0", "eslint-plugin-flowtype": "^5.6.0", "eslint-plugin-import": "^2.22.0", "eslint-plugin-jest": "^23.18.0", - "eslint-plugin-jsx-a11y": "^6.3.1", + "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-react": "^7.20.3", - "eslint-plugin-react-hooks": "^4.0.6", "jest": "^26.1.0", "jest-canvas-mock": "^2.2.0", "jest-localstorage-mock": "^2.4.2", "mirador": "^3.0.0-rc.5", "nwb": "^0.24.7", "prop-types": "^15.7.2", - "react": "^17.0", - "react-dom": "^17.0", + "react": "^16.8", + "react-dom": "^16.8", "uuid": "^8.2.0" }, "author": "", diff --git a/setupJest.js b/setupJest.js index 25d5721d83fa403fc0304a6bed6fb88cc5b1cea3..c7aa0e3ea35ab4667dd5fcfb3dec8fcffadc7673 100644 --- a/setupJest.js +++ b/setupJest.js @@ -1,4 +1,3 @@ import Enzyme from 'enzyme'; // eslint-disable-line import/no-extraneous-dependencies -import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; // eslint-disable-line import/no-extraneous-dependencies Enzyme.configure({ adapter: new Adapter() }); diff --git a/src/AnnotationCreation.js b/src/AnnotationCreation.js index de8725d8593e946e146f31d972bdbc8b5caa616f..15ae285b5bcc8a4d283a5de191a1b2368998228d 100644 --- a/src/AnnotationCreation.js +++ b/src/AnnotationCreation.js @@ -39,6 +39,27 @@ import TextEditor from './TextEditor'; import WebAnnotation from './WebAnnotation'; import CursorIcon from './icons/Cursor'; +/** Extract time information from annotation target */ +function timeFromAnnoTarget(annotarget) { + // TODO w3c media fragments: t=,10 t=5, + const r = /t=([0-9]+),([0-9]+)/.exec(annotarget); + if (!r || r.length !== 3) { + return ['', '']; + } + return [r[1], r[2]]; +} + +/** Extract xywh from annotation target */ +function geomFromAnnoTarget(annotarget) { + console.warn('TODO proper extraction'); + const r = /xywh=((-?[0-9]+,?)+)/.exec(annotarget); + console.info('extracted from ', annotarget, r); + if (!r || r.length !== 3) { + return ['', '']; + } + return [r[1], r[2]]; +} + /** */ class AnnotationCreation extends Component { /** */ @@ -67,6 +88,8 @@ class AnnotationCreation extends Component { annoState.image = false; if (props.annotation) { + // + // annotation body if (Array.isArray(props.annotation.body)) { annoState.tags = []; props.annotation.body.forEach((body) => { @@ -85,28 +108,54 @@ class AnnotationCreation extends Component { annoState.textBody = props.annotation.body.value; annoState.image = props.annotation.body.image; } - + // + // drawing position 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=', ''); + // TODO proper fragment selector extraction + annoState.xywh = geomFromAnnoTarget(selector.value); + [annoState.tstart, annoState.tend] = timeFromAnnoTarget(selector.value); } }); } else { annoState.svg = props.annotation.target.selector.value; + // eslint-disable-next-line max-len + [annoState.tstart, annoState.tend] = timeFromAnnoTarget(props.annotation.target.selector.value); } } + // + // start/end time } + const toolState = { + activeTool: 'cursor', + closedMode: 'closed', + currentColorType: false, + fillColor: null, + strokeColor: '#00BFFF', + strokeWidth: 3, + ...(props.config.annotation.defaults || {}), + }; this.state = { + ...toolState, + annoBody: '', + textBody: '', activeTool: 'cursor', closedMode: 'closed', - colorPopoverOpen: false, currentColorType: false, fillColor: null, + lineWeightPopoverOpen: false, + openAddImgDialog: false, + popoverAnchorEl: null, + popoverLineWeightAnchorEl: null, + svg: null, + tend: '', + tstart: '', + textEditorStateBustingKey: 0, imgConstrain: false, imgHeight: { lastSubmittedValue: '', @@ -125,23 +174,18 @@ class AnnotationCreation extends Component { validity: 0, value: '', }, - lineWeightPopoverOpen: false, - openAddImgDialog: false, - popoverAnchorEl: null, - popoverLineWeightAnchorEl: null, - strokeColor: '#00BFFF', - strokeWidth: 1, - svg: null, - textBody: '', xywh: null, ...annoState, }; this.submitForm = this.submitForm.bind(this); + // this.updateBody = this.updateBody.bind(this); this.updateTextBody = this.updateTextBody.bind(this); this.getImgDimensions = this.getImgDimensions.bind(this); this.setImgWidth = this.setImgWidth.bind(this); this.setImgHeight = this.setImgHeight.bind(this); + this.updateTstart = this.updateTstart.bind(this); + this.updateTend = this.updateTend.bind(this); this.updateGeometry = this.updateGeometry.bind(this); this.changeTool = this.changeTool.bind(this); this.changeClosedMode = this.changeClosedMode.bind(this); @@ -380,13 +424,18 @@ class AnnotationCreation extends Component { submitForm(e) { e.preventDefault(); const { - annotation, canvases, closeCompanionWindow, receiveAnnotation, config, + annotation, canvases, receiveAnnotation, config, } = this.props; const { - textBody, image, imgWidth, imgHeight, imgUrl, tags, xywh, svg, imgConstrain, + textBody, image, imgWidth, imgHeight, imgUrl, tags, xywh, svg, + imgConstrain,tstart, tend, textEditorStateBustingKey, } = this.state; const annoBody = { value: textBody }; let imgBody; + let fsel = xywh; + if (tstart && tend) { + fsel = `${xywh || ''}&t=${tstart},${tend}`; + } if (imgWidth.validity === 1 && imgHeight.validity === 1 && imgUrl.validity === 1) { imgBody = { @@ -410,7 +459,7 @@ class AnnotationCreation extends Component { manifestId: canvas.options.resource.id, svg, tags, - xywh, + xywh: fsel, }).toJson(); if (annotation) { @@ -423,10 +472,13 @@ class AnnotationCreation extends Component { }); } }); + this.setState({ - activeTool: null, + annoBody: '', + svg: null, + textEditorStateBustingKey: textEditorStateBustingKey + 1, + xywh: null, }); - closeCompanionWindow(); } /** */ @@ -448,6 +500,12 @@ class AnnotationCreation extends Component { this.setState({ textBody }); } + /** update annotation start time */ + updateTstart(ev) { this.setState({ tstart: ev.target.value }); } + + /** update annotation end time */ + updateTend(ev) { this.setState({ tend: ev.target.value }); } + /** */ updateGeometry({ svg, xywh }) { this.setState({ @@ -464,8 +522,9 @@ class AnnotationCreation extends Component { const { activeTool, colorPopoverOpen, currentColorType, fillColor, openAddImgDialog, popoverAnchorEl, strokeColor, popoverLineWeightAnchorEl, lineWeightPopoverOpen, strokeWidth, closedMode, - textBody, imgUrl, imgWidth, imgHeight, imgConstrain, svg, + textBody, imgUrl, imgWidth, imgHeight, imgConstrain, svg, tstart, tend, textEditorStateBustingKey, } = this.state; + return ( <CompanionWindow title={annotation ? 'Edit annotation' : 'New annotation'} @@ -483,7 +542,7 @@ class AnnotationCreation extends Component { updateGeometry={this.updateGeometry} windowId={windowId} /> - <form onSubmit={this.submitForm}> + <form onSubmit={this.submitForm} className={classes.section}> <Grid container> <Grid item xs={12}> <Typography variant="overline"> @@ -591,6 +650,15 @@ class AnnotationCreation extends Component { </Grid> </Grid> <Grid container> + <Grid item xs={12}> + <Typography variant="overline"> + Duration + </Typography> + </Grid> + <Grid item xs={12}> + <input name="tstart" type="number" step="1" value={tstart} onChange={this.updateTstart} /> + <input name="tend" type="number" step="1" value={tend} onChange={this.updateTend} /> + </Grid> <Grid item xs={12}> <Typography variant="overline"> Image Content @@ -675,6 +743,11 @@ class AnnotationCreation extends Component { </Typography> </Grid> <Grid item xs={12}> + <TextEditor + key={textEditorStateBustingKey} + annoHtml={annoBody} + updateAnnotationBody={this.updateBody} + /> <TextEditor annoHtml={textBody} updateAnnotationBody={this.updateTextBody} @@ -694,12 +767,15 @@ class AnnotationCreation extends Component { > <Paper> <ClickAwayListener onClickAway={this.handleCloseLineWeight}> - <MenuList> + <MenuList autoFocus role="listbox"> {[1, 3, 5, 10, 50].map((option, index) => ( <MenuItem key={option} onClick={this.handleLineWeightSelect} value={option} + selected={option == strokeWidth} + role="option" + aria-selected={option == strokeWidth} > {option} </MenuItem> @@ -743,9 +819,16 @@ const styles = (theme) => ({ display: 'flex', flexWrap: 'wrap', }, + section: { + paddingBottom: theme.spacing(1), + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(1), + paddingTop: theme.spacing(2), + }, }); AnnotationCreation.propTypes = { + // TODO proper web annotation type ? annotation: PropTypes.object, // eslint-disable-line react/forbid-prop-types canvases: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.string, index: PropTypes.number }), @@ -755,6 +838,11 @@ AnnotationCreation.propTypes = { config: PropTypes.shape({ annotation: PropTypes.shape({ adapter: PropTypes.func, + defaults: PropTypes.objectOf( + PropTypes.oneOfType( + [PropTypes.bool, PropTypes.func, PropTypes.number, PropTypes.string] + ) + ), }), }).isRequired, id: PropTypes.string.isRequired, diff --git a/src/AnnotationDrawing.js b/src/AnnotationDrawing.js index 8cb4cfa887b3a36fcdf6bf113caa668fe01f12f5..b817d2425f045a25087d0e6f7b3557ee71b2ccf9 100644 --- a/src/AnnotationDrawing.js +++ b/src/AnnotationDrawing.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import { OSDReferences } from 'mirador/dist/es/src/plugins/OSDReferences'; +import { VideoViewersReferences } from 'mirador/dist/es/src/plugins/VideoViewersReferences'; import { renderWithPaperScope, PaperContainer } from '@psychobolt/react-paperjs'; import { @@ -10,12 +11,23 @@ import RectangleTool, FreeformPathTool, } - from '@psychobolt/react-paperjs-editor'; +from '@psychobolt/react-paperjs-editor'; import { Point } from 'paper'; import flatten from 'lodash/flatten'; import EditTool from './EditTool'; import { mapChildren } from './utils'; +/** Use a canvas "like a OSD viewport" (temporary) */ +function viewportFromAnnotationOverlayVideo(annotationOverlayVideo) { + const { canvas } = annotationOverlayVideo; + return { + getCenter: () => ({ x: canvas.getWidth() / 2, y: canvas.getHeight() / 2 }), + getFlip: () => false, + getRotation: () => false, + getZoom: () => 1, + }; +} + /** */ class AnnotationDrawing extends Component { /** */ @@ -25,12 +37,6 @@ class AnnotationDrawing extends Component { this.addPath = this.addPath.bind(this); } - /** */ - componentDidMount() { - const { windowId } = this.props; - this.OSDReference = OSDReferences.get(windowId); - } - /** */ addPath(path) { const { closed, strokeWidth, updateGeometry } = this.props; @@ -61,23 +67,32 @@ class AnnotationDrawing extends Component { /** */ paperThing() { + const { windowId } = this.props; + let viewport = null; + let img = null; + if (OSDReferences.get(windowId)) { + console.debug('[annotation-plugin] OSD reference: ', OSDReferences.get(windowId)); + viewport = OSDReferences.get(windowId).current.viewport; + img = OSDReferences.get(windowId).current.world.getItemAt(0); + } else if (VideoViewersReferences.get(windowId)) { + console.debug('[annotation-plugin] VideoViewers reference: ', VideoViewersReferences.get(windowId)); + viewport = viewportFromAnnotationOverlayVideo(VideoViewersReferences.get(windowId).props); + } const { activeTool, fillColor, strokeColor, strokeWidth, svg, } = this.props; if (!activeTool || activeTool === 'cursor') return null; - // Setup Paper View to have the same center and zoom as the OSD Viewport - const viewportZoom = this.OSDReference.viewport.getZoom(true); - const image1 = this.OSDReference.world.getItemAt(0); - const center = image1.viewportToImageCoordinates( - this.OSDReference.viewport.getCenter(true), - ); - const flipped = this.OSDReference.viewport.getFlip(); + // Setup Paper View to have the same center and zoom as the OSD Viewport/video canvas + const center = img + ? img.viewportToImageCoordinates(viewport.getCenter(true)) + : viewport.getCenter(); + const flipped = viewport.getFlip(); const viewProps = { center: new Point(center.x, center.y), - rotation: this.OSDReference.viewport.getRotation(), + rotation: viewport.getRotation(), scaling: new Point(flipped ? -1 : 1, 1), - zoom: image1.viewportToImageZoom(viewportZoom), + zoom: img ? img.viewportToImageZoom(viewport.getZoom()) : viewport.getZoom(), }; let ActiveTool = RectangleTool; @@ -141,9 +156,12 @@ class AnnotationDrawing extends Component { /** */ render() { const { windowId } = this.props; - this.OSDReference = OSDReferences.get(windowId).current; + const container = OSDReferences.get(windowId) + ? OSDReferences.get(windowId).current.element + : VideoViewersReferences.get(windowId).apiRef.current; + return ( - ReactDOM.createPortal(this.paperThing(), this.OSDReference.element) + ReactDOM.createPortal(this.paperThing(), container) ); } } diff --git a/src/TextEditor.js b/src/TextEditor.js index 536ba33b5bfb1236f44d200ec84e4190a3a8bd74..3dcaee93331711730837351c78e6bc8794bd7f62 100644 --- a/src/TextEditor.js +++ b/src/TextEditor.js @@ -20,6 +20,16 @@ class TextEditor extends Component { this.onChange = this.onChange.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this); this.handleFormating = this.handleFormating.bind(this); + this.handleFocus = this.handleFocus.bind(this); + this.editorRef = React.createRef(); + } + + /** + * This is a kinda silly hack (but apparently recommended approach) to + * making sure the whole visible editor area is clickable, not just the first line. + */ + handleFocus() { + if (this.editorRef.current) this.editorRef.current.focus(); } /** */ @@ -58,6 +68,7 @@ class TextEditor extends Component { const { classes } = this.props; const { editorState } = this.state; const currentStyle = editorState.getCurrentInlineStyle(); + return ( <div> <ToggleButtonGroup @@ -77,11 +88,13 @@ class TextEditor extends Component { <ItalicIcon /> </ToggleButton> </ToggleButtonGroup> - <div className={classes.editorRoot}> + + <div className={classes.editorRoot} onClick={this.handleFocus}> <Editor editorState={editorState} handleKeyCommand={this.handleKeyCommand} onChange={this.onChange} + ref={this.editorRef} /> </div> </div>