Skip to content
Snippets Groups Projects
Commit e79e44b3 authored by David Beniamine's avatar David Beniamine
Browse files

Merge branch '1-edit-annotations-on-videos' into 'tetras-main'

Add / edit annotation on video

Closes #1

See merge request iiif/mirador-annotations!4
parents 2681b842 ef26ae0c
No related branches found
No related tags found
1 merge request!4Add / edit annotation on video
Pipeline #1264 failed
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
"page": true, "page": true,
"document": true "document": true
}, },
"parser": "babel-eslint", "parser": "@babel/eslint-parser",
"plugins": ["jest"], "plugins": ["jest"],
"rules": { "rules": {
"import/prefer-default-export": "off", "import/prefer-default-export": "off",
......
...@@ -5,10 +5,10 @@ function createSubject(args = {}) { ...@@ -5,10 +5,10 @@ function createSubject(args = {}) {
return new WebAnnotation({ return new WebAnnotation({
body: 'body', body: 'body',
canvasId: 'canvasId', canvasId: 'canvasId',
fragsel: { t: '5,10', xywh: 'xywh' },
id: 'id', id: 'id',
svg: 'svg', svg: 'svg',
tags: ['tags'], tags: ['tags'],
xywh: 'xywh',
...args, ...args,
}); });
} }
...@@ -17,29 +17,30 @@ describe('WebAnnotation', () => { ...@@ -17,29 +17,30 @@ describe('WebAnnotation', () => {
let subject = createSubject(); let subject = createSubject();
describe('constructor', () => { describe('constructor', () => {
it('sets instance accessors', () => { it('sets instance accessors', () => {
['body', 'canvasId', 'id', 'svg', 'xywh'].forEach((prop) => { ['body', 'canvasId', 'id', 'svg'].forEach((prop) => {
expect(subject[prop]).toBe(prop); expect(subject[prop]).toBe(prop);
}); });
expect(subject.fragsel).toStrictEqual({ t: '5,10', xywh: 'xywh' });
}); });
}); });
describe('target', () => { describe('target', () => {
it('with svg and xywh', () => { it('with svg and xywh', () => {
expect(subject.target()).toEqual({ expect(subject.target()).toEqual({
selector: [ selector: [
{
type: 'FragmentSelector',
value: 'xywh=xywh',
},
{ {
type: 'SvgSelector', type: 'SvgSelector',
value: 'svg', value: 'svg',
}, },
{
type: 'FragmentSelector',
value: 't=5,10&xywh=xywh',
},
], ],
source: 'canvasId', source: 'canvasId',
}); });
}); });
it('with svg only', () => { it('with svg only', () => {
subject = createSubject({ xywh: null }); subject = createSubject({ fragsel: null });
expect(subject.target()).toEqual({ expect(subject.target()).toEqual({
selector: { selector: {
type: 'SvgSelector', type: 'SvgSelector',
...@@ -48,8 +49,38 @@ describe('WebAnnotation', () => { ...@@ -48,8 +49,38 @@ describe('WebAnnotation', () => {
source: 'canvasId', source: 'canvasId',
}); });
}); });
it('with time interval only', () => {
subject = createSubject({ fragsel: { t: '5,10' }, svg: null });
expect(subject.target()).toEqual({
selector: {
type: 'FragmentSelector',
value: 't=5,10',
},
source: 'canvasId',
});
});
it('with time interval only - xywh present but null', () => {
subject = createSubject({ fragsel: { t: '5,10', xywh: null }, svg: null });
expect(subject.target()).toEqual({
selector: {
type: 'FragmentSelector',
value: 't=5,10',
},
source: 'canvasId',
});
});
it('with xywh only', () => { it('with xywh only', () => {
subject = createSubject({ svg: null }); subject = createSubject({ fragsel: { xywh: 'xywh' }, svg: null });
expect(subject.target()).toEqual({
selector: {
type: 'FragmentSelector',
value: 'xywh=xywh',
},
source: 'canvasId',
});
});
it('with xywh only - time interval present but null', () => {
subject = createSubject({ fragsel: { t: null, xywh: 'xywh' }, svg: null });
expect(subject.target()).toEqual({ expect(subject.target()).toEqual({
selector: { selector: {
type: 'FragmentSelector', type: 'FragmentSelector',
...@@ -59,7 +90,7 @@ describe('WebAnnotation', () => { ...@@ -59,7 +90,7 @@ describe('WebAnnotation', () => {
}); });
}); });
it('with no xywh or svg', () => { it('with no xywh or svg', () => {
subject = createSubject({ svg: null, xywh: null }); subject = createSubject({ fragsel: null, svg: null });
expect(subject.target()).toBe('canvasId'); expect(subject.target()).toBe('canvasId');
}); });
}); });
......
import mirador from 'mirador/dist/es/src/index'; import mirador from 'mirador/dist/es/src/index';
import annotationPlugins from '../../src'; import annotationPlugins from '../../src';
import LocalStorageAdapter from '../../src/LocalStorageAdapter'; import LocalStorageAdapter from '../../src/LocalStorageAdapter';
...@@ -16,9 +15,16 @@ const config = { ...@@ -16,9 +15,16 @@ const config = {
defaultSideBarPanel: 'annotations', defaultSideBarPanel: 'annotations',
sideBarOpenByDefault: true, sideBarOpenByDefault: true,
}, },
windows: [{ catalog: [
loadedManifest: 'https://iiif.harvardartmuseums.org/manifests/object/299843', { manifestId: 'https://dzkimgs.l.u-tokyo.ac.jp/videos/iiif_in_japan_2017/manifest.json' },
}], { manifestId: 'https://iiif.io/api/cookbook/recipe/0219-using-caption-file/manifest.json' },
{ manifestId: 'https://preview.iiif.io/cookbook/master/recipe/0003-mvm-video/manifest.json' },
{ manifestId: 'https://iiif.io/api/cookbook/recipe/0065-opera-multiple-canvases/manifest.json' },
{ manifestId: 'https://iiif.io/api/cookbook/recipe/0064-opera-one-canvas/manifest.json' },
{ manifestId: 'https://iiif.io/api/cookbook/recipe/0074-multiple-language-captions/manifest.json' },
{ manifestId: 'https://iiif.harvardartmuseums.org/manifests/object/299843' },
{ manifestId: 'https://iiif.io/api/cookbook/recipe/0002-mvm-audio/manifest.json' },
]
}; };
mirador.viewer(config, [...annotationPlugins]); mirador.viewer(config, [...annotationPlugins]);
This diff is collapsed.
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
"umd" "umd"
], ],
"scripts": { "scripts": {
"build": "nwb build-react-component --no-demo", "build": "nwb build-react-component",
"clean": "nwb clean-module", "clean": "nwb clean-module",
"lint": "eslint ./src ./__tests__", "lint": "eslint ./src ./__tests__",
"prepublishOnly": "npm run build", "prepublishOnly": "npm run build",
...@@ -29,7 +29,8 @@ ...@@ -29,7 +29,8 @@
"draft-js-import-html": "^1.4.1", "draft-js-import-html": "^1.4.1",
"material-ui-color-components": "^0.3.0", "material-ui-color-components": "^0.3.0",
"paper": "^0.12.11", "paper": "^0.12.11",
"react-color": "^2.18.1" "react-color": "^2.18.1",
"react-resize-observer": "^1.1.1"
}, },
"peerDependencies": { "peerDependencies": {
"@material-ui/core": "^4.9.13", "@material-ui/core": "^4.9.13",
...@@ -44,21 +45,22 @@ ...@@ -44,21 +45,22 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.10.4", "@babel/core": "^7.10.4",
"@babel/eslint-parser": "^7.19.1",
"@babel/preset-env": "^7.10.4", "@babel/preset-env": "^7.10.4",
"@babel/preset-react": "^7.10.4", "@babel/preset-react": "^7.10.4",
"@material-ui/core": "^4.11.0", "@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1", "@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.56", "@material-ui/lab": "^4.0.0-alpha.56",
"babel-eslint": "^10.1.0",
"canvas": "^2.6.1", "canvas": "^2.6.1",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2", "enzyme-adapter-react-16": "^1.15.2",
"eslint": "^7.2", "eslint": "^8.11.0",
"eslint-config-airbnb": "^18.2.0", "eslint-config-airbnb": "^19.0.4",
"eslint-plugin-flowtype": "^5.6.0", "eslint-config-react-app": "^7.0.0",
"eslint-plugin-import": "^2.22.0", "eslint-plugin-flowtype": "^8.0.3",
"eslint-plugin-jest": "^23.18.0", "eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsx-a11y": "^6.3.1", "eslint-plugin-jest": "^26.1.1",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.20.3", "eslint-plugin-react": "^7.20.3",
"jest": "^26.1.0", "jest": "^26.1.0",
"jest-canvas-mock": "^2.2.0", "jest-canvas-mock": "^2.2.0",
......
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Button from '@material-ui/core/Button'; import {
Button, Paper, Grid, Popover, Divider,
MenuList, MenuItem, ClickAwayListener,
} from '@material-ui/core';
import { Alarm, LastPage } from '@material-ui/icons';
import Typography from '@material-ui/core/Typography'; import Typography from '@material-ui/core/Typography';
import Paper from '@material-ui/core/Paper';
import Grid from '@material-ui/core/Grid';
import ToggleButton from '@material-ui/lab/ToggleButton'; import ToggleButton from '@material-ui/lab/ToggleButton';
import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'; import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup';
import RectangleIcon from '@material-ui/icons/CheckBoxOutlineBlank'; import RectangleIcon from '@material-ui/icons/CheckBoxOutlineBlank';
...@@ -17,39 +19,38 @@ import StrokeColorIcon from '@material-ui/icons/BorderColor'; ...@@ -17,39 +19,38 @@ import StrokeColorIcon from '@material-ui/icons/BorderColor';
import LineWeightIcon from '@material-ui/icons/LineWeight'; import LineWeightIcon from '@material-ui/icons/LineWeight';
import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'; import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
import FormatShapesIcon from '@material-ui/icons/FormatShapes'; import FormatShapesIcon from '@material-ui/icons/FormatShapes';
import Popover from '@material-ui/core/Popover';
import Divider from '@material-ui/core/Divider';
import MenuItem from '@material-ui/core/MenuItem';
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
import MenuList from '@material-ui/core/MenuList';
import { SketchPicker } from 'react-color'; import { SketchPicker } from 'react-color';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { withStyles } from '@material-ui/core/styles'; import { withStyles } from '@material-ui/core/styles';
import CompanionWindow from 'mirador/dist/es/src/containers/CompanionWindow'; import CompanionWindow from 'mirador/dist/es/src/containers/CompanionWindow';
import { VideosReferences } from 'mirador/dist/es/src/plugins/VideosReferences';
import { OSDReferences } from 'mirador/dist/es/src/plugins/OSDReferences';
import AnnotationDrawing from './AnnotationDrawing'; import AnnotationDrawing from './AnnotationDrawing';
import TextEditor from './TextEditor'; import TextEditor from './TextEditor';
import WebAnnotation from './WebAnnotation'; import WebAnnotation from './WebAnnotation';
import CursorIcon from './icons/Cursor'; import CursorIcon from './icons/Cursor';
import HMSInput from './HMSInput';
import { secondsToHMS } from './utils';
/** Extract time information from annotation target */ /** Extract time information from annotation target */
function timeFromAnnoTarget(annotarget) { function timeFromAnnoTarget(annotarget) {
console.info('TODO proper time extraction from: ', annotarget);
// TODO w3c media fragments: t=,10 t=5, // TODO w3c media fragments: t=,10 t=5,
const r = /t=([0-9]+),([0-9]+)/.exec(annotarget); const r = /t=([0-9.]+),([0-9.]+)/.exec(annotarget);
if (!r || r.length !== 3) { if (!r || r.length !== 3) {
return ['', '']; return [0, 0];
} }
return [r[1], r[2]]; return [Number(r[1]), Number(r[2])];
} }
/** Extract xywh from annotation target */ /** Extract xywh from annotation target */
function geomFromAnnoTarget(annotarget) { function geomFromAnnoTarget(annotarget) {
console.warn('TODO proper extraction'); console.info('TODO proper xywh extraction from: ', annotarget);
const r = /xywh=((-?[0-9]+,?)+)/.exec(annotarget); const r = /xywh=((-?[0-9]+,?)+)/.exec(annotarget);
console.info('extracted from ', annotarget, r);
if (!r || r.length !== 3) { if (!r || r.length !== 3) {
return ['', '']; return '';
} }
return [r[1], r[2]]; return r[1];
} }
/** */ /** */
...@@ -57,6 +58,7 @@ class AnnotationCreation extends Component { ...@@ -57,6 +58,7 @@ class AnnotationCreation extends Component {
/** */ /** */
constructor(props) { constructor(props) {
super(props); super(props);
const annoState = {}; const annoState = {};
if (props.annotation) { if (props.annotation) {
// //
...@@ -88,12 +90,12 @@ class AnnotationCreation extends Component { ...@@ -88,12 +90,12 @@ class AnnotationCreation extends Component {
}); });
} else { } else {
annoState.svg = props.annotation.target.selector.value; annoState.svg = props.annotation.target.selector.value;
// eslint-disable-next-line max-len // TODO does this happen ? when ? where are fragments selectors ?
[annoState.tstart, annoState.tend] = timeFromAnnoTarget(props.annotation.target.selector.value);
} }
} else if (typeof props.annotation.target === 'string') {
annoState.xywh = geomFromAnnoTarget(props.annotation.target);
[annoState.tstart, annoState.tend] = timeFromAnnoTarget(props.annotation.target);
} }
//
// start/end time
} }
const toolState = { const toolState = {
...@@ -106,16 +108,19 @@ class AnnotationCreation extends Component { ...@@ -106,16 +108,19 @@ class AnnotationCreation extends Component {
...(props.config.annotation.defaults || {}), ...(props.config.annotation.defaults || {}),
}; };
const timeState = props.currentTime !== null
? { tend: Math.floor(props.currentTime) + 10, tstart: Math.floor(props.currentTime) }
: { tend: null, tstart: null };
this.state = { this.state = {
...toolState, ...toolState,
...timeState,
annoBody: '', annoBody: '',
colorPopoverOpen: false, colorPopoverOpen: false,
lineWeightPopoverOpen: false, lineWeightPopoverOpen: false,
popoverAnchorEl: null, popoverAnchorEl: null,
popoverLineWeightAnchorEl: null, popoverLineWeightAnchorEl: null,
svg: null, svg: null,
tend: '',
tstart: '',
textEditorStateBustingKey: 0, textEditorStateBustingKey: 0,
xywh: null, xywh: null,
...annoState, ...annoState,
...@@ -125,6 +130,10 @@ class AnnotationCreation extends Component { ...@@ -125,6 +130,10 @@ class AnnotationCreation extends Component {
this.updateBody = this.updateBody.bind(this); this.updateBody = this.updateBody.bind(this);
this.updateTstart = this.updateTstart.bind(this); this.updateTstart = this.updateTstart.bind(this);
this.updateTend = this.updateTend.bind(this); this.updateTend = this.updateTend.bind(this);
this.setTstartNow = this.setTstartNow.bind(this);
this.setTendNow = this.setTendNow.bind(this);
this.seekToTstart = this.seekToTstart.bind(this);
this.seekToTend = this.seekToTend.bind(this);
this.updateGeometry = this.updateGeometry.bind(this); this.updateGeometry = this.updateGeometry.bind(this);
this.changeTool = this.changeTool.bind(this); this.changeTool = this.changeTool.bind(this);
this.changeClosedMode = this.changeClosedMode.bind(this); this.changeClosedMode = this.changeClosedMode.bind(this);
...@@ -153,6 +162,46 @@ class AnnotationCreation extends Component { ...@@ -153,6 +162,46 @@ class AnnotationCreation extends Component {
}); });
} }
/** set annotation start time to current time */
setTstartNow() {
// eslint-disable-next-line react/destructuring-assignment
this.setState({ tstart: Math.floor(this.props.currentTime) });
}
/** set annotation end time to current time */
setTendNow() {
// eslint-disable-next-line react/destructuring-assignment
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;
const { tstart } = this.state;
if (!paused) {
this.setState(setSeekTo(tstart));
} else {
this.setState(setCurrentTime(tstart));
}
}
/** seekTo/goto annotation end time */
seekToTend() {
const { paused, setCurrentTime, setSeekTo } = this.props;
const { tend } = this.state;
if (!paused) {
this.setState(setSeekTo(tend));
} else {
this.setState(setCurrentTime(tend));
}
}
/** */ /** */
openChooseColor(e) { openChooseColor(e) {
this.setState({ this.setState({
...@@ -196,20 +245,17 @@ class AnnotationCreation extends Component { ...@@ -196,20 +245,17 @@ class AnnotationCreation extends Component {
const { const {
annoBody, tags, xywh, svg, tstart, tend, textEditorStateBustingKey, annoBody, tags, xywh, svg, tstart, tend, textEditorStateBustingKey,
} = this.state; } = this.state;
let fsel = xywh; const t = (tstart && tend) ? `${tstart},${tend}` : null;
if (tstart && tend) {
fsel = `${xywh || ''}&t=${tstart},${tend}`;
}
canvases.forEach((canvas) => { canvases.forEach((canvas) => {
const storageAdapter = config.annotation.adapter(canvas.id); const storageAdapter = config.annotation.adapter(canvas.id);
const anno = new WebAnnotation({ const anno = new WebAnnotation({
body: annoBody, body: (!annoBody.length && t) ? `${secondsToHMS(tstart)} -> ${secondsToHMS(tend)}` : annoBody,
canvasId: canvas.id, canvasId: canvas.id,
fragsel: { t, xywh },
id: (annotation && annotation.id) || `${uuid()}`, id: (annotation && annotation.id) || `${uuid()}`,
manifestId: canvas.options.resource.id, manifestId: canvas.options.resource.id,
svg, svg,
tags, tags,
xywh: fsel,
}).toJson(); }).toJson();
if (annotation) { if (annotation) {
storageAdapter.update(anno).then((annoPage) => { storageAdapter.update(anno).then((annoPage) => {
...@@ -225,7 +271,9 @@ class AnnotationCreation extends Component { ...@@ -225,7 +271,9 @@ class AnnotationCreation extends Component {
this.setState({ this.setState({
annoBody: '', annoBody: '',
svg: null, svg: null,
tend: null,
textEditorStateBustingKey: textEditorStateBustingKey + 1, textEditorStateBustingKey: textEditorStateBustingKey + 1,
tstart: null,
xywh: null, xywh: null,
}); });
} }
...@@ -249,12 +297,6 @@ class AnnotationCreation extends Component { ...@@ -249,12 +297,6 @@ class AnnotationCreation extends Component {
this.setState({ annoBody }); this.setState({ annoBody });
} }
/** 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 }) { updateGeometry({ svg, xywh }) {
this.setState({ this.setState({
...@@ -274,6 +316,8 @@ class AnnotationCreation extends Component { ...@@ -274,6 +316,8 @@ class AnnotationCreation extends Component {
tstart, tend, textEditorStateBustingKey, tstart, tend, textEditorStateBustingKey,
} = this.state; } = this.state;
const mediaIsVideo = typeof VideosReferences.get(windowId) !== 'undefined';
return ( return (
<CompanionWindow <CompanionWindow
title={annotation ? 'Edit annotation' : 'New annotation'} title={annotation ? 'Edit annotation' : 'New annotation'}
...@@ -289,6 +333,7 @@ class AnnotationCreation extends Component { ...@@ -289,6 +333,7 @@ class AnnotationCreation extends Component {
svg={svg} svg={svg}
updateGeometry={this.updateGeometry} updateGeometry={this.updateGeometry}
windowId={windowId} windowId={windowId}
player={mediaIsVideo ? VideosReferences.get(windowId) : OSDReferences.get(windowId)}
/> />
<form onSubmit={this.submitForm} className={classes.section}> <form onSubmit={this.submitForm} className={classes.section}>
<Grid container> <Grid container>
...@@ -399,15 +444,41 @@ class AnnotationCreation extends Component { ...@@ -399,15 +444,41 @@ class AnnotationCreation extends Component {
</Grid> </Grid>
</Grid> </Grid>
<Grid container> <Grid container>
{ mediaIsVideo && (
<>
<Grid item xs={12}> <Grid item xs={12}>
<ToggleButton value="true" title="Go to start time" size="small" onClick={this.seekToTstart} className={classes.timecontrolsbutton}>
<LastPage />
</ToggleButton>
<Typography variant="overline"> <Typography variant="overline">
Duration Start
</Typography> </Typography>
</Grid> </Grid>
<Grid item xs={12} className={classes.paper}>
<ToggleButton value="true" title="Set current time" size="small" onClick={this.setTstartNow} className={classes.timecontrolsbutton}>
<Alarm />
</ToggleButton>
<HMSInput seconds={tstart} onChange={this.updateTstart} />
</Grid>
<Grid item xs={12}> <Grid item xs={12}>
<input name="tstart" type="number" step="1" value={tstart} onChange={this.updateTstart} /> <Typography variant="overline">
<input name="tend" type="number" step="1" value={tend} onChange={this.updateTend} /> <ToggleButton value="true" title="Go to start time" size="small" onClick={this.seekToTend} className={classes.timecontrolsbutton}>
<LastPage />
</ToggleButton>
End
</Typography>
</Grid>
<Grid item xs={12} className={classes.paper}>
<ToggleButton value="true" title="Set current time" size="small" onClick={this.setTendNow} className={classes.timecontrolsbutton}>
<Alarm />
</ToggleButton>
<HMSInput seconds={tend} onChange={this.updateTend} />
</Grid> </Grid>
</>
)}
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="overline"> <Typography variant="overline">
Content Content
...@@ -492,6 +563,13 @@ const styles = (theme) => ({ ...@@ -492,6 +563,13 @@ const styles = (theme) => ({
paddingRight: theme.spacing(1), paddingRight: theme.spacing(1),
paddingTop: theme.spacing(2), paddingTop: theme.spacing(2),
}, },
timecontrolsbutton: {
height: '30px',
margin: 'auto',
marginLeft: '0',
marginRight: '5px',
width: '30px',
},
}); });
AnnotationCreation.propTypes = { AnnotationCreation.propTypes = {
...@@ -507,13 +585,17 @@ AnnotationCreation.propTypes = { ...@@ -507,13 +585,17 @@ AnnotationCreation.propTypes = {
adapter: PropTypes.func, adapter: PropTypes.func,
defaults: PropTypes.objectOf( defaults: PropTypes.objectOf(
PropTypes.oneOfType( PropTypes.oneOfType(
[PropTypes.bool, PropTypes.func, PropTypes.number, PropTypes.string] [PropTypes.bool, PropTypes.func, PropTypes.number, PropTypes.string],
) ),
), ),
}), }),
}).isRequired, }).isRequired,
currentTime: PropTypes.oneOfType([PropTypes.number, PropTypes.instanceOf(null)]),
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
paused: PropTypes.bool,
receiveAnnotation: PropTypes.func.isRequired, receiveAnnotation: PropTypes.func.isRequired,
setCurrentTime: PropTypes.func,
setSeekTo: PropTypes.func,
windowId: PropTypes.string.isRequired, windowId: PropTypes.string.isRequired,
}; };
...@@ -521,6 +603,10 @@ AnnotationCreation.defaultProps = { ...@@ -521,6 +603,10 @@ AnnotationCreation.defaultProps = {
annotation: null, annotation: null,
canvases: [], canvases: [],
closeCompanionWindow: () => {}, closeCompanionWindow: () => {},
currentTime: null,
paused: true,
setCurrentTime: () => {},
setSeekTo: () => {},
}; };
export default withStyles(styles)(AnnotationCreation); export default withStyles(styles)(AnnotationCreation);
import React, { Component } from 'react'; import React, { Component } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ResizeObserver from 'react-resize-observer';
import { OSDReferences } from 'mirador/dist/es/src/plugins/OSDReferences'; import { OSDReferences } from 'mirador/dist/es/src/plugins/OSDReferences';
import { VideoViewersReferences } from 'mirador/dist/es/src/plugins/VideoViewersReferences'; import { VideosReferences } from 'mirador/dist/es/src/plugins/VideosReferences';
import { renderWithPaperScope, PaperContainer } from '@psychobolt/react-paperjs'; import { renderWithPaperScope, PaperContainer } from '@psychobolt/react-paperjs';
import import
{ {
...@@ -17,27 +18,87 @@ import flatten from 'lodash/flatten'; ...@@ -17,27 +18,87 @@ import flatten from 'lodash/flatten';
import EditTool from './EditTool'; import EditTool from './EditTool';
import { mapChildren } from './utils'; import { mapChildren } from './utils';
/** Use a canvas "like a OSD viewport" (temporary) */ /** Create a portal with a drawing canvas and a form to fill annotations details */
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 { class AnnotationDrawing extends Component {
/** */ /** */
constructor(props) { constructor(props) {
super(props); super(props);
this.paper = null;
this.getDisplayProps = this.getDisplayProps.bind(this);
this.onPaperResize = this.onPaperResize.bind(this);
this.paperDidMount = this.paperDidMount.bind(this);
this.addPath = this.addPath.bind(this); this.addPath = this.addPath.bind(this);
} }
/** */ /** Sync drawing canvas on componentDidMount */
componentDidMount() {
this.onPaperResize();
}
/** Sync drawing canvas on componentDidUpdate */
componentDidUpdate() {
this.onPaperResize();
}
/** Sync drawing canvas size/zoom with annotations canvas */
onPaperResize(ev) {
const { windowId } = this.props;
if (VideosReferences.get(windowId) && this.paper) {
const { canvasOverlay, video } = VideosReferences.get(windowId);
const { height, width } = canvasOverlay.ref.current;
const { videoHeight, videoWidth } = video;
this.paper.view.center = new Point(videoWidth / 2, videoHeight / 2);
this.paper.view.zoom = canvasOverlay.scale;
this.paper.view.viewSize = new this.paper.Size(width, height);
}
}
/** Build parameters to paperjs View and canvas */
getDisplayProps() {
const { windowId } = this.props;
const osdref = OSDReferences.get(windowId);
const videoref = VideosReferences.get(windowId);
if (osdref) {
const { viewport } = osdref.current;
const img = osdref.current.world.getItemAt(0);
const center = img.viewportToImageCoordinates(viewport.getCenter(true));
return {
canvasProps: { style: { height: '100%', width: '100%' } },
viewProps: {
center: new Point(center.x, center.y),
rotation: viewport.getRotation(),
scaling: new Point(viewport.getFlip() ? -1 : 1, 1),
zoom: img.viewportToImageZoom(viewport.getZoom()),
},
};
}
if (videoref) {
const { height, width } = videoref.canvasOverlay.ref.current;
return {
canvasProps: {
height,
resize: 'true',
style: {
left: 0, position: 'absolute', top: 0,
},
width,
},
viewProps: {
center: new Point(width / 2, height / 2),
height,
width,
zoom: videoref.canvasOverlay.scale,
},
};
}
throw new Error('Unknown or missing data player, not OpenSeadragon (image viewer) nor the video player');
}
/** Draw SVG on canvas */
addPath(path) { addPath(path) {
const { closed, strokeWidth, updateGeometry } = this.props; const { closed, strokeWidth, updateGeometry } = this.props;
// TODO: Compute xywh of bounding container of layers // TODO: Compute xywh of bounding container of layers
...@@ -65,36 +126,18 @@ class AnnotationDrawing extends Component { ...@@ -65,36 +126,18 @@ class AnnotationDrawing extends Component {
}); });
} }
/** Save paperjs ref once created */
paperDidMount(paper) {
this.paper = paper;
}
/** */ /** */
paperThing() { paperThing() {
const { windowId } = this.props; const { viewProps, canvasProps } = this.getDisplayProps();
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 { const {
activeTool, fillColor, strokeColor, strokeWidth, svg, activeTool, fillColor, strokeColor, strokeWidth, svg,
} = this.props; } = this.props;
if (!activeTool || activeTool === 'cursor') return null; if (!activeTool || activeTool === 'cursor') return null;
// 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: viewport.getRotation(),
scaling: new Point(flipped ? -1 : 1, 1),
zoom: img ? img.viewportToImageZoom(viewport.getZoom()) : viewport.getZoom(),
};
let ActiveTool = RectangleTool; let ActiveTool = RectangleTool;
switch (activeTool) { switch (activeTool) {
case 'rectangle': case 'rectangle':
...@@ -118,14 +161,14 @@ class AnnotationDrawing extends Component { ...@@ -118,14 +161,14 @@ class AnnotationDrawing extends Component {
return ( return (
<div <div
className="foo"
style={{ style={{
height: '100%', left: 0, position: 'absolute', top: 0, width: '100%', height: '100%', left: 0, position: 'absolute', top: 0, width: '100%',
}} }}
> >
<PaperContainer <PaperContainer
canvasProps={{ style: { height: '100%', width: '100%' } }} canvasProps={canvasProps}
viewProps={viewProps} viewProps={viewProps}
onMount={this.paperDidMount}
> >
{renderWithPaperScope((paper) => { {renderWithPaperScope((paper) => {
const paths = flatten(paper.project.layers.map((layer) => ( const paths = flatten(paper.project.layers.map((layer) => (
...@@ -149,6 +192,7 @@ class AnnotationDrawing extends Component { ...@@ -149,6 +192,7 @@ class AnnotationDrawing extends Component {
); );
})} })}
</PaperContainer> </PaperContainer>
<ResizeObserver onResize={this.onPaperResize} />
</div> </div>
); );
} }
...@@ -156,10 +200,17 @@ class AnnotationDrawing extends Component { ...@@ -156,10 +200,17 @@ class AnnotationDrawing extends Component {
/** */ /** */
render() { render() {
const { windowId } = this.props; const { windowId } = this.props;
const container = OSDReferences.get(windowId) const osdref = OSDReferences.get(windowId);
? OSDReferences.get(windowId).current.element const videoref = VideosReferences.get(windowId);
: VideoViewersReferences.get(windowId).apiRef.current; if (!osdref && !videoref) {
throw new Error("Unknown or missing data player, didn't found OpenSeadragon (image viewer) nor the video player");
}
if (osdref && videoref) {
throw new Error('Unhandled case: both OpenSeadragon (image viewer) and video player on the same canvas');
}
const container = osdref
? osdref.current.element
: videoref.ref.current.parentElement;
return ( return (
ReactDOM.createPortal(this.paperThing(), container) ReactDOM.createPortal(this.paperThing(), container)
); );
......
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import { IconButton, Input } from '@material-ui/core';
import { ArrowDownward, ArrowUpward } from '@material-ui/icons';
import { secondsToHMSarray } from './utils';
/** hh:mm:ss input which behave like a single input for parent */
class HMSInput extends Component {
/** Initialize state structure & bindings */
constructor(props) {
super(props);
// eslint-disable-next-line react/destructuring-assignment
const [h, m, s] = secondsToHMSarray(this.props.seconds);
this.state = {
hours: h,
minutes: m,
seconds: s,
};
this.someChange = this.someChange.bind(this);
this.addOneSec = this.addOneSec.bind(this);
this.subOneSec = this.subOneSec.bind(this);
}
/** update */
componentDidUpdate(prevProps) {
const { seconds } = this.props;
if (prevProps.seconds === seconds) return;
const [h, m, s] = secondsToHMSarray(seconds);
this.setState({
hours: h,
minutes: m,
seconds: s,
});
}
/** If one value is updated, tell the parent component the total seconds counts */
someChange(ev) {
const { onChange } = this.props;
const { state } = this;
state[ev.target.name] = Number(ev.target.value);
onChange(state.hours * 3600 + state.minutes * 60 + state.seconds);
}
/** Add one second by simulating an input change */
addOneSec() {
const { seconds } = this.state;
this.someChange({ target: { name: 'seconds', value: seconds + 1 } });
}
/** Substract one second by simulating an input change */
subOneSec() {
const { seconds } = this.state;
this.someChange({ target: { name: 'seconds', value: seconds - 1 } });
}
/** Render */
render() {
const { hours, minutes, seconds } = this.state;
const { classes } = this.props;
return (
<div className={classes.root}>
<div className={classes.root}>
<Input className={classes.input} name="hours" value={hours} onChange={this.someChange} />
<Input className={classes.input} name="minutes" value={minutes} onChange={this.someChange} />
<Input className={classes.input} name="seconds" value={seconds} onChange={this.someChange} />
</div>
<div className={classes.flexcol}>
<IconButton size="small" onClick={this.addOneSec}>
<ArrowUpward />
</IconButton>
<IconButton size="small" onClick={this.subOneSec}>
<ArrowDownward />
</IconButton>
</div>
</div>
);
}
}
/** */
const styles = (theme) => ({
root: {
alignItems: 'center',
display: 'flex',
justifyContent: 'end',
},
// eslint-disable-next-line sort-keys
flexcol: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
},
// eslint-disable-next-line sort-keys
input: {
height: 'fit-content',
margin: '2px',
textAlign: 'center',
width: '4ch',
},
});
HMSInput.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
classes: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
seconds: PropTypes.number.isRequired,
};
HMSInput.defaultProps = {
};
export default withStyles(styles)(HMSInput);
...@@ -2,11 +2,11 @@ ...@@ -2,11 +2,11 @@
export default class WebAnnotation { export default class WebAnnotation {
/** */ /** */
constructor({ constructor({
canvasId, id, xywh, body, tags, svg, manifestId, canvasId, id, fragsel, body, tags, svg, manifestId,
}) { }) {
this.id = id; this.id = id;
this.canvasId = canvasId; this.canvasId = canvasId;
this.xywh = xywh; this.fragsel = fragsel;
this.body = body; this.body = body;
this.tags = tags; this.tags = tags;
this.svg = svg; this.svg = svg;
...@@ -46,35 +46,30 @@ export default class WebAnnotation { ...@@ -46,35 +46,30 @@ export default class WebAnnotation {
return bodies; return bodies;
} }
/** */ /** Fill target object with selectors (if any), else returns target url */
target() { target() {
let target = this.canvasId; if (!this.svg
if (this.svg || this.xywh) { && (!this.fragsel || !Object.values(this.fragsel).find((e) => e !== null))) {
target = { return this.canvasId;
source: this.source(),
};
} }
const target = { source: this.source() };
const selectors = [];
if (this.svg) { if (this.svg) {
target.selector = { selectors.push({
type: 'SvgSelector', type: 'SvgSelector',
value: this.svg, value: this.svg,
}; });
} }
if (this.xywh) { if (this.fragsel) {
const fragsel = { selectors.push({
type: 'FragmentSelector', type: 'FragmentSelector',
value: `xywh=${this.xywh}`, value: Object.entries(this.fragsel)
}; .filter((kv) => kv[1])
if (target.selector) { .map((kv) => `${kv[0]}=${kv[1]}`)
// add fragment selector .join('&'),
target.selector = [ });
fragsel,
target.selector,
];
} else {
target.selector = fragsel;
}
} }
target.selector = selectors.length === 1 ? selectors[0] : selectors;
return target; return target;
} }
......
import * as actions from 'mirador/dist/es/src/state/actions'; import * as actions from 'mirador/dist/es/src/state/actions';
import { getCompanionWindow } from 'mirador/dist/es/src/state/selectors/companionWindows'; import { getCompanionWindow } from 'mirador/dist/es/src/state/selectors/companionWindows';
import { getWindowCurrentTime, getWindowPausedStatus } from 'mirador/dist/es/src/state/selectors/window';
import { getVisibleCanvases } from 'mirador/dist/es/src/state/selectors/canvases'; import { getVisibleCanvases } from 'mirador/dist/es/src/state/selectors/canvases';
import { getPresentAnnotationsOnSelectedCanvases } from 'mirador/dist/es/src/state/selectors/annotations';
import AnnotationCreation from '../AnnotationCreation'; import AnnotationCreation from '../AnnotationCreation';
/** */ /** */
...@@ -11,27 +13,27 @@ const mapDispatchToProps = (dispatch, { id, windowId }) => ({ ...@@ -11,27 +13,27 @@ const mapDispatchToProps = (dispatch, { id, windowId }) => ({
receiveAnnotation: (targetId, annoId, annotation) => dispatch( receiveAnnotation: (targetId, annoId, annotation) => dispatch(
actions.receiveAnnotation(targetId, annoId, annotation), actions.receiveAnnotation(targetId, annoId, annotation),
), ),
setCurrentTime: (...args) => dispatch(actions.setWindowCurrentTime(windowId, ...args)),
setSeekTo: (...args) => dispatch(actions.setWindowSeekTo(windowId, ...args)),
}); });
/** */ /** */
function mapStateToProps(state, { id: companionWindowId, windowId }) { function mapStateToProps(state, { id: companionWindowId, windowId }) {
const { annotationid } = getCompanionWindow(state, { companionWindowId, windowId }); const currentTime = getWindowCurrentTime(state, { windowId });
const cw = getCompanionWindow(state, { companionWindowId, windowId });
const { annotationid } = cw;
const canvases = getVisibleCanvases(state, { windowId }); const canvases = getVisibleCanvases(state, { windowId });
let annotation; const annotation = getPresentAnnotationsOnSelectedCanvases(state, { windowId })
canvases.forEach((canvas) => { .flatMap((annoPage) => annoPage.json.items || [])
const annotationsOnCanvas = state.annotations[canvas.id]; .find((annot) => annot.id === annotationid);
Object.values(annotationsOnCanvas || {}).forEach((value, i) => {
if (value.json && value.json.items) {
annotation = value.json.items.find((anno) => anno.id === annotationid);
}
});
});
return { return {
annotation, annotation,
canvases, canvases,
config: state.config, config: state.config,
currentTime,
paused: getWindowPausedStatus(state, { windowId }),
}; };
} }
......
...@@ -7,3 +7,17 @@ export function mapChildren(layerThing) { ...@@ -7,3 +7,17 @@ export function mapChildren(layerThing) {
} }
return layerThing; return layerThing;
} }
/** Pretty print a seconds count into HH:mm:ss */
export function secondsToHMS(secs) {
const [h, m, s] = secondsToHMSarray(secs);
// eslint-disable-next-line require-jsdoc
const pad = (n) => (n < 10 ? `0${n}` : n);
return `${pad(h)}:${pad(m)}:${pad(s)}`;
}
/** Split a second to [hours, minutes, seconds] */
export function secondsToHMSarray(secs) {
const h = Math.floor(secs / 3600);
return [h, Math.floor(secs / 60) - h * 60, secs % 60];
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment