Refactoring: removed all text depdendencies from core

This commit is contained in:
Rainer Simon 2021-02-20 18:52:29 +01:00
parent a8340866d6
commit 91c1fd0ea2
19 changed files with 2 additions and 1917 deletions

View File

@ -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": {

View File

@ -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}
/>
}
</>
);
}
}

View File

@ -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));
}
}

View File

@ -1 +0,0 @@
export { default as Highlighter } from './Highlighter';

View File

@ -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';

View File

@ -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;
}, []);
}

View File

@ -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;
}
}

View File

@ -1,79 +0,0 @@
import EventEmitter from 'tiny-emitter';
import CONST from './SVGConst';
const escapeHtml = unsafe => {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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);
};

View File

@ -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;
}

View File

@ -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');
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;
}
}
}
}

View File

@ -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);
}
}

View File

@ -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>
)
}
}

View File

@ -1,2 +0,0 @@
export { default as RelationsLayer } from './RelationsLayer';
export { default as RelationEditor } from './editor/RelationEditor';

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -1,2 +1,2 @@
export { default as Selection } from './Selection';
export { default as SelectionHandler } from './SelectionHandler';