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

Target

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

Commits on Source 37

16 files
+ 42115
184
Compare changes
  • Side-by-side
  • Inline

Files

+5 −3
Original line number Diff line number Diff line
@@ -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"
  }
}
+0 −1
Original line number Diff line number Diff line
@@ -5,4 +5,3 @@
/node_modules
/umd
npm-debug.log*
package-lock.json

.gitlab-ci.yml

0 → 100644
+14 −0
Original line number Diff line number Diff line
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

Original line number Diff line number Diff line
@@ -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: {
Original line number Diff line number Diff line
@@ -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', () => {
Original line number Diff line number Diff line

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]);

package-lock.json

0 → 100644
+41432 −0

File added.

Preview size limit exceeded, changes collapsed.

+21 −18
Original line number Diff line number Diff line
{
  "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": "",
Original line number Diff line number Diff line
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);
Original line number Diff line number Diff line
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)
    );

src/HMSInput.js

0 → 100644
+115 −0
Original line number Diff line number Diff line
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);

src/ImageFormField.js

0 → 100644
+62 −0
Original line number Diff line number Diff line
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);
Original line number Diff line number Diff line
@@ -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>
Original line number Diff line number Diff line
@@ -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;
  }

Original line number Diff line number Diff line
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 }),
  };
}

+14 −0
Original line number Diff line number Diff line
@@ -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];
}