UNPKG

@recogito/recogito-js

Version:

A JavaScript library for text annotation

417 lines (344 loc) 12.4 kB
import React, { Component } from 'react'; import { Editor } from '@recogito/recogito-client-core'; import Highlighter from './highlighter/Highlighter'; import SelectionHandler from './selection/SelectionHandler'; import RelationsLayer from './relations/RelationsLayer'; import RelationEditor from './relations/editor/RelationEditor'; import './TextAnnotator.scss'; /** * Pulls the strings between the annotation highlight layer * and the editor popup. */ export default class TextAnnotator extends Component { constructor(props) { super(props); this.state = { selectedAnnotation: null, selectedDOMElement: null, selectedRelation: null, // ReadOnly mode readOnly: this.props.config.readOnly, widgets: this.props.config.widgets, // Headless mode editorDisabled: this.props.config.disableEditor, } this._editor = React.createRef(); } /** Shorthand **/ clearState = () => { this.setState({ selectedAnnotation: null, selectedDOMElement: null }); this.selectionHandler.enabled = true; } handleEscape = (evt) => { if (evt.which === 27) this.onCancelAnnotation(); } componentDidMount() { this.highlighter = new Highlighter(this.props.contentEl, this.props.config.formatter); this.selectionHandler = new SelectionHandler(this.props.contentEl, this.highlighter, this.props.config.readOnly); this.selectionHandler.on('select', this.handleSelect); this.relationsLayer = new RelationsLayer(this.props.contentEl); this.relationsLayer.on('createRelation', this.onEditRelation); this.relationsLayer.on('selectRelation', this.onEditRelation); this.relationsLayer.on('cancelDrawing', this.closeRelationsEditor); document.addEventListener('keydown', this.handleEscape); } componentWillUnmount() { document.removeEventListener('keydown', this.handleEscape); } onChanged = () => { // Disable selection outside of the editor // when user makes the first change this.selectionHandler.enabled = false; } /**************************/ /* Annotation CRUD events */ /**************************/ /** Selection on the text **/ handleSelect = evt => { this.state.editorDisabled ? this.onHeadlessSelect(evt) : this.onNormalSelect(evt); } onNormalSelect = evt => { const { selection, element } = evt; if (selection) { this.setState({ selectedAnnotation: null, selectedDOMElement: null }, () => this.setState({ selectedAnnotation: selection, selectedDOMElement: element })); if (!selection.isSelection) this.props.onAnnotationSelected(selection.clone(), element); } else { this.clearState(); } } onHeadlessSelect = evt => { const { selection, element } = evt; if (selection) { this.setState({ selectedAnnotation: null, selectedDOMElement: null }, () => this.setState({ selectedAnnotation: selection, selectedDOMElement: element })); if (!selection.isSelection) { // Selection of existing annotation this.props.onAnnotationSelected(selection.clone(), element); } else { // Notify backend text selection to create a new annotation const undraft = annotation => annotation.clone({ body : annotation.bodies.map(({ draft, ...rest }) => rest) }); this.onCreateOrUpdateAnnotation('onAnnotationCreated')(undraft(selection).toAnnotation()); } } else { this.clearState(); } } /** * A convenience method that allows the external application to * override the autogenerated Id for an annotation. * * Usually, the override will happen almost immediately after * the annotation is created. But we need to be defensive and assume * that the override might come in with considerable delay, thus * the user might have made further edits already. * * A key challenge here is that there may be dependencies between * the original annotation and relations that were created meanwhile. */ overrideAnnotationId = originalAnnotation => forcedId => { const { id } = originalAnnotation; // After the annotation update, we need to update dependencies // on the annotation layer, if any const updateDependentRelations = updatedAnnotation => { // Wait until the highlighter update has come into effect requestAnimationFrame(() => { this.relationsLayer.overrideTargetAnnotation(originalAnnotation, updatedAnnotation); }) }; // Force the editors to close first, otherwise their annotations will be orphaned if (this.state.selectedAnnotation || this.state.selectedRelation) { this.relationsLayer.resetDrawing(); this.setState({ selectedAnnotation: null, selectedRelation: null }, () => { const updated = this.highlighter.overrideId(id, forcedId); updateDependentRelations(updated); }); } else { const updated = this.highlighter.overrideId(id, forcedId); updateDependentRelations(updated); } } /** * A convenience method that allows the external application to * override the autogenerated Id for a relation. * * This operation is less problematic than .overrideAnnotation(). * We just need to make sure the RelationEditor is closed, so that * the annotation doesn't become orphaned. Otherwise, there are * no dependencies. */ overrideRelationId = originalId => forcedId => { if (this.state.selectedRelation) { this.setState({ selectedRelation: null }, () => this.relationsLayer.overrideRelationId(originalId, forcedId)); } else { this.relationsLayer.overrideRelationId(originalId, forcedId); } } /** Common handler for annotation CREATE or UPDATE **/ onCreateOrUpdateAnnotation = method => (annotation, previous) => { this.clearState(); this.selectionHandler.clearSelection(); this.highlighter.addOrUpdateAnnotation(annotation, previous); // Call CREATE or UPDATE handler if (previous) this.props[method](annotation.clone(), previous.clone()); else this.props[method](annotation.clone(), this.overrideAnnotationId(annotation)); } onDeleteAnnotation = annotation => { // Delete connections this.relationsLayer.destroyConnectionsFor(annotation); this.clearState(); this.selectionHandler.clearSelection(); this.highlighter.removeAnnotation(annotation); this.props.onAnnotationDeleted(annotation); } /** Cancel button on annotation editor **/ onCancelAnnotation = annotation => { this.clearState(); this.selectionHandler.clearSelection(); this.props.onCancelSelected(annotation); } /************************/ /* Relation CRUD events */ /************************/ // Shorthand closeRelationsEditor = () => { this.setState({ selectedRelation: null }); this.relationsLayer.resetDrawing(); } /** * Selection on the relations layer: open an existing * or newly created connection for editing. */ onEditRelation = relation => { this.setState({ selectedRelation: relation }); } /** 'Ok' on the relation editor popup **/ onCreateOrUpdateRelation = (relation, previous) => { this.relationsLayer.addOrUpdateRelation(relation, previous); this.closeRelationsEditor(); // This method will always receive a 'previous' connection - // if the previous is just an empty connection, fire 'create', // otherwise, fire 'update' const isNew = previous.annotation.bodies.length === 0; if (isNew) this.props.onAnnotationCreated(relation.annotation.clone(), this.overrideRelationId(relation.annotation.id)); else this.props.onAnnotationUpdated(relation.annotation.clone(), previous.annotation.clone()); } /** 'Delete' on the relation editor popup **/ onDeleteRelation = relation => { this.relationsLayer.removeRelation(relation); this.closeRelationsEditor(); this.props.onAnnotationDeleted(relation.annotation); } /****************/ /* External API */ /****************/ addAnnotation = annotation => { this.highlighter.addOrUpdateAnnotation(annotation.clone()); } get disableSelect() { return !this.selectionHandler.enabled; } set disableSelect(disable) { if (disable) this.props.contentEl.classList.add('r6o-noselect'); else this.props.contentEl.classList.remove('r6o-noselect'); this.selectionHandler.enabled = !disable; } getAnnotations = () => { const annotations = this.highlighter.getAllAnnotations(); const relations = this.relationsLayer.getAllRelations(); return annotations.concat(relations).map(a => a.clone()); } removeAnnotation = annotation => { this.highlighter.removeAnnotation(annotation); // If the editor is currently open on this annotation, close it const { selectedAnnotation } = this.state; if (selectedAnnotation && annotation.isEqual(selectedAnnotation)) this.clearState(); } selectAnnotation = arg => { // De-select in any case this.setState({ selectedAnnotation: null, selectedDOMElement: null }, () => { if (arg) { const spans = this.highlighter.findAnnotationSpans(arg); if (spans.length > 0) { const selectedDOMElement = spans[0]; const selectedAnnotation = spans[0].annotation; this.setState({ selectedAnnotation, selectedDOMElement }); } } }); } setAnnotations = annotations => { this.highlighter.clear(); this.relationsLayer.clear(); const clones = annotations.map(a => a.clone()); return this.highlighter.init(clones).then(() => this.relationsLayer.init(clones)); } setMode = mode => { if (mode === 'RELATIONS') { this.clearState(); this.selectionHandler.enabled = false; this.relationsLayer.readOnly = false; this.relationsLayer.startDrawing(); } else { this.setState({ selectedRelation: null }); this.selectionHandler.enabled = true; this.relationsLayer.readOnly = true; this.relationsLayer.stopDrawing(); } } get readOnly() { return this.state.readOnly; } set readOnly(readOnly) { this.selectionHandler.readOnly = readOnly; // Note: relationsHandler.readOnly should be set by setMode. this.setState({ readOnly }); } get widgets() { return this.state.widgets; } set widgets(widgets) { this.setState({ widgets }); } get disableEditor() { return this.state.editorDisabled; } set disableEditor(disabled) { this.setState({ editorDisabled: disabled }); } render() { // The editor should open under normal conditions - annotation was selected, no headless mode const open = (this.state.selectedAnnotation || this.state.selectedRelation) && !this.state.editorDisabled; const readOnly = this.state.readOnly || this.state.selectedAnnotation?.readOnly; return (open && ( <> { this.state.selectedAnnotation && <Editor ref={this._editor} autoPosition={this.props.config.editorAutoPosition} wrapperEl={this.props.wrapperEl} annotation={this.state.selectedAnnotation} selectedElement={this.state.selectedDOMElement} readOnly={readOnly} allowEmpty={this.props.config.allowEmpty} widgets={this.state.widgets} env={this.props.env} onChanged={this.onChanged} onAnnotationCreated={this.onCreateOrUpdateAnnotation('onAnnotationCreated')} onAnnotationUpdated={this.onCreateOrUpdateAnnotation('onAnnotationUpdated')} onAnnotationDeleted={this.onDeleteAnnotation} onCancel={this.onCancelAnnotation} /> } { this.state.selectedRelation && <RelationEditor relation={this.state.selectedRelation} onRelationCreated={this.onCreateOrUpdateRelation} onRelationUpdated={this.onCreateOrUpdateRelation} onRelationDeleted={this.onDeleteRelation} onCancel={this.closeRelationsEditor} vocabulary={this.props.relationVocabulary} /> } </> )); } }