diff --git a/__tests__/WebAnnotation.test.js b/__tests__/WebAnnotation.test.js index 0e4baa8c3885e1ae568647cebf3f8bf72632f680..ee08252a386eac1bf4d26c2fb1f9b8462b0ad3c9 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,34 @@ 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: {url: 'http://example.photo/pic.jpg' }, tags: null }); + expect(subject.createBody()).toEqual([ + { + type: 'TextualBody', + value: 'hello', + }, + { + type: 'Image', + format: 'image/jpg', + id: 'http://example.photo/pic.jpg', + }, + ]); + }); }); describe('toJson', () => { it('generates a WebAnnotation', () => { diff --git a/src/AnnotationCreation.js b/src/AnnotationCreation.js index ba428ddb855a4c5365fed643d1689966fbde63f5..471f18f258e9bc764e45d541ee5f0b55ab74d7df 100644 --- a/src/AnnotationCreation.js +++ b/src/AnnotationCreation.js @@ -19,6 +19,14 @@ 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'; @@ -55,25 +63,52 @@ 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) { // // 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 @@ -102,6 +137,7 @@ class AnnotationCreation extends Component { activeTool: 'cursor', closedMode: 'closed', currentColorType: false, + colorPopoverOpen: false, fillColor: null, strokeColor: '#00BFFF', strokeWidth: 3, @@ -115,19 +151,45 @@ class AnnotationCreation extends Component { this.state = { ...toolState, ...timeState, - annoBody: '', - colorPopoverOpen: false, + textBody: '', + activeTool: 'cursor', + closedMode: 'closed', + currentColorType: false, + fillColor: null, lineWeightPopoverOpen: false, + openAddImgDialog: false, popoverAnchorEl: null, popoverLineWeightAnchorEl: null, svg: null, textEditorStateBustingKey: 0, + imgConstrain: false, + imgHeight: { + lastSubmittedValue: '', + srcValue: '', + validity: 0, + value: '', + }, + imgUrl: { + lastSubmittedValue: '', + validity: 0, + value: '', + }, + imgWidth: { + lastSubmittedValue: '', + srcValue: '', + validity: 0, + value: '', + }, 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.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); @@ -143,6 +205,79 @@ 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); + } + + /** */ + 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, + }); } /** */ @@ -202,6 +337,106 @@ class AnnotationCreation extends Component { } } + /** */ + 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, + }, + }); + } + + /** */ + 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({ @@ -243,20 +478,38 @@ class AnnotationCreation extends Component { annotation, canvases, receiveAnnotation, config, } = this.props; const { - annoBody, tags, xywh, svg, tstart, tend, textEditorStateBustingKey, + textBody, image, imgWidth, imgHeight, imgUrl, tags, xywh, svg, + imgConstrain, 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; + } + canvases.forEach((canvas) => { const storageAdapter = config.annotation.adapter(canvas.id); + const anno = new WebAnnotation({ - body: (!annoBody.length && t) ? `${secondsToHMS(tstart)} -> ${secondsToHMS(tend)}` : annoBody, + body: annoBody, canvasId: canvas.id, fragsel: { t, xywh }, id: (annotation && annotation.id) || `${uuid()}`, + image: imgBody, manifestId: canvas.options.resource.id, svg, tags, }).toJson(); + if (annotation) { storageAdapter.update(anno).then((annoPage) => { receiveAnnotation(canvas.id, storageAdapter.annotationPageId, annoPage); @@ -269,9 +522,10 @@ class AnnotationCreation extends Component { }); this.setState({ - annoBody: '', + image: false, svg: null, tend: null, + textBody: '', textEditorStateBustingKey: textEditorStateBustingKey + 1, tstart: null, xywh: null, @@ -293,8 +547,8 @@ class AnnotationCreation extends Component { } /** */ - updateBody(annoBody) { - this.setState({ annoBody }); + updateTextBody(textBody) { + this.setState({ textBody }); } /** */ @@ -311,9 +565,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, openAddImgDialog, popoverAnchorEl, + strokeColor, popoverLineWeightAnchorEl, lineWeightPopoverOpen, strokeWidth, closedMode, + textBody, imgUrl, imgWidth, imgHeight, imgConstrain, svg, tstart, tend, + textEditorStateBustingKey, image, } = this.state; const mediaIsVideo = typeof VideosReferences.get(windowId) !== 'undefined'; @@ -326,6 +581,7 @@ class AnnotationCreation extends Component { > <AnnotationDrawing activeTool={activeTool} + annotation={annotation} fillColor={fillColor} strokeColor={strokeColor} strokeWidth={strokeWidth} @@ -440,7 +696,6 @@ class AnnotationCreation extends Component { ) : null } - </Grid> </Grid> <Grid container> @@ -464,7 +719,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 +736,93 @@ class AnnotationCreation extends Component { )} <Grid item xs={12}> <Typography variant="overline"> - Content + Image Content + </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="no trad" /> } + </ToggleButton> + </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 </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/WebAnnotation.js b/src/WebAnnotation.js index 78cd79f5068284f3a03e1d984080bd94d7885231..f43d1f807cd5a13197e91a54e16be1b056a1b551 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, image, fragsel, 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); } + + // TODO WebAnnot format + if (this.image) { + const imgBody = { type: 'Image' }; + Object.assign(imgBody, this.image); + imgBody.id = imgBody.url; + bodies.push(imgBody); + } + if (this.tags) { bodies = bodies.concat(this.tags.map((tag) => ({ purpose: 'tagging',