Enhanced logic to determine whether annotation has delete button or not

This commit is contained in:
Rainer Simon 2021-03-20 14:56:32 +01:00
parent 75d5c79d13
commit 400406a650
3 changed files with 51 additions and 321 deletions

View File

@ -1,287 +0,0 @@
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 &&
<Editor
wrapperEl={this.props.wrapperEl}
annotation={this.state.selectedAnnotation}
selectedElement={this.state.selectedDOMElement}
config={this.props.config}
env={this.props.env}
isRemovable={this.props.isRemovable}
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}
/>
}
</>
);
}
}

View File

@ -142,17 +142,25 @@ const Editor = props => {
}
};
const onRemove = () => props.onAnnotationDeleted(props.annotation);
const onDelete = () =>
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);
const isReadOnlyWidget = w => w.type.disableDelete ?
w.type.disableDelete(currentAnnotation, {
...w.props,
readOnly:props.readOnly,
env: props.env
}) : false;
const hasDelete = currentAnnotation &&
currentAnnotation.bodies.length > 0 && // annotation has bodies,
!props.readOnly && // we are not in read-only config,
!currentAnnotation.isSelection && // this is not a selection, and
!widgets.some(isReadOnlyWidget); // every widget is deletable
return (
<div ref={element} className="r6o-editor">
@ -180,8 +188,8 @@ const Editor = props => {
</div>
) : (
<div className="r6o-footer">
{ removable && (
<button className="r6o-btn highlight left outline" onClick={onRemove}>
{ hasDelete && (
<button className="r6o-btn highlight left outline" onClick={onDelete}>
{i18n.t('Remove')}
</button>
)}

View File

@ -19,6 +19,33 @@ const isComment = (body, matchAllPurposes) => {
);
}
/**
/* A comment should be read-only if:
/* - the global read-only flag is set
/* - the current rule is 'MINE_ONLY' and the creator ID differs
/* The 'editable' config flag overrides the global setting, if any
*/
const isReadOnlyComment = (body, props) => {
if (props.editable === true)
return false;
if (props.editable === false)
return true;
if (props.editable === 'MINE_ONLY') {
// The original creator of the body
const creator = body.creator?.id;
// The current user
const me = props.env.user?.id;
return me !== creator;
}
// Global setting as last possible option
return props.readOnly;
}
/**
* The draft reply is a comment body with a 'draft' flag
*/
@ -59,31 +86,6 @@ const CommentWidget = props => {
const onChangeReplyPurpose = purpose =>
props.onUpdateBody(draftReply, { ...draftReply, purpose: purpose.value });
// A comment should be read-only if:
// - the global read-only flag is set
// - the current rule is 'MINE_ONLY' and the creator ID differs
// The 'editable' config flag overrides the global setting, if any
const isReadOnly = body => {
if (props.editable === true)
return false;
if (props.editable === false)
return true;
if (props.editable === 'MINE_ONLY') {
// The original creator of the body
const creator = body.creator?.id;
// The current user
const me = props.env.user?.id;
return me !== creator;
}
// Global setting as last possible option
return props.readOnly;
}
return (
<>
{ comments.map((body, idx) =>
@ -91,7 +93,7 @@ const CommentWidget = props => {
key={idx}
env={props.env}
purposeSelector={props.purposeSelector}
readOnly={isReadOnly(body)}
readOnly={isReadOnlyComment(body, props)}
body={body}
onUpdate={props.onUpdateBody}
onDelete={props.onRemoveBody}
@ -122,4 +124,11 @@ const CommentWidget = props => {
}
CommentWidget.disableDelete = (annotation, props) => {
const commentBodies =
annotation.bodies.filter(body => isComment(body, props.purposeSelector));
return commentBodies.some(comment => isReadOnlyComment(comment, props));
}
export default CommentWidget;