Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 5-images-in-annotations
  • 61-recettage-des-outils-d-annotation
  • 69-la-video-demare-quand-on-fait-glisser-le-slider-et-le-clic-creer-un-decalage-entre-le-player
  • 75-dernieres-ameliorations-avant-workshop-du-7-02
  • autorisation_un_pannel_annotation
  • autorisation_un_pannel_edition_annotation
  • fix-error-create-annotation-pannel
  • fix-poc-mirador
  • gestion_multiple_ouverture_pannel_annotation
  • master
  • mui5-tetras-main-old-stable
  • mui5-tetras-main-stable
  • preprod
  • récupération_temps_video
  • save-shapes-and-position
  • tetras-antho-test
  • tetras-main
  • time-saving-on-annotation
  • uploads-file
  • wip-annot-video-ui
  • wip-fix-xywh
  • wip-positionement-annot
  • wip-surface-transformer
23 results

Target

Select target project
  • lpo/mirador-annotations
1 result
Select Git revision
  • 5-images-in-annotations
  • 61-recettage-des-outils-d-annotation
  • 69-la-video-demare-quand-on-fait-glisser-le-slider-et-le-clic-creer-un-decalage-entre-le-player
  • 75-dernieres-ameliorations-avant-workshop-du-7-02
  • autorisation_un_pannel_annotation
  • autorisation_un_pannel_edition_annotation
  • fix-error-create-annotation-pannel
  • fix-poc-mirador
  • gestion_multiple_ouverture_pannel_annotation
  • master
  • mui5-tetras-main-old-stable
  • mui5-tetras-main-stable
  • preprod
  • récupération_temps_video
  • save-shapes-and-position
  • tetras-antho-test
  • tetras-main
  • time-saving-on-annotation
  • uploads-file
  • wip-annot-video-ui
  • wip-fix-xywh
  • wip-positionement-annot
  • wip-surface-transformer
