Refactoring: removed all text depdendencies from core
This commit is contained in:
parent
a8340866d6
commit
91c1fd0ea2
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@recogito/recogito-client-core",
|
||||
"version": "0.3.4",
|
||||
"version": "1.0.0",
|
||||
"description": "Core functions, classes and components for RecogitoJS",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
|
|
@ -1,289 +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() {
|
||||
const readOnly = this.props.config.readOnly || this.state.selectedAnnotation?.readOnly
|
||||
|
||||
return (
|
||||
<>
|
||||
{ this.state.selectedAnnotation &&
|
||||
<Editor
|
||||
wrapperEl={this.props.wrapperEl}
|
||||
annotation={this.state.selectedAnnotation}
|
||||
selectedElement={this.state.selectedDOMElement}
|
||||
readOnly={readOnly}
|
||||
config={this.props.config}
|
||||
env={this.props.env}
|
||||
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}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,301 +0,0 @@
|
|||
const TEXT = 3; // HTML DOM node type for text nodes
|
||||
|
||||
const RENDER_BATCH_SIZE = 100; // Number of annotations to render in one frame
|
||||
|
||||
const uniqueItems = items => Array.from(new Set(items))
|
||||
|
||||
export default class Highlighter {
|
||||
|
||||
constructor(element, formatter) {
|
||||
this.el = element;
|
||||
this.formatter = formatter;
|
||||
}
|
||||
|
||||
init = annotations => new Promise((resolve, _) => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Discard all annotations without a TextPositionSelector
|
||||
const highlights = annotations.filter(a => a.selector('TextPositionSelector'));
|
||||
|
||||
// Sorting bottom to top significantly speeds things up,
|
||||
// because walkTextNodes will have a lot less to walk
|
||||
highlights.sort((a, b) => b.start - a.start);
|
||||
|
||||
// Render loop
|
||||
const render = annotations => {
|
||||
const batch = annotations.slice(0, RENDER_BATCH_SIZE);
|
||||
const remainder = annotations.slice(RENDER_BATCH_SIZE);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
batch.forEach(this._addAnnotation);
|
||||
if (remainder.length > 0) {
|
||||
render(remainder);
|
||||
} else {
|
||||
console.log(`Rendered ${highlights.length}, took ${performance.now() - startTime}ms`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render(highlights);
|
||||
})
|
||||
|
||||
_addAnnotation = annotation => {
|
||||
try {
|
||||
const [ domStart, domEnd ] = this.charOffsetsToDOMPosition([ annotation.start, annotation.end ]);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(domStart.node, domStart.offset);
|
||||
range.setEnd(domEnd.node, domEnd.offset);
|
||||
|
||||
const spans = this.wrapRange(range);
|
||||
this.applyStyles(annotation, spans);
|
||||
this.bindAnnotation(annotation, spans);
|
||||
} catch (error) {
|
||||
console.warn('Could not render annotation')
|
||||
console.warn(error);
|
||||
console.warn(annotation.underlying);
|
||||
}
|
||||
}
|
||||
|
||||
_findAnnotationSpans = annotation => {
|
||||
const allAnnotationSpans = document.querySelectorAll('.r6o-annotation');
|
||||
return Array.prototype.slice.call(allAnnotationSpans)
|
||||
.filter(span => span.annotation.isEqual(annotation));
|
||||
}
|
||||
|
||||
getAllAnnotations = () => {
|
||||
const allAnnotationSpans = document.querySelectorAll('.r6o-annotation');
|
||||
const allAnnotations = Array.from(allAnnotationSpans).map(span => span.annotation);
|
||||
return [...new Set(allAnnotations)];
|
||||
}
|
||||
|
||||
addOrUpdateAnnotation = (annotation, maybePrevious) => {
|
||||
// TODO index annotation to make this faster
|
||||
const annoSpans = this._findAnnotationSpans(annotation);
|
||||
const prevSpans = maybePrevious ? this._findAnnotationSpans(maybePrevious) : [];
|
||||
const spans = uniqueItems(annoSpans.concat(prevSpans));
|
||||
|
||||
if (spans.length > 0) {
|
||||
// naive approach
|
||||
this._unwrapHighlightings(spans);
|
||||
this.el.normalize();
|
||||
this._addAnnotation(annotation);
|
||||
} else {
|
||||
this._addAnnotation(annotation);
|
||||
}
|
||||
}
|
||||
|
||||
removeAnnotation = annotation => {
|
||||
const spans = this._findAnnotationSpans(annotation);
|
||||
if (spans) {
|
||||
this._unwrapHighlightings(spans)
|
||||
this.el.normalize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a new ID on the annotation with the given ID. This method handles
|
||||
* the ID update within the Highlighter ONLY. It's up to the application to
|
||||
* keep the RelationsLayer in sync!
|
||||
*
|
||||
* @returns the updated annotation for convenience
|
||||
*/
|
||||
overrideId = (originalId, forcedId) => {
|
||||
const allSpans = document.querySelectorAll(`.r6o-annotation[data-id="${originalId}"]`);
|
||||
const annotation = allSpans[0].annotation;
|
||||
|
||||
const updatedAnnotation = annotation.clone({ id : forcedId });
|
||||
this.bindAnnotation(updatedAnnotation, allSpans);
|
||||
|
||||
return updatedAnnotation;
|
||||
}
|
||||
|
||||
_unwrapHighlightings(highlightSpans) {
|
||||
for (const span of highlightSpans) {
|
||||
const parent = span.parentNode;
|
||||
parent.insertBefore(document.createTextNode(span.textContent), span);
|
||||
parent.removeChild(span);
|
||||
}
|
||||
}
|
||||
|
||||
applyStyles = (annotation, spans) => {
|
||||
const extraClasses = this.formatter ? this.formatter(annotation) : '';
|
||||
spans.forEach(span => span.className = `r6o-annotation ${extraClasses}`.trim());
|
||||
}
|
||||
|
||||
bindAnnotation = (annotation, elements) => {
|
||||
elements.forEach(el => {
|
||||
el.annotation = annotation;
|
||||
el.dataset.id = annotation.id;
|
||||
});
|
||||
}
|
||||
|
||||
walkTextNodes = (node, stopOffset, nodeArray) => {
|
||||
const nodes = (nodeArray) ? nodeArray : [];
|
||||
|
||||
const offset = (function() {
|
||||
var runningOffset = 0;
|
||||
nodes.forEach(function(node) {
|
||||
runningOffset += node.textContent.length;;
|
||||
});
|
||||
return runningOffset;
|
||||
})();
|
||||
|
||||
let keepWalking = true;
|
||||
|
||||
if (offset > stopOffset)
|
||||
return false;
|
||||
|
||||
if (node.nodeType === TEXT)
|
||||
nodes.push(node);
|
||||
|
||||
node = node.firstChild;
|
||||
|
||||
while(node && keepWalking) {
|
||||
keepWalking = this.walkTextNodes(node, stopOffset, nodes);
|
||||
node = node.nextSibling;
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
charOffsetsToDOMPosition = charOffsets => {
|
||||
const maxOffset = Math.max.apply(null, charOffsets);
|
||||
|
||||
const textNodeProps = (() => {
|
||||
let start = 0;
|
||||
return this.walkTextNodes(this.el, maxOffset).map(function(node) {
|
||||
var nodeLength = node.textContent.length,
|
||||
nodeProps = { node: node, start: start, end: start + nodeLength };
|
||||
|
||||
start += nodeLength;
|
||||
return nodeProps;
|
||||
});
|
||||
})();
|
||||
|
||||
return this.calculateDomPositionWithin(textNodeProps, charOffsets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a rootNode, this helper gets all text between a given
|
||||
* start- and end-node. Basically combines walkTextNodes (above)
|
||||
* with a hand-coded dropWhile & takeWhile.
|
||||
*/
|
||||
textNodesBetween = (startNode, endNode, rootNode) => {
|
||||
// To improve performance, don't walk the DOM longer than necessary
|
||||
var stopOffset = (function() {
|
||||
var rangeToEnd = document.createRange();
|
||||
rangeToEnd.setStart(rootNode, 0);
|
||||
rangeToEnd.setEnd(endNode, endNode.textContent.length);
|
||||
return rangeToEnd.toString().length;
|
||||
})(),
|
||||
|
||||
allTextNodes = this.walkTextNodes(rootNode, stopOffset),
|
||||
|
||||
nodesBetween = [],
|
||||
len = allTextNodes.length,
|
||||
take = false,
|
||||
n, i;
|
||||
|
||||
for (i=0; i<len; i++) {
|
||||
n = allTextNodes[i];
|
||||
|
||||
if (n === endNode) take = false;
|
||||
|
||||
if (take) nodesBetween.push(n);
|
||||
|
||||
if (n === startNode) take = true;
|
||||
}
|
||||
|
||||
return nodesBetween;
|
||||
}
|
||||
|
||||
calculateDomPositionWithin = (textNodeProperties, charOffsets) => {
|
||||
var positions = [];
|
||||
|
||||
textNodeProperties.forEach(function(props, i) {
|
||||
charOffsets.forEach(function(charOffset, j) {
|
||||
if (charOffset >= props.start && charOffset <= props.end) {
|
||||
// Don't attach nodes for the same charOffset twice
|
||||
var previousOffset = (positions.length > 0) ?
|
||||
positions[positions.length - 1].charOffset : false;
|
||||
|
||||
if (previousOffset !== charOffset)
|
||||
positions.push({
|
||||
charOffset: charOffset,
|
||||
node: props.node,
|
||||
offset: charOffset - props.start
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Break (i.e. return false) if all positions are computed
|
||||
return positions.length < charOffsets.length;
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
wrapRange = (range, commonRoot) => {
|
||||
const root = commonRoot ? commonRoot : this.el;
|
||||
|
||||
const surround = range => {
|
||||
var wrapper = document.createElement('SPAN');
|
||||
range.surroundContents(wrapper);
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
if (range.startContainer === range.endContainer) {
|
||||
return [ surround(range) ];
|
||||
} else {
|
||||
// The tricky part - we need to break the range apart and create
|
||||
// sub-ranges for each segment
|
||||
var nodesBetween =
|
||||
this.textNodesBetween(range.startContainer, range.endContainer, root);
|
||||
|
||||
// Start with start and end nodes
|
||||
var startRange = document.createRange();
|
||||
startRange.selectNodeContents(range.startContainer);
|
||||
startRange.setStart(range.startContainer, range.startOffset);
|
||||
var startWrapper = surround(startRange);
|
||||
|
||||
var endRange = document.createRange();
|
||||
endRange.selectNode(range.endContainer);
|
||||
endRange.setEnd(range.endContainer, range.endOffset);
|
||||
var endWrapper = surround(endRange);
|
||||
|
||||
// And wrap nodes in between, if any
|
||||
var centerWrappers = nodesBetween.reverse().map(function(node) {
|
||||
const wrapper = document.createElement('SPAN');
|
||||
node.parentNode.insertBefore(wrapper, node);
|
||||
wrapper.appendChild(node);
|
||||
return wrapper;
|
||||
});
|
||||
|
||||
return [ startWrapper ].concat(centerWrappers, [ endWrapper ]);
|
||||
}
|
||||
}
|
||||
|
||||
getAnnotationsAt = element => {
|
||||
// Helper to get all annotations in case of multipe nested annotation spans
|
||||
var getAnnotationsRecursive = function(element, a) {
|
||||
var annotations = (a) ? a : [ ],
|
||||
parent = element.parentNode;
|
||||
|
||||
annotations.push(element.annotation);
|
||||
|
||||
return (parent.classList.contains('r6o-annotation')) ?
|
||||
getAnnotationsRecursive(parent, annotations) : annotations;
|
||||
},
|
||||
|
||||
sortByQuoteLength = function(annotations) {
|
||||
return annotations.sort(function(a, b) {
|
||||
return a.quote.length - b.quote.length;
|
||||
});
|
||||
};
|
||||
|
||||
return sortByQuoteLength(getAnnotationsRecursive(element));
|
||||
}
|
||||
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { default as Highlighter } from './Highlighter';
|
|
@ -1,10 +1,7 @@
|
|||
export { default as Editor } from './editor/Editor';
|
||||
export { default as createEnvironment } from './Environment';
|
||||
export { default as I18n } from './i18n';
|
||||
export { default as TextAnnotator } from './TextAnnotator';
|
||||
export { default as WebAnnotation } from './WebAnnotation';
|
||||
|
||||
export * from './highlighter';
|
||||
export * from './relations';
|
||||
export * from './selection';
|
||||
export * from './utils';
|
||||
|
|
|
@ -1,118 +0,0 @@
|
|||
export default class Bounds {
|
||||
|
||||
constructor(elements, offsetContainer) {
|
||||
this.elements = elements;
|
||||
this.offsetContainer = offsetContainer;
|
||||
this.recompute();
|
||||
}
|
||||
|
||||
recompute = () => {
|
||||
this.offsetBounds = toUnionBoundingRects(this.elements).map(clientBounds => {
|
||||
return toOffsetBounds(clientBounds, this.offsetContainer);
|
||||
});
|
||||
}
|
||||
|
||||
get rects() {
|
||||
return this.offsetBounds;
|
||||
}
|
||||
|
||||
get top() {
|
||||
return this.offsetBounds[0].top;
|
||||
}
|
||||
|
||||
get bottom() {
|
||||
return this.offsetBounds[this.offsetBounds.length - 1].bottom;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.bottom - this.top;
|
||||
}
|
||||
|
||||
get topHandleXY() {
|
||||
return [
|
||||
this.offsetBounds[0].left + this.offsetBounds[0].width / 2 + 0.5,
|
||||
this.offsetBounds[0].top
|
||||
];
|
||||
}
|
||||
|
||||
get bottomHandleXY() {
|
||||
const i = this.offsetBounds.length - 1;
|
||||
return [
|
||||
this.offsetBounds[i].left + this.offsetBounds[i].width / 2 - 0.5,
|
||||
this.offsetBounds[i].bottom
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/** Translates DOMRect client bounds to offset bounds within the given container **/
|
||||
const toOffsetBounds = (clientBounds, offsetContainer) => {
|
||||
const { top, left } = offsetContainer.getBoundingClientRect();
|
||||
const l = Math.round(clientBounds.left - left);
|
||||
const t = Math.round(clientBounds.top - top);
|
||||
|
||||
return {
|
||||
left : l,
|
||||
top : t,
|
||||
right : Math.round(l + clientBounds.width),
|
||||
bottom: Math.round(t + clientBounds.height),
|
||||
width : Math.round(clientBounds.width),
|
||||
height: Math.round(clientBounds.height)
|
||||
};
|
||||
};
|
||||
|
||||
/** Returns a clean list of (merged) DOMRect bounds for the given elements **/
|
||||
const toUnionBoundingRects = elements => {
|
||||
const allRects = elements.reduce(function(arr, el) {
|
||||
const rectList = el.getClientRects();
|
||||
const len = rectList.length;
|
||||
|
||||
for (let i = 0; i<len; i++) {
|
||||
arr.push(rectList[i]);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, []);
|
||||
|
||||
return mergeBounds(allRects);
|
||||
}
|
||||
|
||||
/** Helper to merge two bounds that have the same height + are exactly consecutive **/
|
||||
const mergeBounds = clientBounds => {
|
||||
if (clientBounds.length == 1)
|
||||
return clientBounds; // shortcut
|
||||
|
||||
return clientBounds.reduce(function(merged, bbox) {
|
||||
const previous = (merged.length > 0) ? merged[merged.length - 1] : null;
|
||||
|
||||
const isConsecutive = function(a, b) {
|
||||
if (a.height === b.height)
|
||||
return (a.x + a.width === b.x || b.x + b.width === a.x);
|
||||
else
|
||||
return false;
|
||||
};
|
||||
|
||||
const extend = function(a, b) {
|
||||
const { bottom, height, top } = a;
|
||||
|
||||
const x = Math.min(a.x, b.x);
|
||||
const left = Math.min(a.left, b.left);
|
||||
const y = Math.max(a.y, b.y);
|
||||
const right = Math.max(a.right, b.right);
|
||||
const width = a.width + b.width;
|
||||
|
||||
return { bottom, height, left, right, top, width, x, y };
|
||||
};
|
||||
|
||||
if (previous) {
|
||||
if (isConsecutive(previous, bbox))
|
||||
merged[merged.length - 1] = extend(previous, bbox);
|
||||
else
|
||||
merged.push(bbox);
|
||||
} else {
|
||||
merged.push(bbox);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, []);
|
||||
}
|
|
@ -1,247 +0,0 @@
|
|||
import EventEmitter from 'tiny-emitter';
|
||||
import Bounds from './Bounds';
|
||||
import Handle from './Handle';
|
||||
import CONST from './SVGConst';
|
||||
import { getNodeById } from './RelationUtils'
|
||||
|
||||
/**
|
||||
* The connecting line between two annotation highlights.
|
||||
*/
|
||||
export default class Connection extends EventEmitter {
|
||||
|
||||
constructor(contentEl, svgEl, nodeOrAnnotation) {
|
||||
super();
|
||||
|
||||
this.svgEl = svgEl;
|
||||
|
||||
// SVG elements
|
||||
this.path = document.createElementNS(CONST.NAMESPACE, 'path'),
|
||||
this.startDot = document.createElementNS(CONST.NAMESPACE, 'circle'),
|
||||
this.endDot = document.createElementNS(CONST.NAMESPACE, 'circle'),
|
||||
|
||||
svgEl.appendChild(this.path);
|
||||
svgEl.appendChild(this.startDot);
|
||||
svgEl.appendChild(this.endDot);
|
||||
|
||||
// Connections are initialized either from a relation annotation
|
||||
// (when loading), or as a 'floating' relation, attached to a start
|
||||
// node (when drawing a new one).
|
||||
const props = nodeOrAnnotation.type === 'Annotation' ?
|
||||
this.initFromAnnotation(contentEl, svgEl, nodeOrAnnotation) :
|
||||
this.initFromStartNode(svgEl, nodeOrAnnotation);
|
||||
|
||||
this.annotation = props.annotation;
|
||||
|
||||
// 'Descriptive' instance properties
|
||||
this.fromNode = props.fromNode;
|
||||
this.fromBounds = props.fromBounds;
|
||||
|
||||
this.toNode = props.toNode;
|
||||
this.toBounds = props.toBounds;
|
||||
|
||||
this.currentEnd = props.currentEnd;
|
||||
|
||||
this.handle = props.handle;
|
||||
|
||||
// A floating connection is not yet attached to an end node.
|
||||
this.floating = props.floating;
|
||||
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Initializes a fixed connection from an annotation **/
|
||||
initFromAnnotation = function(contentEl, svgEl, annotation) {
|
||||
const [ fromId, toId ] = annotation.target.map(t => t.id);
|
||||
const relation = annotation.bodies[0].value;
|
||||
|
||||
const fromNode = getNodeById(contentEl, fromId);
|
||||
const fromBounds = new Bounds(fromNode.elements, svgEl);
|
||||
|
||||
const toNode = getNodeById(contentEl, toId);
|
||||
const toBounds = new Bounds(toNode.elements, svgEl);
|
||||
|
||||
const currentEnd = toNode;
|
||||
|
||||
const handle = new Handle(relation, svgEl);
|
||||
|
||||
// RelationsLayer uses click as a selection event
|
||||
handle.on('click', () => this.emit('click', {
|
||||
annotation,
|
||||
from: fromNode.annotation,
|
||||
to: toNode.annotation,
|
||||
midX: this.currentMidXY[0],
|
||||
midY: this.currentMidXY[1]
|
||||
}));
|
||||
|
||||
return { annotation, fromNode, fromBounds, toNode, toBounds, currentEnd, handle, floating: false };
|
||||
}
|
||||
|
||||
/** Initializes a floating connection from a start node **/
|
||||
initFromStartNode = function(svgEl, fromNode) {
|
||||
const fromBounds = new Bounds(fromNode.elements, svgEl);
|
||||
return { fromNode, fromBounds, floating: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes the end of the connection to the current end node,
|
||||
* turning a floating connection into a non-floating one.
|
||||
*/
|
||||
unfloat = function() {
|
||||
if (this.currentEnd.elements)
|
||||
this.floating = false;
|
||||
}
|
||||
|
||||
/** Moves the end of a (floating!) connection to the given [x,y] or node **/
|
||||
dragTo = function(xyOrNode) {
|
||||
if (this.floating) {
|
||||
this.currentEnd = xyOrNode;
|
||||
if (xyOrNode.elements) {
|
||||
this.toNode = xyOrNode;
|
||||
this.toBounds = new Bounds(xyOrNode.elements, this.svgEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy = () => {
|
||||
this.svgEl.removeChild(this.path);
|
||||
this.svgEl.removeChild(this.startDot);
|
||||
this.svgEl.removeChild(this.endDot);
|
||||
|
||||
if (this.handle)
|
||||
this.handle.destroy();
|
||||
}
|
||||
|
||||
/** Redraws this connection **/
|
||||
redraw = function() {
|
||||
if (this.currentEnd) {
|
||||
const end = this.endXY;
|
||||
|
||||
const startsAtTop = end[1] <= (this.fromBounds.top + this.fromBounds.height / 2);
|
||||
const start = (startsAtTop) ?
|
||||
this.fromBounds.topHandleXY : this.fromBounds.bottomHandleXY;
|
||||
|
||||
const deltaX = end[0] - start[0];
|
||||
const deltaY = end[1] - start[1];
|
||||
|
||||
const half = (Math.abs(deltaX) + Math.abs(deltaY)) / 2; // Half of length, for middot pos computation
|
||||
const midX = (half > Math.abs(deltaX)) ? start[0] + deltaX : start[0] + half * Math.sign(deltaX);
|
||||
|
||||
let midY; // computed later
|
||||
|
||||
const orientation = (half > Math.abs(deltaX)) ?
|
||||
(deltaY > 0) ? 'down' : 'up' :
|
||||
(deltaX > 0) ? 'right' : 'left';
|
||||
|
||||
const d = CONST.LINE_DISTANCE - CONST.BORDER_RADIUS; // Shorthand: vertical straight line length
|
||||
|
||||
// Path that starts at the top edge of the annotation highlight
|
||||
const compileBottomPath = function() {
|
||||
const arc1 = (deltaX > 0) ? CONST.ARC_9CC : CONST.ARC_3CW;
|
||||
const arc2 = (deltaX > 0) ? CONST.ARC_0CW : CONST.ARC_0CC;
|
||||
|
||||
midY = (half > Math.abs(deltaX)) ?
|
||||
start[1] + half - Math.abs(deltaX) + CONST.LINE_DISTANCE :
|
||||
start[1] + CONST.LINE_DISTANCE;
|
||||
|
||||
return 'M' + start[0] +
|
||||
' ' + start[1] +
|
||||
'v' + d +
|
||||
arc1 +
|
||||
'h' + (deltaX - 2 * Math.sign(deltaX) * CONST.BORDER_RADIUS) +
|
||||
arc2 +
|
||||
'V' + end[1];
|
||||
};
|
||||
|
||||
// Path that starts at the bottom edge of the annotation highlight
|
||||
const compileTopPath = function() {
|
||||
const arc1 = (deltaX > 0) ? CONST.ARC_9CW : CONST.ARC_3CC;
|
||||
const arc2 = (deltaX > 0) ?
|
||||
(deltaY >= 0) ? CONST.ARC_0CW : CONST.ARC_6CC :
|
||||
(deltaY >= 0) ? CONST.ARC_0CC : CONST.ARC_6CW;
|
||||
|
||||
midY = (half > Math.abs(deltaX)) ?
|
||||
start[1] - (half - Math.abs(deltaX)) - CONST.LINE_DISTANCE :
|
||||
start[1] - CONST.LINE_DISTANCE;
|
||||
|
||||
return 'M' + start[0] +
|
||||
' ' + start[1] +
|
||||
'v-' + (CONST.LINE_DISTANCE - CONST.BORDER_RADIUS) +
|
||||
arc1 +
|
||||
'h' + (deltaX - 2 * Math.sign(deltaX) * CONST.BORDER_RADIUS) +
|
||||
arc2 +
|
||||
'V' + end[1];
|
||||
};
|
||||
|
||||
this.startDot.setAttribute('cx', start[0]);
|
||||
this.startDot.setAttribute('cy', start[1]);
|
||||
this.startDot.setAttribute('r', 2);
|
||||
this.startDot.setAttribute('class', 'start');
|
||||
|
||||
this.endDot.setAttribute('cx', end[0]);
|
||||
this.endDot.setAttribute('cy', end[1]);
|
||||
this.endDot.setAttribute('r', 2);
|
||||
this.endDot.setAttribute('class', 'end');
|
||||
|
||||
if (startsAtTop)
|
||||
this.path.setAttribute('d', compileTopPath());
|
||||
else
|
||||
this.path.setAttribute('d', compileBottomPath());
|
||||
|
||||
this.path.setAttribute('class', 'connection');
|
||||
|
||||
this.currentMidXY = [ midX, midY ];
|
||||
|
||||
if (this.handle)
|
||||
this.handle.setPosition(this.currentMidXY, orientation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraws this connection, and additionally forces a recompute of
|
||||
* the start and end coordinates. This is only needed if the position
|
||||
* of the annotation highlights changes, e.g. after a window resize.
|
||||
*/
|
||||
recompute = () => {
|
||||
this.fromBounds.recompute();
|
||||
|
||||
if (this.currentEnd.elements)
|
||||
this.toBounds.recompute();
|
||||
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given relation matches this connection,
|
||||
* meaning that this connection has the same start and end point
|
||||
* as recorded in the relation.
|
||||
*/
|
||||
matchesRelation = relation =>
|
||||
relation.from.isEqual(this.fromNode.annotation) &&
|
||||
relation.to.isEqual(this.toNode.annotation);
|
||||
|
||||
/** Getter/setter shorthands **/
|
||||
|
||||
get isFloating() {
|
||||
return this.floating;
|
||||
}
|
||||
|
||||
get startAnnotation() {
|
||||
return this.fromNode.annotation;
|
||||
}
|
||||
|
||||
get endAnnotation() {
|
||||
return this.toNode.annotation;
|
||||
}
|
||||
|
||||
get endXY() {
|
||||
return (this.currentEnd instanceof Array) ?
|
||||
this.currentEnd :
|
||||
(this.fromBounds.top > this.toBounds.top) ?
|
||||
this.toBounds.bottomHandleXY : this.toBounds.topHandleXY;
|
||||
}
|
||||
|
||||
get midXY() {
|
||||
return this.currentMidXY;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,79 +0,0 @@
|
|||
import EventEmitter from 'tiny-emitter';
|
||||
import CONST from './SVGConst';
|
||||
|
||||
const escapeHtml = unsafe => {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export default class Handle extends EventEmitter {
|
||||
|
||||
constructor(label, svg) {
|
||||
super();
|
||||
|
||||
this.svg = svg;
|
||||
|
||||
this.g = document.createElementNS(CONST.NAMESPACE, 'g');
|
||||
this.text = document.createElementNS(CONST.NAMESPACE, 'text');
|
||||
this.rect = document.createElementNS(CONST.NAMESPACE, 'rect');
|
||||
this.arrow = document.createElementNS(CONST.NAMESPACE, 'path');
|
||||
|
||||
// Append first and init afterwards, so we can query text width/height
|
||||
this.g.appendChild(this.rect);
|
||||
this.g.appendChild(this.text);
|
||||
this.g.appendChild(this.arrow);
|
||||
this.svg.appendChild(this.g);
|
||||
|
||||
this.g.setAttribute('class', 'handle');
|
||||
|
||||
this.text.innerHTML = escapeHtml(label);
|
||||
|
||||
this.bounds = this.text.getBBox();
|
||||
|
||||
this.text.setAttribute('dy', 2);
|
||||
this.text.setAttribute('dx', - Math.round(this.bounds.width / 2));
|
||||
|
||||
this.rect.setAttribute('rx', 2); // Rounded corners
|
||||
this.rect.setAttribute('ry', 2);
|
||||
this.rect.setAttribute('width', Math.round(this.bounds.width) + 5);
|
||||
this.rect.setAttribute('height', Math.round(this.bounds.height));
|
||||
|
||||
this.arrow.setAttribute('class', 'r6o-arrow');
|
||||
|
||||
this.rect.addEventListener('click', () => this.emit('click'));
|
||||
}
|
||||
|
||||
setPosition = (xy, orientation) => {
|
||||
const x = Math.round(xy[0]) - 0.5;
|
||||
const y = Math.round(xy[1]);
|
||||
|
||||
const dx = Math.round(this.bounds.width / 2);
|
||||
|
||||
const createArrow = function() {
|
||||
if (orientation === 'left')
|
||||
return 'M' + (xy[0] - dx - 8) + ',' + (xy[1] - 4) + 'l-7,4l7,4';
|
||||
else if (orientation === 'right')
|
||||
return 'M' + (xy[0] + dx + 8) + ',' + (xy[1] - 4) + 'l7,4l-7,4';
|
||||
else if (orientation === 'down')
|
||||
return 'M' + (xy[0] - 4) + ',' + (xy[1] + 12) + 'l4,7l4,-7';
|
||||
else
|
||||
return 'M' + (xy[0] - 4) + ',' + (xy[1] - 12) + 'l4,-7l4,7';
|
||||
};
|
||||
|
||||
this.rect.setAttribute('x', x - 3 - dx);
|
||||
this.rect.setAttribute('y', y - 6.5);
|
||||
|
||||
this.text.setAttribute('x', x);
|
||||
this.text.setAttribute('y', y);
|
||||
|
||||
this.arrow.setAttribute('d', createArrow());
|
||||
}
|
||||
|
||||
destroy = () =>
|
||||
this.svg.removeChild(this.g);
|
||||
|
||||
};
|
|
@ -1,55 +0,0 @@
|
|||
/**
|
||||
* Returns the 'graph node' ({ annotation: ..., elements: ...}) for the
|
||||
* given annotation ID.
|
||||
*/
|
||||
export const getNodeById = function(contentEl, annotationId) {
|
||||
const elements = contentEl.querySelectorAll(`*[data-id="${annotationId}"]`);
|
||||
return (elements.length > 0) ?
|
||||
{ annotation: elements[0].annotation, elements: Array.from(elements) } : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the graph node for the target of the given mouse event (or
|
||||
* null if the event target is not an annotation span).
|
||||
*/
|
||||
export const getNodeForEvent = function(evt) {
|
||||
// Sorts annotations by length, so we can reliably get the inner-most
|
||||
const sortByQuoteLengthDesc = annotations =>
|
||||
annotations.sort((a, b) => a.quote.length - b.quote.length);
|
||||
|
||||
const annotationSpan = evt.target.closest('.r6o-annotation');
|
||||
|
||||
if (annotationSpan) {
|
||||
// All stacked annotation spans
|
||||
const spans = getAnnotationSpansRecursive(annotationSpan);
|
||||
|
||||
// Annotation from the inner-most span in the stack
|
||||
const annotation = sortByQuoteLengthDesc(spans.map(span => span.annotation))[0];
|
||||
|
||||
// ALL spans for this annotation (not just the hovered one)
|
||||
const elements = document.querySelectorAll(`.r6o-annotation[data-id="${annotation.id}"]`);
|
||||
|
||||
return { annotation, elements: Array.from(elements) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: gets all stacked annotation SPANS for an element.
|
||||
*
|
||||
* Reminder - annotations can be nested. This helper retrieves the whole stack.
|
||||
*
|
||||
* <span class="annotation" data-id="annotation-01">
|
||||
* <span class="annotation" data-id="annotation-02">
|
||||
* <span class="annotation" data-id="annotation-03">foo</span>
|
||||
* </span>
|
||||
* </span>
|
||||
*/
|
||||
export const getAnnotationSpansRecursive = function(element, s) {
|
||||
const spans = s ? s : [ ];
|
||||
spans.push(element);
|
||||
|
||||
const parent = element.parentNode;
|
||||
|
||||
return parent.classList.contains('r6o-annotation') ?
|
||||
getAnnotationSpansRecursive(parent, spans) : spans;
|
||||
}
|
|
@ -1,160 +0,0 @@
|
|||
import Connection from './Connection';
|
||||
import DrawingTool from './drawing/DrawingTool';
|
||||
import CONST from './SVGConst';
|
||||
import EventEmitter from 'tiny-emitter';
|
||||
|
||||
import './RelationsLayer.scss';
|
||||
|
||||
export default class RelationsLayer extends EventEmitter {
|
||||
|
||||
constructor(contentEl) {
|
||||
super();
|
||||
|
||||
this.connections = [];
|
||||
|
||||
this.contentEl = contentEl;
|
||||
|
||||
this.svg = document.createElementNS(CONST.NAMESPACE, 'svg');
|
||||
this.svg.setAttribute('class', 'r6o-relations-layer');
|
||||
this.contentEl.appendChild(this.svg);
|
||||
|
||||
this.drawingTool = new DrawingTool(contentEl, this.svg);
|
||||
|
||||
// Forward events
|
||||
this.drawingTool.on('createRelation', relation => this.emit('createRelation', relation));
|
||||
this.drawingTool.on('cancelDrawing', () => this.emit('cancelDrawing'));
|
||||
|
||||
// Redraw on window resize
|
||||
window.addEventListener('resize', () => requestAnimationFrame(() => {
|
||||
this.recomputeAll();
|
||||
}));
|
||||
}
|
||||
|
||||
/** Shorthand **/
|
||||
createConnection = annotation => {
|
||||
const c = new Connection(this.contentEl, this.svg, annotation);
|
||||
|
||||
// Forward click event as selection, unless we're read-only
|
||||
c.on('click', relation => this.emit('selectRelation', relation));
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
init = annotations => {
|
||||
// Filter annotations for 'relationship annotation' shape first
|
||||
this.connections = annotations.filter(annotation => {
|
||||
const allTargetsHashIDs = annotation.targets.every(t => t.id && t.id.startsWith('#'))
|
||||
return allTargetsHashIDs && annotation.motivation === 'linking';
|
||||
}).reduce((conns, annotation) => {
|
||||
try {
|
||||
const c = this.createConnection(annotation);
|
||||
return [ ...conns, c ];
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
console.log(`Error rendering relation for annotation ${annotation.id}`);
|
||||
return conns;
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
recomputeAll = () => {
|
||||
this.connections.forEach(conn => {
|
||||
conn.recompute();
|
||||
});
|
||||
}
|
||||
|
||||
addOrUpdateRelation = (relation, maybePrevious) => {
|
||||
const previous = maybePrevious ?
|
||||
this.connections.find(c => c.matchesRelation(relation)) : null;
|
||||
|
||||
if (previous) {
|
||||
// Replace existing
|
||||
this.connections = this.connections.map(connection => {
|
||||
if (connection == previous) {
|
||||
connection.destroy();
|
||||
return this.createConnection(relation.annotation);
|
||||
} else {
|
||||
return connection;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Add new
|
||||
const c = this.createConnection(relation.annotation);
|
||||
this.connections.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
removeRelation = relation => {
|
||||
const toRemove = this.connections.find(c => c.matchesRelation(relation));
|
||||
|
||||
if (toRemove) {
|
||||
this.connections = this.connections.filter(c => c !== toRemove);
|
||||
toRemove.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** Overrides the ID for an existing relation **/
|
||||
overrideRelationId = (originalId, forcedId) => {
|
||||
const conn = this.connections.find(c => c.annotation.id == originalId);
|
||||
const updatedAnnotation = conn.annotation.clone({ id : forcedId });
|
||||
conn.annotation = updatedAnnotation;
|
||||
return conn;
|
||||
}
|
||||
|
||||
/** Overrides the given source or target annotation **/
|
||||
overrideTargetAnnotation = (originalAnnotation, forcedAnnotation) => {
|
||||
const affectedFrom = this.connections.filter(c => c.fromNode.annotation == originalAnnotation);
|
||||
affectedFrom.forEach(c => c.fromNode.annotation = forcedAnnotation);
|
||||
|
||||
const affectedTo = this.connections.filter(c => c.toNode.annotation == originalAnnotation);
|
||||
affectedTo.forEach(c => c.toNode.annotation = forcedAnnotation);
|
||||
}
|
||||
|
||||
getAllRelations = () => {
|
||||
return this.connections.map(c => c.annotation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relations that have the given annotation as start
|
||||
* or end node.
|
||||
*/
|
||||
getConnectionsFor = annotation => {
|
||||
return this.connections.filter(c =>
|
||||
c.startAnnotation.isEqual(annotation) || c.endAnnotation.isEqual(annotation));
|
||||
}
|
||||
|
||||
destroyConnectionsFor = annotation => {
|
||||
const connections = this.getConnectionsFor(annotation);
|
||||
connections.forEach(c => c.destroy());
|
||||
this.connections = this.connections.filter(c => !connections.includes(c));
|
||||
}
|
||||
|
||||
show = () =>
|
||||
this.svg.style.display = 'inital';
|
||||
|
||||
hide = () => {
|
||||
this.drawingEnabled = false;
|
||||
this.svg.style.display = 'none';
|
||||
}
|
||||
|
||||
startDrawing = () =>
|
||||
this.drawingTool.enabled = true;
|
||||
|
||||
stopDrawing = () =>
|
||||
this.drawingTool.enabled = false;
|
||||
|
||||
resetDrawing = () =>
|
||||
this.drawingTool.reset();
|
||||
|
||||
get readOnly() {
|
||||
this.svg.classList.contains('readonly');
|
||||
}
|
||||
|
||||
set readOnly(readOnly) {
|
||||
if (readOnly)
|
||||
this.svg.setAttribute('class', 'r6o-relations-layer readonly');
|
||||
else
|
||||
this.svg.setAttribute('class', 'r6o-relations-layer');
|
||||
}
|
||||
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
// Standard 'black' (well, not quite) type
|
||||
$line-color:#3f3f3f;
|
||||
|
||||
.r6o-drawing {
|
||||
cursor:none;
|
||||
}
|
||||
|
||||
.r6o-relations-layer.readonly {
|
||||
|
||||
.handle rect {
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.r6o-relations-layer {
|
||||
position:absolute;
|
||||
top:0;
|
||||
left:0;
|
||||
width:100%;
|
||||
height:100%;
|
||||
pointer-events:none;
|
||||
|
||||
circle {
|
||||
stroke:lighten($line-color, 7%);
|
||||
stroke-width:0.4;
|
||||
fill:$line-color;
|
||||
}
|
||||
|
||||
path {
|
||||
stroke:lighten($line-color, 10%);
|
||||
stroke-linecap:round;
|
||||
stroke-linejoin:round;
|
||||
fill:transparent;
|
||||
}
|
||||
|
||||
path.connection {
|
||||
stroke-width:1.6;
|
||||
stroke-dasharray:2,3;
|
||||
}
|
||||
|
||||
path.r6o-arrow {
|
||||
stroke-width:1.8;
|
||||
fill:lighten($line-color, 25%);
|
||||
}
|
||||
|
||||
.handle {
|
||||
|
||||
rect {
|
||||
stroke-width:1;
|
||||
stroke:lighten($line-color, 10%);
|
||||
fill:#fff;
|
||||
pointer-events:auto;
|
||||
cursor:pointer;
|
||||
}
|
||||
|
||||
text {
|
||||
font-size:10px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.hover {
|
||||
stroke:rgba($line-color, 0.9);
|
||||
stroke-width:1.4;
|
||||
fill:transparent;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
// Rounded path corner radius
|
||||
const BORDER_RADIUS = 3;
|
||||
|
||||
// Horizontal distance between connection line and annotation highlight
|
||||
const LINE_DISTANCE = 6.5;
|
||||
|
||||
const SVGConst = {
|
||||
|
||||
NAMESPACE : 'http://www.w3.org/2000/svg',
|
||||
|
||||
// Rounded corner arc radius
|
||||
BORDER_RADIUS : BORDER_RADIUS,
|
||||
|
||||
// Horizontal distance between connection line and annotation highlight
|
||||
LINE_DISTANCE : LINE_DISTANCE,
|
||||
|
||||
// Possible rounded corner SVG arc configurations: clock position + clockwise/counterclockwise
|
||||
ARC_0CW : 'a' + BORDER_RADIUS + ',' + BORDER_RADIUS + ' 0 0 1 ' + BORDER_RADIUS + ',' + BORDER_RADIUS,
|
||||
ARC_0CC : 'a' + BORDER_RADIUS + ',' + BORDER_RADIUS + ' 0 0 0 -' + BORDER_RADIUS + ',' + BORDER_RADIUS,
|
||||
ARC_3CW : 'a' + BORDER_RADIUS + ',' + BORDER_RADIUS + ' 0 0 1 -' + BORDER_RADIUS + ',' + BORDER_RADIUS,
|
||||
ARC_3CC : 'a' + BORDER_RADIUS + ',' + BORDER_RADIUS + ' 0 0 0 -' + BORDER_RADIUS + ',-' + BORDER_RADIUS,
|
||||
ARC_6CW : 'a' + BORDER_RADIUS + ',' + BORDER_RADIUS + ' 0 0 1 -' + BORDER_RADIUS + ',-' + BORDER_RADIUS,
|
||||
ARC_6CC : 'a' + BORDER_RADIUS + ',' + BORDER_RADIUS + ' 0 0 0 ' + BORDER_RADIUS + ',-' + BORDER_RADIUS,
|
||||
ARC_9CW : 'a' + BORDER_RADIUS + ',' + BORDER_RADIUS + ' 0 0 1 ' + BORDER_RADIUS + ',-' + BORDER_RADIUS,
|
||||
ARC_9CC : 'a' + BORDER_RADIUS + ',' + BORDER_RADIUS + ' 0 0 0 ' + BORDER_RADIUS + ',' + BORDER_RADIUS
|
||||
|
||||
}
|
||||
|
||||
export default SVGConst;
|
|
@ -1,182 +0,0 @@
|
|||
import EventEmitter from 'tiny-emitter';
|
||||
import Connection from '../Connection';
|
||||
import HoverEmphasis from './HoverEmphasis';
|
||||
import { getNodeForEvent } from '../RelationUtils';
|
||||
import WebAnnotation from '../../WebAnnotation';
|
||||
|
||||
/**
|
||||
* Wraps an event handler for event delegation. This way, we
|
||||
* can listen to events emitted by children matching the given
|
||||
* selector, rather than attaching (loads of!) handlers to each
|
||||
* child individually.
|
||||
*/
|
||||
const delegatingHandler = (selector, handler) => evt => {
|
||||
if (evt.target.matches(selector))
|
||||
handler(evt);
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawing tool for creating a new relation.
|
||||
*/
|
||||
export default class DrawingTool extends EventEmitter {
|
||||
|
||||
constructor(contentEl, svgEl) {
|
||||
super();
|
||||
|
||||
this.contentEl = contentEl;
|
||||
this.svgEl = svgEl;
|
||||
|
||||
this.currentHover = null;
|
||||
this.currentConnection = null;
|
||||
}
|
||||
|
||||
attachHandlers = () => {
|
||||
this.contentEl.classList.add('r6o-noselect');
|
||||
|
||||
this.contentEl.addEventListener('mousedown', this.onMouseDown);
|
||||
this.contentEl.addEventListener('mousemove', this.onMouseMove);
|
||||
this.contentEl.addEventListener('mouseup', this.onMouseUp);
|
||||
|
||||
this.contentEl.addEventListener('mouseover', this.onEnterAnnotation);
|
||||
this.contentEl.addEventListener('mouseout', this.onLeaveAnnotation);
|
||||
|
||||
document.addEventListener('keydown', this.onKeyDown);
|
||||
}
|
||||
|
||||
detachHandlers = () => {
|
||||
this.contentEl.classList.remove('r6o-noselect');
|
||||
|
||||
this.contentEl.removeEventListener('mousedown', this.onMouseDown);
|
||||
this.contentEl.removeEventListener('mousemove', this.onMouseMove);
|
||||
this.contentEl.removeEventListener('mouseup', this.onMouseUp);
|
||||
|
||||
this.contentEl.removeEventListener('mouseover', this.onEnterAnnotation);
|
||||
this.contentEl.removeEventListener('mouseleave', this.onLeaveAnnotation);
|
||||
|
||||
document.removeEventListener('keydown', this.onKeyDown);
|
||||
}
|
||||
|
||||
onMouseDown = evt => {
|
||||
const node = getNodeForEvent(evt);
|
||||
if (node) {
|
||||
if (this.currentConnection) {
|
||||
this.completeConnection(node);
|
||||
} else {
|
||||
this.startNewConnection(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove = evt => {
|
||||
if (this.currentConnection && this.currentConnection.isFloating) {
|
||||
if (this.currentHover) {
|
||||
this.currentConnection.dragTo(this.currentHover.node);
|
||||
} else {
|
||||
const { top, left } = this.contentEl.getBoundingClientRect();
|
||||
this.currentConnection.dragTo([ evt.pageX - left, evt.pageY - top ]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We want to support both possible drawing modes: click once for start
|
||||
* and once for end; or click and hold to start, drag to end and release.
|
||||
*/
|
||||
onMouseUp = () => {
|
||||
if (this.currentHover && this.currentConnection && this.currentConnection.isFloating) {
|
||||
// If this is a different node than the start node, complete the connection
|
||||
if (this.currentHover.annotation !== this.currentConnection.startAnnotation) {
|
||||
this.completeConnection(this.currentHover.node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onKeyDown = evt => {
|
||||
if (evt.which === 27) { // Escape
|
||||
this.reset();
|
||||
this.emit('cancelDrawing');
|
||||
}
|
||||
}
|
||||
|
||||
/** Emphasise hovered annotation **/
|
||||
onEnterAnnotation = delegatingHandler('.r6o-annotation', evt => {
|
||||
if (this.currentHover)
|
||||
this.hover();
|
||||
|
||||
this.hover(getNodeForEvent(evt).elements);
|
||||
});
|
||||
|
||||
/** Clear hover emphasis **/
|
||||
onLeaveAnnotation = delegatingHandler('.r6o-annotation', evt => {
|
||||
this.hover();
|
||||
});
|
||||
|
||||
/** Drawing code for hover emphasis */
|
||||
hover = elements => {
|
||||
if (elements) {
|
||||
this.currentHover = new HoverEmphasis(this.svgEl, elements);
|
||||
} else { // Clear hover
|
||||
if (this.currentHover)
|
||||
this.currentHover.destroy();
|
||||
|
||||
this.currentHover = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start drawing a new connection line **/
|
||||
startNewConnection = fromNode => {
|
||||
this.currentConnection = new Connection(this.contentEl, this.svgEl, fromNode);
|
||||
this.contentEl.classList.add('r6o-drawing');
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** Complete drawing of a new connection **/
|
||||
completeConnection = function() {
|
||||
this.currentConnection.unfloat();
|
||||
|
||||
this.contentEl.classList.remove('r6o-drawing');
|
||||
|
||||
const from = this.currentConnection.startAnnotation;
|
||||
const to = this.currentConnection.endAnnotation;
|
||||
const [ midX, midY ] = this.currentConnection.midXY;
|
||||
|
||||
const annotation = WebAnnotation.create({
|
||||
target: [
|
||||
{ id: from.id },
|
||||
{ id: to.id }
|
||||
]
|
||||
});
|
||||
|
||||
this.emit('createRelation', { annotation, from, to, midX, midY });
|
||||
}
|
||||
|
||||
reset = () => {
|
||||
if (this.currentConnection) {
|
||||
this.currentConnection.destroy();
|
||||
this.currentConnection = null;
|
||||
this.contentEl.classList.remove('r6o-drawing');
|
||||
}
|
||||
}
|
||||
|
||||
render = () => {
|
||||
if (this.currentConnection) {
|
||||
this.currentConnection.redraw();
|
||||
requestAnimationFrame(this.render);
|
||||
}
|
||||
}
|
||||
|
||||
set enabled(enabled) {
|
||||
if (enabled) {
|
||||
this.attachHandlers();
|
||||
} else {
|
||||
this.detachHandlers();
|
||||
this.contentEl.classList.remove('r6o-drawing');
|
||||
|
||||
if (this.currentConnection) {
|
||||
this.currentConnection.destroy();
|
||||
this.currentConnection = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
import Bounds from '../Bounds';
|
||||
import CONST from '../SVGConst';
|
||||
|
||||
export default class HoverEmphasis {
|
||||
|
||||
constructor(svgEl, elements) {
|
||||
this.annotation = elements[0].annotation;
|
||||
this.node = { annotation: this.annotation, elements };
|
||||
|
||||
this.outlines = document.createElementNS(CONST.NAMESPACE, 'g');
|
||||
|
||||
const bounds = new Bounds(elements, svgEl);
|
||||
|
||||
bounds.rects.forEach(r => {
|
||||
const rect = document.createElementNS(CONST.NAMESPACE, 'rect');
|
||||
|
||||
rect.setAttribute('x', r.left - 0.5);
|
||||
rect.setAttribute('y', r.top - 0.5);
|
||||
rect.setAttribute('width', r.width + 1);
|
||||
rect.setAttribute('height', r.height);
|
||||
rect.setAttribute('class', 'hover');
|
||||
|
||||
this.outlines.appendChild(rect);
|
||||
});
|
||||
|
||||
svgEl.appendChild(this.outlines);
|
||||
|
||||
this.svgEl = svgEl;
|
||||
}
|
||||
|
||||
destroy = () => {
|
||||
this.svgEl.removeChild(this.outlines);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,105 +0,0 @@
|
|||
import React, { Component } from 'preact/compat';
|
||||
import { TrashIcon, CheckIcon } from '../../Icons';
|
||||
import Autocomplete from '../../editor/widgets/Autocomplete';
|
||||
|
||||
/**
|
||||
* Shorthand to get the label (= first tag body value) from the
|
||||
* annotation of a relation.
|
||||
*/
|
||||
const getContent = relation => {
|
||||
const firstTag = relation.annotation.bodies.find(b => b.purpose === 'tagging');
|
||||
return firstTag ? firstTag.value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A React component for the relationship editor popup. Note that this
|
||||
* component is NOT wired into the RelationsLayer directly, but needs
|
||||
* to be used separately by the implementing application. We
|
||||
* still keep it in the /recogito-relations folder though, so that
|
||||
* all code that belongs together stays together.
|
||||
*/
|
||||
export default class RelationEditor extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.element = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setPosition();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.setPosition();
|
||||
}
|
||||
|
||||
setPosition() {
|
||||
if (this.element.current) {
|
||||
const el = this.element.current;
|
||||
const { midX, midY } = this.props.relation;
|
||||
|
||||
el.style.top = `${midY}px`;
|
||||
el.style.left = `${midX}px`;
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit = () => {
|
||||
const value = this.element.current.querySelector('input').value;
|
||||
|
||||
const updatedAnnotation = this.props.relation.annotation.clone({
|
||||
motivation: 'linking',
|
||||
body: [{
|
||||
type: 'TextualBody',
|
||||
value,
|
||||
purpose: 'tagging'
|
||||
}]
|
||||
});
|
||||
|
||||
const updatedRelation = { ...this.props.relation, annotation: updatedAnnotation };
|
||||
|
||||
if (value) {
|
||||
// Fire create or update event
|
||||
if (this.props.relation.annotation.bodies.length === 0)
|
||||
this.props.onRelationCreated(updatedRelation, this.props.relation);
|
||||
else
|
||||
this.props.onRelationUpdated(updatedRelation, this.props.relation);
|
||||
} else {
|
||||
// Leaving the tag empty and hitting Enter equals cancel
|
||||
this.props.onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
onDelete = () =>
|
||||
this.props.onRelationDeleted(this.props.relation);
|
||||
|
||||
render() {
|
||||
return(
|
||||
<div className="r6o-relation-editor" ref={this.element}>
|
||||
<div className="input-wrapper">
|
||||
<Autocomplete
|
||||
initialValue={getContent(this.props.relation)}
|
||||
placeholder="Tag..."
|
||||
onSubmit={this.onSubmit}
|
||||
onCancel={this.props.onCancel}
|
||||
vocabulary={this.props.vocabulary || []} />
|
||||
</div>
|
||||
|
||||
<div className="buttons">
|
||||
<span
|
||||
className="r6o-icon delete"
|
||||
onClick={this.onDelete}>
|
||||
<TrashIcon width={14} />
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="r6o-icon ok"
|
||||
onClick={this.onSubmit}>
|
||||
<CheckIcon width={14} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
export { default as RelationsLayer } from './RelationsLayer';
|
||||
export { default as RelationEditor } from './editor/RelationEditor';
|
|
@ -1,133 +0,0 @@
|
|||
import { trimRange, rangeToSelection, enableTouch, getExactOverlaps } from './SelectionUtils';
|
||||
import EventEmitter from 'tiny-emitter';
|
||||
|
||||
const IS_TOUCH = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
|
||||
const IS_INTERNET_EXPLORER =
|
||||
navigator?.userAgent.match(/(MSIE|Trident)/);
|
||||
|
||||
/** Tests whether maybeChildEl is contained in containerEl **/
|
||||
const contains = (containerEl, maybeChildEl) => {
|
||||
if (IS_INTERNET_EXPLORER) {
|
||||
// In IE, .contains returns false for text nodes
|
||||
// https://stackoverflow.com/questions/44140712/ie-acting-strange-with-node-contains-and-text-nodes
|
||||
if (maybeChildEl.nodeType == Node.TEXT_NODE)
|
||||
return containerEl === maybeChildEl.parentNode || containerEl.contains(maybeChildEl.parentNode);
|
||||
else
|
||||
return containerEl.contains(maybeChildEl);
|
||||
} else {
|
||||
// Things can be so simple, unless you're in IE
|
||||
return containerEl.contains(maybeChildEl);
|
||||
}
|
||||
}
|
||||
|
||||
export default class SelectionHandler extends EventEmitter {
|
||||
|
||||
constructor(element, highlighter, readOnly) {
|
||||
super();
|
||||
|
||||
this.el = element;
|
||||
this.highlighter = highlighter;
|
||||
this.readOnly = readOnly;
|
||||
|
||||
this.isEnabled = true;
|
||||
|
||||
element.addEventListener('mousedown', this._onMouseDown);
|
||||
element.addEventListener('mouseup', this._onMouseUp);
|
||||
|
||||
if (IS_TOUCH)
|
||||
enableTouch(element, this._onMouseUp);
|
||||
}
|
||||
|
||||
get enabled() {
|
||||
return this.isEnabled;
|
||||
}
|
||||
|
||||
set enabled(enabled) {
|
||||
this.isEnabled = enabled;
|
||||
}
|
||||
|
||||
_onMouseDown = evt => {
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
_onMouseUp = evt => {
|
||||
if (this.isEnabled) {
|
||||
const selection = getSelection();
|
||||
|
||||
if (selection.isCollapsed) {
|
||||
const annotationSpan = evt.target.closest('.r6o-annotation');
|
||||
if (annotationSpan) {
|
||||
this.emit('select', {
|
||||
selection: this.highlighter.getAnnotationsAt(annotationSpan)[0],
|
||||
element: annotationSpan
|
||||
});
|
||||
} else {
|
||||
// De-select
|
||||
this.emit('select', {});
|
||||
}
|
||||
} else if (!this.readOnly) {
|
||||
const selectedRange = trimRange(selection.getRangeAt(0));
|
||||
|
||||
// Make sure the selection is entirely inside this.el
|
||||
const { commonAncestorContainer } = selectedRange;
|
||||
|
||||
if (contains(this.el, commonAncestorContainer)) {
|
||||
const stub = rangeToSelection(selectedRange, this.el);
|
||||
|
||||
const spans = this.highlighter.wrapRange(selectedRange);
|
||||
spans.forEach(span => span.className = 'r6o-selection');
|
||||
|
||||
this._hideNativeSelection();
|
||||
|
||||
const exactOverlaps = getExactOverlaps(stub, spans);
|
||||
if (exactOverlaps.length > 0) {
|
||||
// User selected existing - reuse top-most original to avoid stratification
|
||||
this.clearSelection();
|
||||
this.emit('select', {
|
||||
selection: exactOverlaps[0],
|
||||
element: evt.target.closest('.r6o-annotation')
|
||||
});
|
||||
} else {
|
||||
this.emit('select', {
|
||||
selection: stub,
|
||||
element: selectedRange
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_hideNativeSelection = () => {
|
||||
this.el.classList.add('r6o-hide-selection');
|
||||
}
|
||||
|
||||
clearSelection = () => {
|
||||
this._currentSelection = null;
|
||||
|
||||
// Remove native selection, if any
|
||||
if (window.getSelection) {
|
||||
if (window.getSelection().empty) { // Chrome
|
||||
window.getSelection().empty();
|
||||
} else if (window.getSelection().removeAllRanges) { // Firefox
|
||||
window.getSelection().removeAllRanges();
|
||||
}
|
||||
} else if (document.selection) { // IE?
|
||||
document.selection.empty();
|
||||
}
|
||||
|
||||
this.el.classList.remove('r6o-hide-selection');
|
||||
|
||||
const spans = Array.prototype.slice.call(this.el.querySelectorAll('.r6o-selection'));
|
||||
if (spans) {
|
||||
spans.forEach(span => {
|
||||
const parent = span.parentNode;
|
||||
parent.insertBefore(document.createTextNode(span.textContent), span);
|
||||
parent.removeChild(span);
|
||||
});
|
||||
}
|
||||
this.el.normalize();
|
||||
}
|
||||
|
||||
}
|
|
@ -1,107 +0,0 @@
|
|||
import Selection from './Selection';
|
||||
|
||||
export const trimRange = range => {
|
||||
let quote = range.toString();
|
||||
let leadingSpaces = 0;
|
||||
let trailingSpaces = 0;
|
||||
|
||||
// Count/strip leading spaces
|
||||
while (quote.substring(0, 1) === ' ') {
|
||||
leadingSpaces += 1;
|
||||
quote = quote.substring(1);
|
||||
}
|
||||
|
||||
// Count/strip trailing spaces
|
||||
while (quote.substring(quote.length - 1) === ' ') {
|
||||
trailingSpaces += 1;
|
||||
quote = quote.substring(0, quote.length - 1);
|
||||
}
|
||||
|
||||
// Adjust range
|
||||
if (leadingSpaces > 0)
|
||||
range.setStart(range.startContainer, range.startOffset + leadingSpaces);
|
||||
|
||||
if (trailingSpaces > 0)
|
||||
range.setEnd(range.endContainer, range.endOffset - trailingSpaces);
|
||||
|
||||
return range;
|
||||
};
|
||||
|
||||
export const rangeToSelection = (range, containerEl) => {
|
||||
const rangeBefore = document.createRange();
|
||||
|
||||
// A helper range from the start of the contentNode to the start of the selection
|
||||
rangeBefore.setStart(containerEl, 0);
|
||||
rangeBefore.setEnd(range.startContainer, range.startOffset);
|
||||
|
||||
const quote = range.toString();
|
||||
const start = rangeBefore.toString().length;
|
||||
|
||||
return new Selection({
|
||||
selector: [{
|
||||
type: 'TextQuoteSelector',
|
||||
exact: quote
|
||||
}, {
|
||||
type: 'TextPositionSelector',
|
||||
start: start,
|
||||
end: start + quote.length
|
||||
}]
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Util function that checks if the given selection is an exact overlap to any
|
||||
* existing annotations, and returns them, if so
|
||||
*/
|
||||
export const getExactOverlaps = (newAnnotation, selectedSpans) => {
|
||||
// All existing annotations at this point
|
||||
const existingAnnotations = [];
|
||||
|
||||
selectedSpans.forEach(span => {
|
||||
const enclosingAnnotationSpan = span.closest('.r6o-annotation');
|
||||
const enclosingAnnotation = enclosingAnnotationSpan?.annotation;
|
||||
|
||||
if (enclosingAnnotation && !existingAnnotations.includes(enclosingAnnotation))
|
||||
existingAnnotations.push(enclosingAnnotation);
|
||||
});
|
||||
|
||||
if (existingAnnotations.length > 0)
|
||||
return existingAnnotations.filter(anno => {
|
||||
const isSameAnchor = anno.anchor == newAnnotation.anchor;
|
||||
const isSameQuote = anno.quote == newAnnotation.quote;
|
||||
return isSameAnchor && isSameQuote;
|
||||
});
|
||||
else
|
||||
return [];
|
||||
};
|
||||
|
||||
export const enableTouch = (element, selectHandler) => {
|
||||
let touchTimeout;
|
||||
let lastTouchEvent;
|
||||
|
||||
const onTouchStart = evt => {
|
||||
if (!touchTimeout) {
|
||||
lastTouchEvent = evt;
|
||||
touchTimeout = setTimeout(executeTouchSelect, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
const executeTouchSelect = () => {
|
||||
if (lastTouchEvent) {
|
||||
selectHandler(lastTouchEvent);
|
||||
touchTimeout = null;
|
||||
lastTouchEvent = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resetTouch = evt => {
|
||||
if (touchTimeout) {
|
||||
clearTimeout(touchTimeout);
|
||||
touchTimeout = setTimeout(executeTouchSelect, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener('touchstart', onTouchStart);
|
||||
document.addEventListener('selectionchange', resetTouch);
|
||||
}
|
|
@ -1,2 +1,2 @@
|
|||
export { default as Selection } from './Selection';
|
||||
export { default as SelectionHandler } from './SelectionHandler';
|
||||
|
||||
|
|
Loading…
Reference in New Issue