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 &&
+