23 results
Show changes
Commits on Source (37)
......@@ -7,7 +7,7 @@
"page": true,
"document": true
},
"parser": "babel-eslint",
"parser": "@babel/eslint-parser",
"plugins": ["jest"],
"rules": {
"import/prefer-default-export": "off",
......@@ -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"
}
}
......@@ -5,4 +5,3 @@
/node_modules
/umd
npm-debug.log*
package-lock.json
upstream_tests:
image: docker.io/node:current
before_script:
- npm ci
script:
- npm run test:ci
artifacts:
when: always
paths:
- junit.xml
reports:
junit: junit.xml
......@@ -4,13 +4,14 @@ 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) {
return shallow(
<AnnotationCreation
id="x"
config={{}}
config={{ annotation: {} }}
receiveAnnotation={jest.fn()}
windowId="abc"
{...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: {
......
......@@ -3,12 +3,14 @@ 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',
svg: 'svg',
tags: ['tags'],
xywh: 'xywh',
...args,
});
}
......@@ -17,29 +19,35 @@ describe('WebAnnotation', () => {
let subject = createSubject();
describe('constructor', () => {
it('sets instance accessors', () => {
['body', 'canvasId', 'id', 'svg', 'xywh'].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', () => {
expect(subject.target()).toEqual({
selector: [
{
type: 'FragmentSelector',
value: 'xywh=xywh',
},
{
type: 'SvgSelector',
value: 'svg',
},
{
type: 'FragmentSelector',
value: 't=5,10&xywh=xywh',
},
],
source: 'canvasId',
});
});
it('with svg only', () => {
subject = createSubject({ xywh: null });
subject = createSubject({ fragsel: null });
expect(subject.target()).toEqual({
selector: {
type: 'SvgSelector',
......@@ -48,8 +56,38 @@ describe('WebAnnotation', () => {
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', () => {
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({
selector: {
type: 'FragmentSelector',
......@@ -59,7 +97,7 @@ describe('WebAnnotation', () => {
});
});
it('with no xywh or svg', () => {
subject = createSubject({ svg: null, xywh: null });
subject = createSubject({ fragsel: null, svg: null });
expect(subject.target()).toBe('canvasId');
});
});
......@@ -78,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', () => {
......
import mirador from 'mirador/dist/es/src/index';
import annotationPlugins from '../../src';
import LocalStorageAdapter from '../../src/LocalStorageAdapter';
......@@ -16,9 +15,16 @@ const config = {
defaultSideBarPanel: 'annotations',
sideBarOpenByDefault: true,
},
windows: [{
loadedManifest: 'https://iiif.harvardartmuseums.org/manifests/object/299843',
}],
catalog: [
{ 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]);
This diff is collapsed.
{
"name": "mirador-annotations",
"version": "0.4.0",
"version": "0.5.0",
"description": "mirador-annotations React component",
"main": "lib/index.js",
"module": "es/index.js",
......@@ -11,14 +11,15 @@
"umd"
],
"scripts": {
"build": "nwb build-react-component --no-demo",
"build": "nwb build-react-component",
"clean": "nwb clean-module",
"lint": "eslint ./src ./__tests__",
"prepublishOnly": "npm run build",
"start": "nwb serve-react-demo",
"test": "npm run lint && jest",
"test:coverage": "jest --coverage",
"test:watch": "jest --watch"
"test:watch": "jest --watch",
"test:ci": "jest --ci --reporters=default --reporters=jest-junit --watchAll=false"
},
"dependencies": {
"@psychobolt/react-paperjs": "< 1.0",
......@@ -28,46 +29,48 @@
"draft-js-import-html": "^1.4.1",
"material-ui-color-components": "^0.3.0",
"paper": "^0.12.11",
"react-color": "^2.18.1"
"react-color": "^2.18.1",
"react-resize-observer": "^1.1.1"
},
"peerDependencies": {
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.52",
"lodash": "^4.17.11",
"mirador": "^3.0.0-rc.5",
"mirador": "git+https://gitlab.tetras-libre.fr/iiif/mirador-video-annotation#annotation-on-video",
"prop-types": "^15.7.2",
"react": "^16.0",
"react-dom": "^16.0",
"react": "^16.8",
"react-dom": "^16.8",
"uuid": "^8.0.0"
},
"devDependencies": {
"@babel/core": "^7.10.4",
"@babel/eslint-parser": "^7.19.1",
"@babel/preset-env": "^7.10.4",
"@babel/preset-react": "^7.10.4",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.56",
"babel-eslint": "^10.1.0",
"canvas": "^2.6.1",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"eslint": "^6.8.0",
"eslint-config-airbnb": "^18.2.0",
"eslint-config-react-app": "^5.2.1",
"eslint-plugin-flowtype": "^4.7.0",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-jest": "^23.18.0",
"eslint-plugin-jsx-a11y": "^6.3.1",
"eslint": "^8.11.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-react-app": "^7.0.0",
"eslint-plugin-flowtype": "^8.0.3",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jest": "^26.1.1",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.20.3",
"jest": "^26.1.0",
"jest-canvas-mock": "^2.2.0",
"jest-junit": "^15.0.0",
"jest-localstorage-mock": "^2.4.2",
"mirador": "^3.0.0-rc.5",
"mirador": "git+https://gitlab.tetras-libre.fr/iiif/mirador-video-annotation#annotation-on-video",
"nwb": "^0.24.7",
"prop-types": "^15.7.2",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react": "^16.8",
"react-dom": "^16.8",
"uuid": "^8.2.0"
},
"author": "",
......
import React, { Component } from 'react';
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 Paper from '@material-ui/core/Paper';
import Grid from '@material-ui/core/Grid';
import ToggleButton from '@material-ui/lab/ToggleButton';
import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup';
import RectangleIcon from '@material-ui/icons/CheckBoxOutlineBlank';
......@@ -17,39 +19,40 @@ 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 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 TextField from '@material-ui/core/TextField';
import { SketchPicker } from 'react-color';
import { v4 as uuid } from 'uuid';
import { withStyles } from '@material-ui/core/styles';
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 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 */
function timeFromAnnoTarget(annotarget) {
console.info('TODO proper time extraction from: ', annotarget);
// 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) {
return ['', ''];
return [0, 0];
}
return [r[1], r[2]];
return [Number(r[1]), Number(r[2])];
}
/** Extract xywh from annotation target */
function geomFromAnnoTarget(annotarget) {
console.warn('TODO proper extraction');
console.info('TODO proper xywh extraction from: ', annotarget);
const r = /xywh=((-?[0-9]+,?)+)/.exec(annotarget);
console.info('extracted from ', annotarget, r);
if (!r || r.length !== 3) {
return ['', ''];
return '';
}
return [r[1], r[2]];
return r[1];
}
/** */
......@@ -57,21 +60,29 @@ class AnnotationCreation extends Component {
/** */
constructor(props) {
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
......@@ -88,36 +99,56 @@ class AnnotationCreation extends Component {
});
} else {
annoState.svg = props.annotation.target.selector.value;
// eslint-disable-next-line max-len
[annoState.tstart, annoState.tend] = timeFromAnnoTarget(props.annotation.target.selector.value);
// TODO does this happen ? when ? where are fragments selectors ?
}
} else if (typeof props.annotation.target === 'string') {
annoState.xywh = geomFromAnnoTarget(props.annotation.target);
[annoState.tstart, annoState.tend] = timeFromAnnoTarget(props.annotation.target);
}
//
// start/end time
}
this.state = {
const toolState = {
activeTool: 'cursor',
annoBody: '',
closedMode: 'closed',
colorPopoverOpen: false,
currentColorType: false,
fillColor: null,
strokeColor: '#00BFFF',
strokeWidth: 3,
...(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 = {
...toolState,
...timeState,
activeTool: 'cursor',
closedMode: 'closed',
currentColorType: false,
fillColor: null,
image: { id: null },
lineWeightPopoverOpen: false,
popoverAnchorEl: null,
popoverLineWeightAnchorEl: null,
strokeColor: '#00BFFF',
strokeWidth: 1,
svg: null,
tend: '',
tstart: '',
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);
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.changeTool = this.changeTool.bind(this);
this.changeClosedMode = this.changeClosedMode.bind(this);
......@@ -127,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 } });
}
/** */
......@@ -146,6 +184,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) });
}
/** 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));
}
}
/** update annotation start time */
updateTstart(value) { this.setState({ tstart: value }); }
/** update annotation end time */
updateTend(value) { this.setState({ tend: value }); }
/** */
openChooseColor(e) {
this.setState({
......@@ -184,26 +262,28 @@ class AnnotationCreation extends Component {
submitForm(e) {
e.preventDefault();
const {
annotation, canvases, closeCompanionWindow, receiveAnnotation, config,
annotation, canvases, receiveAnnotation, config,
} = this.props;
const {
annoBody, tags, xywh, svg, tstart, tend,
textBody, image, tags, xywh, svg, tstart, tend, textEditorStateBustingKey,
} = this.state;
let fsel = xywh;
if (tstart && tend) {
fsel = `${xywh || ''}&t=${tstart},${tend}`;
}
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,
body,
canvasId: canvas.id,
fragsel: { t, xywh },
id: (annotation && annotation.id) || `${uuid()}`,
image,
manifestId: canvas.options.resource.id,
svg,
tags,
xywh: fsel,
}).toJson();
if (annotation) {
storageAdapter.update(anno).then((annoPage) => {
receiveAnnotation(canvas.id, storageAdapter.annotationPageId, annoPage);
......@@ -214,10 +294,16 @@ class AnnotationCreation extends Component {
});
}
});
this.setState({
activeTool: null,
image: { id: null },
svg: null,
tend: null,
textBody: '',
textEditorStateBustingKey: textEditorStateBustingKey + 1,
tstart: null,
xywh: null,
});
closeCompanionWindow();
}
/** */
......@@ -235,16 +321,10 @@ class AnnotationCreation extends Component {
}
/** */
updateBody(annoBody) {
this.setState({ annoBody });
updateTextBody(textBody) {
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({
......@@ -259,11 +339,14 @@ class AnnotationCreation extends Component {
} = this.props;
const {
activeTool, colorPopoverOpen, currentColorType, fillColor, popoverAnchorEl, strokeColor,
popoverLineWeightAnchorEl, lineWeightPopoverOpen, strokeWidth, closedMode, annoBody, svg,
tstart, tend,
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';
return (
<CompanionWindow
title={annotation ? 'Edit annotation' : 'New annotation'}
......@@ -272,6 +355,7 @@ class AnnotationCreation extends Component {
>
<AnnotationDrawing
activeTool={activeTool}
annotation={annotation}
fillColor={fillColor}
strokeColor={strokeColor}
strokeWidth={strokeWidth}
......@@ -279,8 +363,9 @@ class AnnotationCreation extends Component {
svg={svg}
updateGeometry={this.updateGeometry}
windowId={windowId}
player={mediaIsVideo ? VideosReferences.get(windowId) : OSDReferences.get(windowId)}
/>
<form onSubmit={this.submitForm}>
<form onSubmit={this.submitForm} className={classes.section}>
<Grid container>
<Grid item xs={12}>
<Typography variant="overline">
......@@ -385,28 +470,62 @@ class AnnotationCreation extends Component {
)
: null
}
</Grid>
</Grid>
<Grid container>
{ mediaIsVideo && (
<>
<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">
Duration
Start
</Typography>
</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}>
<input name="tstart" type="number" step="1" value={tstart} onChange={this.updateTstart} />
<input name="tend" type="number" step="1" value={tend} onChange={this.updateTend} />
<Typography variant="overline">
<ToggleButton value="true" title="Go to end 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 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
annoHtml={annoBody}
updateAnnotationBody={this.updateBody}
key={textEditorStateBustingKey}
annoHtml={textBody}
updateAnnotationBody={this.updateTextBody}
/>
</Grid>
</Grid>
......@@ -423,12 +542,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>
......@@ -472,6 +594,19 @@ const styles = (theme) => ({
display: 'flex',
flexWrap: 'wrap',
},
section: {
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(1),
paddingTop: theme.spacing(2),
},
timecontrolsbutton: {
height: '30px',
margin: 'auto',
marginLeft: '0',
marginRight: '5px',
width: '30px',
},
});
AnnotationCreation.propTypes = {
......@@ -485,10 +620,19 @@ 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,
currentTime: PropTypes.oneOfType([PropTypes.number, PropTypes.instanceOf(null)]),
id: PropTypes.string.isRequired,
paused: PropTypes.bool,
receiveAnnotation: PropTypes.func.isRequired,
setCurrentTime: PropTypes.func,
setSeekTo: PropTypes.func,
windowId: PropTypes.string.isRequired,
};
......@@ -496,6 +640,10 @@ AnnotationCreation.defaultProps = {
annotation: null,
canvases: [],
closeCompanionWindow: () => {},
currentTime: null,
paused: true,
setCurrentTime: () => {},
setSeekTo: () => {},
};
export default withStyles(styles)(AnnotationCreation);
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import ResizeObserver from 'react-resize-observer';
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
{
......@@ -17,27 +18,87 @@ 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,
};
}
/** */
/** Create a portal with a drawing canvas and a form to fill annotations details */
class AnnotationDrawing extends Component {
/** */
constructor(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);
}
/** */
/** 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) {
const { closed, strokeWidth, updateGeometry } = this.props;
// TODO: Compute xywh of bounding container of layers
......@@ -65,36 +126,18 @@ class AnnotationDrawing extends Component {
});
}
/** Save paperjs ref once created */
paperDidMount(paper) {
this.paper = paper;
}
/** */
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 { viewProps, canvasProps } = this.getDisplayProps();
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/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;
switch (activeTool) {
case 'rectangle':
......@@ -118,14 +161,14 @@ class AnnotationDrawing extends Component {
return (
<div
className="foo"
style={{
height: '100%', left: 0, position: 'absolute', top: 0, width: '100%',
}}
>
<PaperContainer
canvasProps={{ style: { height: '100%', width: '100%' } }}
canvasProps={canvasProps}
viewProps={viewProps}
onMount={this.paperDidMount}
>
{renderWithPaperScope((paper) => {
const paths = flatten(paper.project.layers.map((layer) => (
......@@ -149,6 +192,7 @@ class AnnotationDrawing extends Component {
);
})}
</PaperContainer>
<ResizeObserver onResize={this.onPaperResize} />
</div>
);
}
......@@ -156,10 +200,17 @@ class AnnotationDrawing extends Component {
/** */
render() {
const { windowId } = this.props;
const container = OSDReferences.get(windowId)
? OSDReferences.get(windowId).current.element
: VideoViewersReferences.get(windowId).apiRef.current;
const osdref = OSDReferences.get(windowId);
const videoref = VideosReferences.get(windowId);
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 (
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);
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);
......@@ -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>
......
......@@ -2,14 +2,15 @@
export default class WebAnnotation {
/** */
constructor({
canvasId, id, xywh, body, tags, svg, manifestId,
canvasId, id, fragsel, image, body, tags, svg, manifestId,
}) {
this.id = id;
this.canvasId = canvasId;
this.xywh = xywh;
this.fragsel = fragsel;
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',
......@@ -46,35 +58,30 @@ export default class WebAnnotation {
return bodies;
}
/** */
/** Fill target object with selectors (if any), else returns target url */
target() {
let target = this.canvasId;
if (this.svg || this.xywh) {
target = {
source: this.source(),
};
if (!this.svg
&& (!this.fragsel || !Object.values(this.fragsel).find((e) => e !== null))) {
return this.canvasId;
}
const target = { source: this.source() };
const selectors = [];
if (this.svg) {
target.selector = {
selectors.push({
type: 'SvgSelector',
value: this.svg,
};
});
}
if (this.xywh) {
const fragsel = {
if (this.fragsel) {
selectors.push({
type: 'FragmentSelector',
value: `xywh=${this.xywh}`,
};
if (target.selector) {
// add fragment selector
target.selector = [
fragsel,
target.selector,
];
} else {
target.selector = fragsel;
}
value: Object.entries(this.fragsel)
.filter((kv) => kv[1])
.map((kv) => `${kv[0]}=${kv[1]}`)
.join('&'),
});
}
target.selector = selectors.length === 1 ? selectors[0] : selectors;
return target;
}
......
import * as actions from 'mirador/dist/es/src/state/actions';
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 { getPresentAnnotationsOnSelectedCanvases } from 'mirador/dist/es/src/state/selectors/annotations';
import AnnotationCreation from '../AnnotationCreation';
/** */
......@@ -11,27 +13,27 @@ const mapDispatchToProps = (dispatch, { id, windowId }) => ({
receiveAnnotation: (targetId, annoId, annotation) => dispatch(
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 }) {
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 });
let annotation;
canvases.forEach((canvas) => {
const annotationsOnCanvas = state.annotations[canvas.id];
Object.values(annotationsOnCanvas || {}).forEach((value, i) => {
if (value.json && value.json.items) {
annotation = value.json.items.find((anno) => anno.id === annotationid);
}
});
});
const annotation = getPresentAnnotationsOnSelectedCanvases(state, { windowId })
.flatMap((annoPage) => annoPage.json.items || [])
.find((annot) => annot.id === annotationid);
return {
annotation,
canvases,
config: state.config,
currentTime,
paused: getWindowPausedStatus(state, { windowId }),
};
}
......
......@@ -7,3 +7,17 @@ export function mapChildren(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];
}