diff --git a/.eslintrc b/.eslintrc index f3fe6398314c69b792d21eb4d7c8973e3bc0cc24..b7539c708583ffebd93c52bc000cfd5e16df3e52 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,7 @@ "env": { "jest/globals": true }, - "extends": ["airbnb","react-app"], + "extends": ["airbnb", "react-app"], "globals": { "page": true, "document": true diff --git a/__tests__/AnnotationCreation.test.js b/__tests__/AnnotationCreation.test.js index d0a355d746333ab1330cbdc119c99fa262a45b2e..adf15672e122018f778c82cdff883ed298874ffa 100644 --- a/__tests__/AnnotationCreation.test.js +++ b/__tests__/AnnotationCreation.test.js @@ -4,6 +4,7 @@ import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'; import AnnotationCreation from '../src/AnnotationCreation'; import AnnotationDrawing from '../src/AnnotationDrawing'; import TextEditor from '../src/TextEditor'; +import ImageFormField from '../src/ImageFormField'; /** */ function createWrapper(props) { @@ -36,6 +37,10 @@ describe('AnnotationCreation', () => { wrapper = createWrapper(); expect(wrapper.dive().find(TextEditor).length).toBe(1); }); + it('adds the ImageFormField component', () => { + wrapper = createWrapper(); + expect(wrapper.dive().find(ImageFormField).length).toBe(1); + }); it('can handle annotations without target selector', () => { wrapper = createWrapper({ annotation: { diff --git a/__tests__/WebAnnotation.test.js b/__tests__/WebAnnotation.test.js index 0e4baa8c3885e1ae568647cebf3f8bf72632f680..f3135b91305ed26ac02ad384ee98e18cad5680b2 100644 --- a/__tests__/WebAnnotation.test.js +++ b/__tests__/WebAnnotation.test.js @@ -3,7 +3,9 @@ import WebAnnotation from '../src/WebAnnotation'; /** */ function createSubject(args = {}) { return new WebAnnotation({ - body: 'body', + body: { + value: 'body', + }, canvasId: 'canvasId', fragsel: { t: '5,10', xywh: 'xywh' }, id: 'id', @@ -17,11 +19,16 @@ describe('WebAnnotation', () => { let subject = createSubject(); describe('constructor', () => { it('sets instance accessors', () => { - ['body', 'canvasId', 'id', 'svg'].forEach((prop) => { + ['canvasId', 'id', 'svg'].forEach((prop) => { expect(subject[prop]).toBe(prop); }); expect(subject.fragsel).toStrictEqual({ t: '5,10', xywh: 'xywh' }); }); + it('sets instance accessors for body', () => { + ['body'].forEach((prop) => { + expect(subject[prop].value).toBe(prop); + }); + }); }); describe('target', () => { it('with svg and xywh', () => { @@ -109,20 +116,40 @@ describe('WebAnnotation', () => { ]); }); it('with text only', () => { - subject = createSubject({ tags: null }); + subject = createSubject({ image: null, tags: null }); expect(subject.createBody()).toEqual({ type: 'TextualBody', value: 'body', }); }); it('with tags only', () => { - subject = createSubject({ body: null }); + subject = createSubject({ body: null, image: null }); expect(subject.createBody()).toEqual({ purpose: 'tagging', type: 'TextualBody', value: 'tags', }); }); + it('with image and text', () => { + subject = createSubject({ body: { value: 'hello' }, image: { id: 'http://example.photo/pic.jpg' }, tags: null }); + expect(subject.createBody()).toEqual([ + { + type: 'TextualBody', + value: 'hello', + }, + { + id: 'http://example.photo/pic.jpg', + type: 'Image', + }, + ]); + }); + it('with image only', () => { + subject = createSubject({ body: null, image: { id: 'http://example.photo/pic.jpg' }, tags: null }); + expect(subject.createBody()).toEqual({ + id: 'http://example.photo/pic.jpg', + type: 'Image', + }); + }); }); describe('toJson', () => { it('generates a WebAnnotation', () => { diff --git a/package-lock.json b/package-lock.json index 98281b2d8afd5f48f85609fa7817943be736ea17..18c66e7849c2f0a1074e9bf4fffa359534a2e081 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15514,7 +15514,7 @@ }, "node_modules/mirador": { "version": "3.3.0", - "resolved": "git+https://gitlab.tetras-libre.fr/iiif/mirador-video-annotation#8d142157eeb008edd0761859b6ad8abfa564c2a6", + "resolved": "git+https://gitlab.tetras-libre.fr/iiif/mirador-video-annotation#047b206353616adc135bcd3b018da9857c4222d6", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -34987,7 +34987,7 @@ } }, "mirador": { - "version": "git+https://gitlab.tetras-libre.fr/iiif/mirador-video-annotation#8d142157eeb008edd0761859b6ad8abfa564c2a6", + "version": "git+https://gitlab.tetras-libre.fr/iiif/mirador-video-annotation#047b206353616adc135bcd3b018da9857c4222d6", "dev": true, "from": "mirador@git+https://gitlab.tetras-libre.fr/iiif/mirador-video-annotation#annotation-on-video", "requires": { diff --git a/src/AnnotationCreation.js b/src/AnnotationCreation.js index ba428ddb855a4c5365fed643d1689966fbde63f5..a72410a4527f3e6b2ddd8291e30e791b9f93e375 100644 --- a/src/AnnotationCreation.js +++ b/src/AnnotationCreation.js @@ -19,6 +19,7 @@ import StrokeColorIcon from '@material-ui/icons/BorderColor'; import LineWeightIcon from '@material-ui/icons/LineWeight'; import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'; import FormatShapesIcon from '@material-ui/icons/FormatShapes'; +import TextField from '@material-ui/core/TextField'; import { SketchPicker } from 'react-color'; import { v4 as uuid } from 'uuid'; import { withStyles } from '@material-ui/core/styles'; @@ -30,6 +31,7 @@ import TextEditor from './TextEditor'; import WebAnnotation from './WebAnnotation'; import CursorIcon from './icons/Cursor'; import HMSInput from './HMSInput'; +import ImageFormField from './ImageFormField'; import { secondsToHMS } from './utils'; /** Extract time information from annotation target */ @@ -60,20 +62,27 @@ class AnnotationCreation extends Component { super(props); const annoState = {}; + if (props.annotation) { // // annotation body if (Array.isArray(props.annotation.body)) { annoState.tags = []; props.annotation.body.forEach((body) => { - if (body.purpose === 'tagging') { + if (body.purpose === 'tagging' && body.type === 'TextualBody') { annoState.tags.push(body.value); - } else { - annoState.annoBody = body.value; + } else if (body.type === 'TextualBody') { + annoState.textBody = body.value; + } else if (body.type === 'Image') { + // annoState.textBody = body.value; // why text body here ??? + annoState.image = body; } }); - } else { - annoState.annoBody = props.annotation.body.value; + } else if (props.annotation.body.type === 'TextualBody') { + annoState.textBody = props.annotation.body.value; + } else if (props.annotation.body.type === 'Image') { + // annoState.textBody = props.annotation.body.value; // why text body here ??? + annoState.image = props.annotation.body; } // // drawing position @@ -101,6 +110,7 @@ class AnnotationCreation extends Component { const toolState = { activeTool: 'cursor', closedMode: 'closed', + colorPopoverOpen: false, currentColorType: false, fillColor: null, strokeColor: '#00BFFF', @@ -115,19 +125,24 @@ class AnnotationCreation extends Component { this.state = { ...toolState, ...timeState, - annoBody: '', - colorPopoverOpen: false, + activeTool: 'cursor', + closedMode: 'closed', + currentColorType: false, + fillColor: null, + image: { id: null }, lineWeightPopoverOpen: false, popoverAnchorEl: null, popoverLineWeightAnchorEl: null, svg: null, + textBody: '', textEditorStateBustingKey: 0, xywh: null, ...annoState, }; this.submitForm = this.submitForm.bind(this); - this.updateBody = this.updateBody.bind(this); + // this.updateBody = this.updateBody.bind(this); + this.updateTextBody = this.updateTextBody.bind(this); this.updateTstart = this.updateTstart.bind(this); this.updateTend = this.updateTend.bind(this); this.setTstartNow = this.setTstartNow.bind(this); @@ -143,6 +158,13 @@ class AnnotationCreation extends Component { this.handleCloseLineWeight = this.handleCloseLineWeight.bind(this); this.closeChooseColor = this.closeChooseColor.bind(this); this.updateStrokeColor = this.updateStrokeColor.bind(this); + this.handleImgChange = this.handleImgChange.bind(this); + } + + /** */ + handleImgChange(newUrl, imgRef) { + const { image } = this.state; + this.setState({ image: { ...image, id: newUrl } }); } /** */ @@ -174,12 +196,6 @@ class AnnotationCreation extends Component { this.setState({ tend: Math.floor(this.props.currentTime) }); } - /** update annotation start time */ - updateTstart(value) { this.setState({ tstart: value }); } - - /** update annotation end time */ - updateTend(value) { this.setState({ tend: value }); } - /** seekTo/goto annotation start time */ seekToTstart() { const { paused, setCurrentTime, setSeekTo } = this.props; @@ -202,6 +218,12 @@ class AnnotationCreation extends Component { } } + /** update annotation start time */ + updateTstart(value) { this.setState({ tstart: value }); } + + /** update annotation end time */ + updateTend(value) { this.setState({ tend: value }); } + /** */ openChooseColor(e) { this.setState({ @@ -243,20 +265,25 @@ class AnnotationCreation extends Component { annotation, canvases, receiveAnnotation, config, } = this.props; const { - annoBody, tags, xywh, svg, tstart, tend, textEditorStateBustingKey, + textBody, image, tags, xywh, svg, tstart, tend, textEditorStateBustingKey, } = this.state; const t = (tstart && tend) ? `${tstart},${tend}` : null; + const body = { value: (!textBody.length && t) ? `${secondsToHMS(tstart)} -> ${secondsToHMS(tend)}` : textBody }; + canvases.forEach((canvas) => { const storageAdapter = config.annotation.adapter(canvas.id); + const anno = new WebAnnotation({ - body: (!annoBody.length && t) ? `${secondsToHMS(tstart)} -> ${secondsToHMS(tend)}` : annoBody, + body, canvasId: canvas.id, fragsel: { t, xywh }, id: (annotation && annotation.id) || `${uuid()}`, + image, manifestId: canvas.options.resource.id, svg, tags, }).toJson(); + if (annotation) { storageAdapter.update(anno).then((annoPage) => { receiveAnnotation(canvas.id, storageAdapter.annotationPageId, annoPage); @@ -269,9 +296,10 @@ class AnnotationCreation extends Component { }); this.setState({ - annoBody: '', + image: { id: null }, svg: null, tend: null, + textBody: '', textEditorStateBustingKey: textEditorStateBustingKey + 1, tstart: null, xywh: null, @@ -293,8 +321,8 @@ class AnnotationCreation extends Component { } /** */ - updateBody(annoBody) { - this.setState({ annoBody }); + updateTextBody(textBody) { + this.setState({ textBody }); } /** */ @@ -311,9 +339,10 @@ class AnnotationCreation extends Component { } = this.props; const { - activeTool, colorPopoverOpen, currentColorType, fillColor, popoverAnchorEl, strokeColor, - popoverLineWeightAnchorEl, lineWeightPopoverOpen, strokeWidth, closedMode, annoBody, svg, - tstart, tend, textEditorStateBustingKey, + activeTool, colorPopoverOpen, currentColorType, fillColor, popoverAnchorEl, + strokeColor, popoverLineWeightAnchorEl, lineWeightPopoverOpen, strokeWidth, closedMode, + textBody, svg, tstart, tend, + textEditorStateBustingKey, image, } = this.state; const mediaIsVideo = typeof VideosReferences.get(windowId) !== 'undefined'; @@ -326,6 +355,7 @@ class AnnotationCreation extends Component { > <AnnotationDrawing activeTool={activeTool} + annotation={annotation} fillColor={fillColor} strokeColor={strokeColor} strokeWidth={strokeWidth} @@ -440,7 +470,6 @@ class AnnotationCreation extends Component { ) : null } - </Grid> </Grid> <Grid container> @@ -464,7 +493,7 @@ class AnnotationCreation extends Component { <Grid item xs={12}> <Typography variant="overline"> - <ToggleButton value="true" title="Go to start time" size="small" onClick={this.seekToTend} className={classes.timecontrolsbutton}> + <ToggleButton value="true" title="Go to end time" size="small" onClick={this.seekToTend} className={classes.timecontrolsbutton}> <LastPage /> </ToggleButton> End @@ -481,14 +510,22 @@ class AnnotationCreation extends Component { )} <Grid item xs={12}> <Typography variant="overline"> - Content + Image Content + </Typography> + </Grid> + <Grid item xs={12} style={{ marginBottom: 10 }}> + <ImageFormField value={image} onChange={this.handleImgChange} /> + </Grid> + <Grid item xs={12}> + <Typography variant="overline"> + Text Content </Typography> </Grid> <Grid item xs={12}> <TextEditor key={textEditorStateBustingKey} - annoHtml={annoBody} - updateAnnotationBody={this.updateBody} + annoHtml={textBody} + updateAnnotationBody={this.updateTextBody} /> </Grid> </Grid> diff --git a/src/ImageFormField.js b/src/ImageFormField.js new file mode 100644 index 0000000000000000000000000000000000000000..b7162a47c77ebcb9a7db666901bf7240626306d8 --- /dev/null +++ b/src/ImageFormField.js @@ -0,0 +1,62 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withStyles } from '@material-ui/core/styles'; +import { TextField } from '@material-ui/core'; + +/** URL input with an <img> preview */ +class ImageFormField extends Component { + /** */ + constructor(props) { + super(props); + + this.inputRef = React.createRef(); + } + + /** Render input field and a preview if the input is valid */ + render() { + const { value: image, classes, onChange } = this.props; + const imgIsValid = this.inputRef.current + ? (image.id && this.inputRef.current.checkValidity()) : image.id; + const imgUrl = image.id === null ? '' : image.id; + return ( + <div className={classes.root}> + <TextField + value={imgUrl} + onChange={(ev) => onChange(ev.target.value)} + error={imgUrl !== '' && !imgIsValid} + margin="dense" + label="Image URL" + type="url" + fullWidth + inputRef={this.inputRef} + /> + { imgIsValid + && <img src={image.id} width="100%" height="auto" alt="loading failed" /> } + </div> + ); + } +} + +/** custom css */ +const styles = (theme) => ({ + root: { + alignItems: 'center', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + }, +}); + +ImageFormField.propTypes = { + // eslint-disable-next-line react/forbid-prop-types + classes: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + value: PropTypes.shape({ + id: PropTypes.string, + }).isRequired, +}; + +ImageFormField.defaultProps = { +}; + +export default withStyles(styles)(ImageFormField); diff --git a/src/WebAnnotation.js b/src/WebAnnotation.js index 78cd79f5068284f3a03e1d984080bd94d7885231..ea9396572e7cf9ff210a05578988177a62b0508c 100644 --- a/src/WebAnnotation.js +++ b/src/WebAnnotation.js @@ -2,7 +2,7 @@ export default class WebAnnotation { /** */ constructor({ - canvasId, id, fragsel, body, tags, svg, manifestId, + canvasId, id, fragsel, image, body, tags, svg, manifestId, }) { this.id = id; this.canvasId = canvasId; @@ -10,6 +10,7 @@ export default class WebAnnotation { this.body = body; this.tags = tags; this.svg = svg; + this.image = image; this.manifestId = manifestId; } @@ -27,12 +28,23 @@ export default class WebAnnotation { /** */ createBody() { let bodies = []; - if (this.body) { - bodies.push({ + + if (this.body && this.body.value !== '') { + const textBody = { type: 'TextualBody', - value: this.body, - }); + value: this.body.value, + }; + bodies.push(textBody); } + + if (this.image) { + const imgBody = { + id: this.image.id, + type: 'Image', + }; + bodies.push(imgBody); + } + if (this.tags) { bodies = bodies.concat(this.tags.map((tag) => ({ purpose: 'tagging',