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 01cbb1fc559b7e72b07a8b59cfbeec119fe308fd..f3135b91305ed26ac02ad384ee98e18cad5680b2 100644 --- a/__tests__/WebAnnotation.test.js +++ b/__tests__/WebAnnotation.test.js @@ -131,23 +131,21 @@ describe('WebAnnotation', () => { }); }); it('with image and text', () => { - subject = createSubject({ body: { value: 'hello' }, image: { url: 'http://example.photo/pic.jpg' }, tags: null }); + subject = createSubject({ body: { value: 'hello' }, image: { id: 'http://example.photo/pic.jpg' }, tags: null }); expect(subject.createBody()).toEqual([ { type: 'TextualBody', value: 'hello', }, { - format: 'image/jpg', id: 'http://example.photo/pic.jpg', type: 'Image', }, ]); }); it('with image only', () => { - subject = createSubject({ body: null, image: { url: 'http://example.photo/pic.jpg' }, tags: null }); + subject = createSubject({ body: null, image: { id: 'http://example.photo/pic.jpg' }, tags: null }); expect(subject.createBody()).toEqual({ - format: 'image/jpg', id: 'http://example.photo/pic.jpg', type: 'Image', }); diff --git a/src/AnnotationCreation.js b/src/AnnotationCreation.js index 9d5dd16d41cc9fcf57f7562a047a84db424549c7..a72410a4527f3e6b2ddd8291e30e791b9f93e375 100644 --- a/src/AnnotationCreation.js +++ b/src/AnnotationCreation.js @@ -19,14 +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 InsertPhotoIcon from '@material-ui/icons/InsertPhoto'; -import Dialog from '@material-ui/core/Dialog'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogActions from '@material-ui/core/DialogActions'; import TextField from '@material-ui/core/TextField'; -import Checkbox from '@material-ui/core/Checkbox'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; import { SketchPicker } from 'react-color'; import { v4 as uuid } from 'uuid'; import { withStyles } from '@material-ui/core/styles'; @@ -38,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 */ @@ -63,31 +57,11 @@ function geomFromAnnoTarget(annotarget) { /** */ class AnnotationCreation extends Component { - /** */ - static checkURL(url) { - const expression = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/; - const regex = new RegExp(expression); - - return url.match(regex); - } - - /** */ - static loadImg(url) { - return new Promise((resolve, reject) => { - const img = new Image(); - img.src = url; - - img.onload = () => resolve({ height: img.height, width: img.width }); - img.onerror = reject; - }); - } - /** */ constructor(props) { super(props); const annoState = {}; - annoState.image = false; if (props.annotation) { // @@ -155,26 +129,8 @@ class AnnotationCreation extends Component { closedMode: 'closed', currentColorType: false, fillColor: null, - imgConstrain: false, - imgHeight: { - lastSubmittedValue: '', - srcValue: '', - validity: 0, - value: '', - }, - imgUrl: { - lastSubmittedValue: '', - validity: 0, - value: '', - }, - imgWidth: { - lastSubmittedValue: '', - srcValue: '', - validity: 0, - value: '', - }, + image: { id: null }, lineWeightPopoverOpen: false, - openAddImgDialog: false, popoverAnchorEl: null, popoverLineWeightAnchorEl: null, svg: null, @@ -187,9 +143,6 @@ class AnnotationCreation extends Component { 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.setTstartNow = this.setTstartNow.bind(this); @@ -205,79 +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.isConstrained = this.isConstrained.bind(this); - this.handleConstrainCheck = this.handleConstrainCheck.bind(this); - this.handleImgDialogChange = this.handleImgDialogChange.bind(this); - this.handleImgDialogSubmit = this.handleImgDialogSubmit.bind(this); + this.handleImgChange = this.handleImgChange.bind(this); } /** */ - handleImgDialogChange(open) { - const { imgHeight, imgWidth, imgUrl } = this.state; - - this.setState({ - imgHeight: { - ...imgHeight, - validity: 1, - value: imgHeight.lastSubmittedValue, - }, - imgUrl: { - ...imgUrl, - validity: 1, - value: imgUrl.lastSubmittedValue, - }, - imgWidth: { - ...imgWidth, - validity: 1, - value: imgWidth.lastSubmittedValue, - }, - openAddImgDialog: open, - }); - } - - /** */ - handleConstrainCheck(event) { - const value = event.target.checked; - - this.setState({ - imgConstrain: value, - }); - } - - /** */ - handleImgDialogSubmit() { - let open = true; - const { imgUrl, imgHeight, imgWidth } = this.state; - - const urlValidity = AnnotationCreation.checkURL(imgUrl.value) ? 1 : 2; - const widthValidity = imgWidth.value > 0 ? 1 : 2; - const heightValidity = imgHeight.value > 0 ? 1 : 2; - if (urlValidity === 1 && widthValidity === 1 && heightValidity === 1) { - open = false; - } - - this.setState({ - image: { id: imgUrl.value }, - imgHeight: { - ...imgHeight, - lastSubmittedValue: heightValidity === 1 ? imgHeight.value : imgHeight.lastSubmittedValue, - validity: heightValidity, - value: imgHeight.value, - }, - imgUrl: { - ...imgUrl, - lastSubmittedValue: urlValidity === 1 ? imgUrl.value : imgUrl.lastSubmittedValue, - validity: urlValidity, - value: imgUrl.value, - }, - imgWidth: { - ...imgWidth, - lastSubmittedValue: widthValidity === 1 ? imgWidth.value : imgWidth.lastSubmittedValue, - validity: widthValidity, - value: imgWidth.value, - }, - openAddImgDialog: open, - }); + handleImgChange(newUrl, imgRef) { + const { image } = this.state; + this.setState({ image: { ...image, id: newUrl } }); } /** */ @@ -309,75 +196,6 @@ class AnnotationCreation extends Component { this.setState({ tend: Math.floor(this.props.currentTime) }); } - /** */ - async getImgDimensions(url) { - const { imgHeight, imgWidth, imgUrl } = this.state; - const urlValidity = AnnotationCreation.checkURL(url) ? 1 : 2; - - try { - const dimensions = await AnnotationCreation.loadImg(url); - - this.setState({ - imgHeight: { - ...imgHeight, - srcValue: dimensions.height || '', - value: dimensions.height || '', - }, - imgUrl: { - ...imgUrl, - validity: 1, - value: url, - }, - imgWidth: { - ...imgWidth, - srcValue: dimensions.width || '', - value: dimensions.width || '', - }, - }); - } catch (e) { - this.setState({ - imgUrl: { - ...imgUrl, - validity: urlValidity, - value: url, - }, - }); - } - } - - /** */ - setImgWidth(value) { - const { imgWidth } = this.state; - this.setState({ - imgWidth: { - ...imgWidth, - value, - }, - }); - } - - /** */ - setImgUrl(value) { - const { imgUrl } = this.state; - this.setState({ - imgUrl: { - ...imgUrl, - value, - }, - }); - } - - /** */ - setImgHeight(value) { - const { imgHeight } = this.state; - this.setState({ - imgHeight: { - ...imgHeight, - value, - }, - }); - } - /** seekTo/goto annotation start time */ seekToTstart() { const { paused, setCurrentTime, setSeekTo } = this.props; @@ -406,37 +224,6 @@ class AnnotationCreation extends Component { /** update annotation end time */ updateTend(value) { this.setState({ tend: value }); } - /** */ - isConstrained(event) { // adjust other dimension in proportion to inputted dimension - const { imgConstrain, imgWidth, imgHeight } = this.state; - const ratio = imgWidth.srcValue / imgHeight.srcValue; - - if (imgConstrain) { - if (event.target.id === 'width' && imgWidth.srcValue !== '') { - // set height to be the same as width if width is less than 0 - const height = imgWidth.value > 0 ? imgWidth.value * (1 / ratio) : imgWidth.value; - this.setState({ - imgHeight: { - ...imgHeight, - validity: 1, - value: height, - }, - }); - } else if (event.target.id === 'height' && imgHeight.srcValue !== '') { - // set width to be the same as height if height is less than 0 - const width = imgHeight.value > 0 ? imgHeight.value * ratio : imgHeight.value; - - this.setState({ - imgWidth: { - ...imgWidth, - validity: 1, - value: width, - }, - }); - } - } - } - /** */ openChooseColor(e) { this.setState({ @@ -478,33 +265,20 @@ class AnnotationCreation extends Component { annotation, canvases, receiveAnnotation, config, } = this.props; const { - textBody, image, imgWidth, imgHeight, imgUrl, tags, xywh, svg, - imgConstrain, tstart, tend, textEditorStateBustingKey, + textBody, image, tags, xywh, svg, tstart, tend, textEditorStateBustingKey, } = this.state; const t = (tstart && tend) ? `${tstart},${tend}` : null; - const annoBody = { value: (!textBody.length && t) ? `${secondsToHMS(tstart)} -> ${secondsToHMS(tend)}` : textBody }; - - let imgBody; - if (imgWidth.validity === 1 && imgHeight.validity === 1 && imgUrl.validity === 1) { - imgBody = { - constrain: imgConstrain, - h: imgHeight.value, - url: imgUrl.value, - w: imgWidth.value, - }; - } else { - imgBody = image; - } + 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, + body, canvasId: canvas.id, fragsel: { t, xywh }, id: (annotation && annotation.id) || `${uuid()}`, - image: imgBody, + image, manifestId: canvas.options.resource.id, svg, tags, @@ -522,7 +296,7 @@ class AnnotationCreation extends Component { }); this.setState({ - image: false, + image: { id: null }, svg: null, tend: null, textBody: '', @@ -565,9 +339,9 @@ class AnnotationCreation extends Component { } = this.props; const { - activeTool, colorPopoverOpen, currentColorType, fillColor, openAddImgDialog, popoverAnchorEl, + activeTool, colorPopoverOpen, currentColorType, fillColor, popoverAnchorEl, strokeColor, popoverLineWeightAnchorEl, lineWeightPopoverOpen, strokeWidth, closedMode, - textBody, imgUrl, imgWidth, imgHeight, imgConstrain, svg, tstart, tend, + textBody, svg, tstart, tend, textEditorStateBustingKey, image, } = this.state; @@ -740,79 +514,8 @@ class AnnotationCreation extends Component { </Typography> </Grid> <Grid item xs={12} style={{ marginBottom: 10 }}> - <ToggleButton value="image-icon" aria-label="insert an image" onClick={() => this.handleImgDialogChange(true)}> - { image === false && <InsertPhotoIcon /> } - { image !== false && <img src={image.id} width="100" height="auto" alt="loading failed" /> } - </ToggleButton> + <ImageFormField value={image} onChange={this.handleImgChange} /> </Grid> - <Dialog open={openAddImgDialog} fullWidth onClose={() => this.handleImgDialogChange(false)} aria-labelledby="form-dialog-title"> - <DialogTitle id="form-dialog-title" disableTypography> - <Typography variant="h2">Insert image</Typography> - </DialogTitle> - <DialogContent> - <DialogTitle id="form-dialog-subtitle-1" style={{ paddingLeft: 0 }} disableTypography> - <Typography variant="h5">Image source</Typography> - </DialogTitle> - <TextField - value={imgUrl.value} - onChange={(e) => this.setImgUrl(e.target.value)} - onBlur={(e) => this.getImgDimensions(e.target.value)} - error={imgUrl.validity === 2} - helperText={imgUrl.validity === 2 ? 'Invalid URL' : ''} - margin="dense" - id="source" - label="Image URL" - type="url" - fullWidth - /> - </DialogContent> - <DialogContent> - <DialogTitle id="form-dialog-subtitle-2" style={{ paddingLeft: 0 }} disableTypography> - <Typography variant="h5">Image dimensions</Typography> - </DialogTitle> - <TextField - value={imgWidth.value} - style={{ marginRight: 10, width: 100 }} - onChange={(e) => this.setImgWidth(e.target.value)} - onBlur={(e) => this.isConstrained(e)} - error={imgWidth.validity === 2} - helperText={imgWidth.validity === 2 ? 'Invalid width' : ''} - margin="dense" - id="width" - label="Width" - type="number" - variant="outlined" - /> - <TextField - value={imgHeight.value} - style={{ marginLeft: 10, width: 100 }} - onChange={(e) => this.setImgHeight(e.target.value)} - onBlur={(e) => this.isConstrained(e)} - error={imgHeight.validity === 2} - helperText={imgHeight.validity === 2 ? 'Invalid height' : ''} - margin="dense" - id="height" - label="Height" - type="number" - variant="outlined" - /> - <FormControlLabel - control={( - <Checkbox - checked={imgConstrain} - onChange={(e) => this.handleConstrainCheck(e)} - inputProps={{ 'aria-label': 'primary checkbox' }} - style={{ marginLeft: 30 }} - /> - )} - label="Constrain proportions" - /> - </DialogContent> - <DialogActions> - <Button onClick={() => this.handleImgDialogChange(false)}>Cancel</Button> - <Button variant="contained" onClick={this.handleImgDialogSubmit} color="primary">Add</Button> - </DialogActions> - </Dialog> <Grid item xs={12}> <Typography variant="overline"> Text Content 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 86ecbfa452229f2d00b3ae2ff7be9be4819988dd..ea9396572e7cf9ff210a05578988177a62b0508c 100644 --- a/src/WebAnnotation.js +++ b/src/WebAnnotation.js @@ -37,11 +37,11 @@ export default class WebAnnotation { bodies.push(textBody); } - // TODO correct WebAnnotation format if (this.image) { - const imgBody = { type: 'Image' }; - Object.assign(imgBody, this.image); - imgBody.id = imgBody.url; + const imgBody = { + id: this.image.id, + type: 'Image', + }; bodies.push(imgBody); }