From 91c1fd0ea27b662cf876c304516f0fc8a2104978 Mon Sep 17 00:00:00 2001 From: Rainer Simon Date: Sat, 20 Feb 2021 18:52:29 +0100 Subject: [PATCH] Refactoring: removed all text depdendencies from core --- package.json | 2 +- src/TextAnnotator.jsx | 289 ----------------------- src/highlighter/Highlighter.js | 301 ------------------------ src/highlighter/index.js | 1 - src/index.js | 3 - src/relations/Bounds.js | 118 ---------- src/relations/Connection.js | 247 ------------------- src/relations/Handle.js | 79 ------- src/relations/RelationUtils.js | 55 ----- src/relations/RelationsLayer.js | 160 ------------- src/relations/RelationsLayer.scss | 69 ------ src/relations/SVGConst.js | 29 --- src/relations/drawing/DrawingTool.js | 182 -------------- src/relations/drawing/HoverEmphasis.js | 35 --- src/relations/editor/RelationEditor.jsx | 105 --------- src/relations/index.js | 2 - src/selection/SelectionHandler.js | 133 ----------- src/selection/SelectionUtils.js | 107 --------- src/selection/index.js | 2 +- 19 files changed, 2 insertions(+), 1917 deletions(-) delete mode 100644 src/TextAnnotator.jsx delete mode 100644 src/highlighter/Highlighter.js delete mode 100644 src/highlighter/index.js delete mode 100644 src/relations/Bounds.js delete mode 100644 src/relations/Connection.js delete mode 100644 src/relations/Handle.js delete mode 100644 src/relations/RelationUtils.js delete mode 100644 src/relations/RelationsLayer.js delete mode 100644 src/relations/RelationsLayer.scss delete mode 100644 src/relations/SVGConst.js delete mode 100644 src/relations/drawing/DrawingTool.js delete mode 100644 src/relations/drawing/HoverEmphasis.js delete mode 100644 src/relations/editor/RelationEditor.jsx delete mode 100644 src/relations/index.js delete mode 100644 src/selection/SelectionHandler.js delete mode 100644 src/selection/SelectionUtils.js diff --git a/package.json b/package.json index 2152b58..6683450 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/TextAnnotator.jsx b/src/TextAnnotator.jsx deleted file mode 100644 index e145196..0000000 --- a/src/TextAnnotator.jsx +++ /dev/null @@ -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 && - - } - - { this.state.selectedRelation && - - } - - ); - } - -} diff --git a/src/highlighter/Highlighter.js b/src/highlighter/Highlighter.js deleted file mode 100644 index 9b992d1..0000000 --- a/src/highlighter/Highlighter.js +++ /dev/null @@ -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 { - 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)); - } - -} diff --git a/src/highlighter/index.js b/src/highlighter/index.js deleted file mode 100644 index cb9ce60..0000000 --- a/src/highlighter/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as Highlighter } from './Highlighter'; diff --git a/src/index.js b/src/index.js index 97166d2..6cff55c 100644 --- a/src/index.js +++ b/src/index.js @@ -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'; diff --git a/src/relations/Bounds.js b/src/relations/Bounds.js deleted file mode 100644 index 9f09d9a..0000000 --- a/src/relations/Bounds.js +++ /dev/null @@ -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 { - 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; - }, []); -} diff --git a/src/relations/Connection.js b/src/relations/Connection.js deleted file mode 100644 index b880852..0000000 --- a/src/relations/Connection.js +++ /dev/null @@ -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; - } - -} diff --git a/src/relations/Handle.js b/src/relations/Handle.js deleted file mode 100644 index 41386df..0000000 --- a/src/relations/Handle.js +++ /dev/null @@ -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, '''); -} - -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); - -}; \ No newline at end of file diff --git a/src/relations/RelationUtils.js b/src/relations/RelationUtils.js deleted file mode 100644 index d439f3b..0000000 --- a/src/relations/RelationUtils.js +++ /dev/null @@ -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. - * - * - * - * foo - * - * - */ -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; -} \ No newline at end of file diff --git a/src/relations/RelationsLayer.js b/src/relations/RelationsLayer.js deleted file mode 100644 index 5b9a135..0000000 --- a/src/relations/RelationsLayer.js +++ /dev/null @@ -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'); - } - -} diff --git a/src/relations/RelationsLayer.scss b/src/relations/RelationsLayer.scss deleted file mode 100644 index 7cd2723..0000000 --- a/src/relations/RelationsLayer.scss +++ /dev/null @@ -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; - } - -} \ No newline at end of file diff --git a/src/relations/SVGConst.js b/src/relations/SVGConst.js deleted file mode 100644 index 5949a63..0000000 --- a/src/relations/SVGConst.js +++ /dev/null @@ -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; \ No newline at end of file diff --git a/src/relations/drawing/DrawingTool.js b/src/relations/drawing/DrawingTool.js deleted file mode 100644 index f914682..0000000 --- a/src/relations/drawing/DrawingTool.js +++ /dev/null @@ -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; - } - } - } - -} diff --git a/src/relations/drawing/HoverEmphasis.js b/src/relations/drawing/HoverEmphasis.js deleted file mode 100644 index 015b9e1..0000000 --- a/src/relations/drawing/HoverEmphasis.js +++ /dev/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); - } - -} \ No newline at end of file diff --git a/src/relations/editor/RelationEditor.jsx b/src/relations/editor/RelationEditor.jsx deleted file mode 100644 index b80491b..0000000 --- a/src/relations/editor/RelationEditor.jsx +++ /dev/null @@ -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( -
-
- -
- -
- - - - - - - -
-
- ) - } - -} \ No newline at end of file diff --git a/src/relations/index.js b/src/relations/index.js deleted file mode 100644 index 0e48a96..0000000 --- a/src/relations/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as RelationsLayer } from './RelationsLayer'; -export { default as RelationEditor } from './editor/RelationEditor'; diff --git a/src/selection/SelectionHandler.js b/src/selection/SelectionHandler.js deleted file mode 100644 index 69dd9a8..0000000 --- a/src/selection/SelectionHandler.js +++ /dev/null @@ -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(); - } - -} diff --git a/src/selection/SelectionUtils.js b/src/selection/SelectionUtils.js deleted file mode 100644 index 2f30ec0..0000000 --- a/src/selection/SelectionUtils.js +++ /dev/null @@ -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); -} \ No newline at end of file diff --git a/src/selection/index.js b/src/selection/index.js index bb26bf9..b002fe4 100644 --- a/src/selection/index.js +++ b/src/selection/index.js @@ -1,2 +1,2 @@ export { default as Selection } from './Selection'; -export { default as SelectionHandler } from './SelectionHandler'; +