diff --git a/package-lock.json b/package-lock.json index ebba3b4..52a290c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@recogito/recogito-client-core", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/src/TextAnnotator.jsx b/src/TextAnnotator.jsx new file mode 100644 index 0000000..aad0121 --- /dev/null +++ b/src/TextAnnotator.jsx @@ -0,0 +1,287 @@ +import React, { Component } from 'preact/compat'; +import Editor from './editor/Editor'; +import Highlighter from './highlighter/Highlighter'; +import SelectionHandler from './selection/SelectionHandler'; +import RelationsLayer from './relations/RelationsLayer'; +import RelationEditor from './relations/editor/RelationEditor'; + +/** + * Pulls the strings between the annotation highlight layer + * and the editor popup. + */ +export default class TextAnnotator extends Component { + + state = { + selectedAnnotation: null, + selectedDOMElement: null, + selectedRelation: null, + headless: false + } + + /** Shorthand **/ + clearState = () => { + this.setState({ + selectedAnnotation: null, + selectedDOMElement: null + }); + } + + 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.readOnly = true; // Deactivate by default + + 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); + } + + /**************************/ + /* Annotation CRUD events */ + /**************************/ + + /** Selection on the text **/ + handleSelect = 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()); + } 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.overrideId(originalId, forcedId)); + } else { + this.relationsLayer.overrideId(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 = () => { + this.clearState(); + this.selectionHandler.clearSelection(); + } + + /************************/ + /* 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()); + } + + 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(); + } + + setAnnotations = annotations => { + const clones = annotations.map(a => a.clone()); + this.highlighter.init(clones).then(() => + this.relationsLayer.init(clones)); + } + + getAnnotations = () => { + const annotations = this.highlighter.getAllAnnotations(); + const relations = this.relationsLayer.getAllRelations(); + return annotations.concat(relations).map(a => a.clone()); + } + + 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(); + } + } + + render() { + return ( + <> + { this.state.selectedAnnotation && + + } + + { this.state.selectedRelation && + + } + + ); + } + +} diff --git a/src/editor/Editor.jsx b/src/editor/Editor.jsx index d0de315..b4f322d 100644 --- a/src/editor/Editor.jsx +++ b/src/editor/Editor.jsx @@ -142,9 +142,18 @@ const Editor = props => { } }; + const onRemove = () => props.onAnnotationDeleted(props.annotation); + // Use default comment + tag widget unless host app overrides const widgets = props.config.widgets ? props.config.widgets.map(getWidget) : DEFAULT_WIDGETS; + + const removable = + currentAnnotation && + currentAnnotation.bodies.length > 0 && + !currentAnnotation.isSelection && + (props.isRemovable ? props.isRemovable(currentAnnotation) : true); + return (
@@ -171,6 +180,12 @@ const Editor = props => {
) : (
+ { removable && ( + + )} + diff --git a/themes/default/globals/includes/_buttons.scss b/themes/default/globals/includes/_buttons.scss index f811279..d51956f 100644 --- a/themes/default/globals/includes/_buttons.scss +++ b/themes/default/globals/includes/_buttons.scss @@ -40,3 +40,14 @@ background-color:transparent; text-shadow:none; } + +.r6o-btn.left { + margin-left: 8px; + margin-right: 0px; + float: left; +} + +.r6o-btn.highlight.outline { + border:1px solid $orange; + color:$orange; +} diff --git a/themes/default/globals/includes/_colors.scss b/themes/default/globals/includes/_colors.scss index 2e58e58..fcfb8b0 100644 --- a/themes/default/globals/includes/_colors.scss +++ b/themes/default/globals/includes/_colors.scss @@ -19,6 +19,9 @@ $blueish-white-hi:lighten($blueish-white, 3%); $lightgrey-border:#e5e5e5; $lightgrey-border-darker:#d6d7d9; +// Highlight color +$orange: rgb(230, 149, 0); + /** ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **/ /** Type colors **/ /** ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **/