@cogic/annotorious
Version:
A JavaScript image annotation library
450 lines (365 loc) • 13.8 kB
JSX
import React, { Component } from 'react';
import AnnotationLayer from './AnnotationLayer';
import { Editor } from '@recogito/recogito-client-core';
import { getSnippet } from './util/ImageSnippet';
import './ImageAnnotator.scss';
export default class ImageAnnotator extends Component {
constructor(props) {
super(props);
this.state = {
selectedAnnotation: null,
selectedDOMElement: null,
modifiedTarget: null,
// ReadOnly mode
readOnly: this.props.config.readOnly,
// Headless mode
editorDisabled: this.props.config.disableEditor,
// Widgets
widgets: this.props.config.widgets,
// Records the state before any potential headless modify (done via
// .updateSelected) so we can properly fire the updateAnnotation(a, previous)
// event, and distinguish between headless Save and Cancel
beforeHeadlessModify: null
}
this._editor = React.createRef();
}
/** Shorthand **/
clearState = opt_callback => this.setState({
selectedAnnotation: null,
selectedDOMElement: null,
modifiedTarget: null,
beforeHeadlessModify: null
}, opt_callback);
componentDidMount() {
this.annotationLayer = new AnnotationLayer(this.props);
this.annotationLayer.on('load', this.props.onLoad);
this.annotationLayer.on('startSelection', this.handleStartSelect);
this.annotationLayer.on('select', this.handleSelect);
this.annotationLayer.on('updateTarget', this.handleUpdateTarget);
this.forwardEvent('clickAnnotation','onClickAnnotation');
this.forwardEvent('mouseEnterAnnotation', 'onMouseEnterAnnotation');
this.forwardEvent('mouseLeaveAnnotation','onMouseLeaveAnnotation');
// Escape cancels editing
document.addEventListener('keyup', this.onKeyUp);
}
forwardEvent = (from, to) => {
this.annotationLayer.on(from, (annotation, elem) => {
this.props[to](annotation.clone(), elem);
});
}
componentWillUnmount() {
this.annotationLayer.destroy();
document.removeEventListener('keyup', this.onKeyUp);
}
onKeyUp = evt => {
if (evt.which === 27) { // Escape
const { disableEscapeKey } = this.props.config;
if (!disableEscapeKey) {
this.annotationLayer.stopDrawing();
const { selectedAnnotation } = this.state;
if (selectedAnnotation) {
this.cancelSelected();
this.props.onCancelSelected(selectedAnnotation);
}
}
} else if (evt.which === 46) { // Delete
const { disableDeleteKey } = this.props.config;
if (!disableDeleteKey) {
const { selectedAnnotation } = this.state;
if (selectedAnnotation) {
if (selectedAnnotation.isSelection) {
this.onCancelAnnotation(selectedAnnotation);
} else {
this.onDeleteAnnotation(selectedAnnotation);
}
}
}
}
}
handleStartSelect = pt =>
this.props.onSelectionStarted(pt);
handleSelect = (evt, skipEvent) => {
this.state.editorDisabled ?
this.onHeadlessSelect(evt, skipEvent) : this.onNormalSelect(evt, skipEvent);
}
/** Selection when editorDisabled == false **/
onNormalSelect = (evt, skipEvent) => {
const { annotation, element } = evt;
if (annotation) {
// Select action needs to run immediately if no annotation was
// selected before. Otherwise, make a deselect state change first,
// and then select after this state change has completed. (This is
// keep our external event cycle clean!)
const select = opt_callback => {
this.setState({
selectedAnnotation: annotation,
selectedDOMElement: element,
modifiedTarget: null,
beforeHeadlessModify: null
}, () => {
if (!skipEvent) {
if (annotation.isSelection) {
this.props.onSelectionCreated(annotation.clone());
} else {
this.props.onAnnotationSelected(annotation.clone(), element);
}
}
opt_callback && opt_callback();
});
}
// If there is another selected annotation,
// fire cancel before making the new selection
const { selectedAnnotation } = this.state;
if (selectedAnnotation && !selectedAnnotation.isEqual(annotation)) {
this.clearState(() => {
this.props.onCancelSelected(selectedAnnotation.clone());
select(() =>
this.props.onChangeSelected(annotation.clone(), selectedAnnotation.clone()));
});
} else {
select();
}
} else {
const { selectedAnnotation } = this.state;
if (selectedAnnotation)
this.clearState(() => {
this.props.onCancelSelected(selectedAnnotation);
});
else
this.clearState();
}
}
/** Selection when editorDisabled == true **/
onHeadlessSelect = (evt, skipEvent) => {
// When in headless mode, changing selection acts as 'Ok' - changes
// to the previous annotation are stored! (In normal mode, selection
// acts as 'Cancel'.)
this.saveSelected().then(() => {
this.onNormalSelect(evt, skipEvent);
// saveSelected will have deselected - we need to re-select
const { annotation } = evt;
if (annotation && !annotation.isSelection) {
const selection = this.annotationLayer.selectAnnotation(evt.annotation, true);
this.setState({ selectedDOMElement: selection.element });
}
});
}
handleUpdateTarget = (selectedDOMElement, modifiedTarget) => {
this.setState({ selectedDOMElement, modifiedTarget });
const clone = JSON.parse(JSON.stringify(modifiedTarget));
this.props.onSelectionTargetChanged(clone);
}
/**
* A convenience method that allows the external application to
* override the autogenerated Id for an annotation.
*/
overrideAnnotationId = originalAnnotation => forcedId => {
const { id } = originalAnnotation;
// Force the editor to close first, otherwise there's a risk of orphaned annotation
if (this.state.selectedAnnotation) {
this.clearState(() => {
this.annotationLayer.overrideId(id, forcedId);
});
} else {
this.annotationLayer.overrideId(id, forcedId);
}
}
/**************************/
/* Annotation CRUD events */
/**************************/
/** Common handler for annotation CREATE or UPDATE **/
onCreateOrUpdateAnnotation = (method, opt_callback) => (annotation, previous) => {
// Merge updated target if necessary
let a = annotation.isSelection ? annotation.toAnnotation() : annotation;
a = (this.state.modifiedTarget) ?
a.clone({ target: this.state.modifiedTarget }) : a.clone();
this.clearState(() => {
this.annotationLayer.deselect();
this.annotationLayer.addOrUpdateAnnotation(a, previous);
// Call CREATE or UPDATE handler
if (previous)
this.props[method](a, previous.clone());
else
this.props[method](a, this.overrideAnnotationId(a));
opt_callback && opt_callback();
});
}
onDeleteAnnotation = annotation => {
this.clearState();
this.annotationLayer.removeAnnotation(annotation);
this.props.onAnnotationDeleted(annotation);
}
onCancelAnnotation = (annotation, opt_callback) => {
this.annotationLayer.deselect();
this.props.onCancelSelected(annotation);
this.clearState(opt_callback);
}
/****************/
/* External API */
/****************/
addAnnotation = annotation =>
this.annotationLayer.addOrUpdateAnnotation(annotation.clone());
addDrawingTool = plugin =>
this.annotationLayer.addDrawingTool(plugin);
/** Cancel is an async op, return promise **/
cancelSelected = () => new Promise(resolve => {
this.annotationLayer.deselect();
if (this.state.selectedAnnotation) {
// Clear state and resolve afterwards
this.clearState(resolve);
} else {
// Nothing to clear - resolve now
resolve();
}
});
get disableEditor() {
return this.state.editorDisabled;
}
set disableEditor(disabled) {
this.setState({ editorDisabled: disabled });
}
get disableSelect() {
return this.annotationLayer.disableSelect;
}
set disableSelect(disable) {
this.annotationLayer.disableSelect = disable;
}
get formatters() {
return this.annotationLayer.formatters;
}
set formatters(formatters) {
this.annotationLayer.formatters = formatters;
this.annotationLayer.redraw();
}
getAnnotationById = annotationId =>
this.annotationLayer.findShape(annotationId)?.annotation;
getAnnotations = () =>
this.annotationLayer.getAnnotations().map(a => a.clone());
getImageSnippetById = annotationId => {
const shape = this.annotationLayer.findShape(annotationId);
if (shape)
return getSnippet(this.props.env.image, shape);
}
getSelected = () => {
if (this.state.selectedAnnotation) {
return this.state.editorDisabled ?
this.state.selectedAnnotation :
this._editor.current?.getCurrentAnnotation();
}
}
getSelectedImageSnippet = () =>
this.annotationLayer.getSelectedImageSnippet();
getCurrentDrawingTool = () => this.annotationLayer.tools?.current;
getSelectedShape = () =>
this.annotationLayer.selectedShape;
listDrawingTools = () =>
this.annotationLayer.listDrawingTools();
get readOnly() {
return this.state.readOnly;
}
set readOnly(readOnly) {
this.annotationLayer.readOnly = readOnly;
this.setState({ readOnly });
}
removeAnnotation = annotationOrId => {
this.annotationLayer.removeAnnotation(annotationOrId);
if (this.state.selectedAnnotation)
this.setState({ selectedAnnotation: null });
}
removeDrawingTool = id =>
this.annotationLayer.removeDrawingTool(id);
saveSelected = () =>
new Promise(resolve => {
const a = this.state.selectedAnnotation;
if (a) {
if (this._editor.current) {
this._editor.current.onOk();
resolve();
} else if (a.isSelection) {
if (a.bodies.length > 0 || this.props.config.allowEmpty) {
this.onCreateOrUpdateAnnotation('onAnnotationCreated', resolve)(a);
} else {
this.annotationLayer.deselect();
resolve();
}
} else {
// Headless update?
const { beforeHeadlessModify, modifiedTarget } = this.state;
if (beforeHeadlessModify) {
// Annotation was modified using '.updateSelected()'
this.onCreateOrUpdateAnnotation('onAnnotationUpdated', resolve)(a, beforeHeadlessModify);
} else if (modifiedTarget) {
// Target was modified, but otherwise no change
this.onCreateOrUpdateAnnotation('onAnnotationUpdated', resolve)(a, a);
} else {
this.onCancelAnnotation(a, resolve);
}
}
} else {
resolve();
}
});
selectAnnotation = arg => {
const selected = this.annotationLayer.selectAnnotation(arg, true);
if (selected) {
this.handleSelect(selected, true);
return selected.annotation.clone();
} else {
this.clearState(); // Deselect
}
}
setAnnotations = annotations =>
this.annotationLayer.init(annotations.map(a => a.clone()));
setDrawingTool = shape =>
this.annotationLayer.setDrawingTool(shape);
setVisible = visible => {
this.annotationLayer.setVisible(visible);
if (!visible)
this.clearState();
}
updateSelected = (annotation, saveImmediately) =>
new Promise(resolve => {
if (this.state.selectedAnnotation) {
if (saveImmediately) {
if (this.state.selectedAnnotation.isSelection) {
this.onCreateOrUpdateAnnotation('onAnnotationCreated', resolve)(annotation);
} else {
this.onCreateOrUpdateAnnotation('onAnnotationUpdated', resolve)(annotation, this.state.selectedAnnotation);
}
} else {
this.setState({
selectedAnnotation: annotation, // Updated annotation
beforeHeadlessModify: this.state.beforeHeadlessModify || this.state.selectedAnnotation
}, resolve);
}
}
});
get widgets() {
return this.state.widgets;
}
set widgets(widgets) {
this.setState({ widgets });
}
render() {
// The editor should open under normal conditions - annotation was selected, no headless mode
const open = this.state.selectedAnnotation && !this.state.editorDisabled;
const readOnly = this.state.readOnly || this.state.selectedAnnotation?.readOnly;
return (open && (
<Editor
ref={this._editor}
detachable
wrapperEl={this.props.wrapperEl}
annotation={this.state.selectedAnnotation}
modifiedTarget={this.state.modifiedTarget}
selectedElement={this.state.selectedDOMElement}
readOnly={readOnly}
allowEmpty={this.props.config.allowEmpty}
widgets={this.state.widgets}
env={this.props.env}
onAnnotationCreated={this.onCreateOrUpdateAnnotation('onAnnotationCreated')}
onAnnotationUpdated={this.onCreateOrUpdateAnnotation('onAnnotationUpdated')}
onAnnotationDeleted={this.onDeleteAnnotation}
onCancel={this.onCancelAnnotation} />
))
}
}