Initial import

This commit is contained in:
Rainer Simon 2020-04-03 21:31:10 +02:00
commit f155c5751d
50 changed files with 12600 additions and 0 deletions

8
.babelrc Normal file
View File

@ -0,0 +1,8 @@
{
"presets": [
"@babel/preset-env"
],
"plugins": [
"@babel/plugin-proposal-class-properties"
]
}

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# recogito-client-core
Core functions, classes and components for RecogitoJS.

9771
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "recogito-client-core",
"version": "0.1.0",
"description": "Core functions, classes and components for RecogitoJS",
"main": "src/index.js",
"scripts": {
"test": "./node_modules/.bin/mocha --require @babel/register",
"build": "webpack --mode=production"
},
"repository": {
"type": "git",
"url": "git+https://github.com/recogito/recogito-client-core.git"
},
"keywords": [
"Annotation",
"RecogitoJS"
],
"author": "Rainer Simon",
"license": "BSD-3-Clause",
"bugs": {
"url": "https://github.com/recogito/recogito-client-core/issues"
},
"homepage": "https://github.com/recogito/recogito-client-core#readme",
"devDependencies": {
"@babel/core": "^7.6.2",
"@babel/plugin-proposal-class-properties": "^7.5.5",
"@babel/preset-env": "^7.6.2",
"@babel/preset-react": "^7.0.0",
"@babel/register": "^7.9.0",
"babel-loader": "^8.0.6",
"chai": "^4.2.0",
"css-loader": "^3.2.0",
"mocha": "^7.1.1",
"node-sass": "^4.13.1",
"sass-loader": "^8.0.0",
"serialize-javascript": "^2.1.2",
"style-loader": "^1.0.0",
"webpack": "^4.41.0",
"webpack-cli": "^3.3.9",
"webpack-dev-server": "^3.8.1"
},
"dependencies": {
"axios": "^0.19.0",
"react": "^16.12.0",
"react-contenteditable": "^3.3.3",
"react-dom": "^16.12.0",
"react-transition-group": "^4.3.0",
"tiny-emitter": "^2.1.0"
}
}

76
src/Selection.js Normal file
View File

@ -0,0 +1,76 @@
import WebAnnotation from './WebAnnotation';
import uuid from 'uuid/v1';
/**
* An "annotation in draft mode". Really the same
* data structure, but as a separate class so we can
* tell things apart properly.
*/
export default class Selection {
constructor(selectors) {
this._stub = {
type: 'Selection',
body: [],
target: {
selector: selectors
}
}
}
/** Creates a copy of this selection **/
clone = opt_props => {
const selectors = this._stub.target.selector.map(s => ({ ...s })); // Clone selectors
const cloned = new Selection(selectors);
if (opt_props)
cloned._stub = { ...cloned._stub, ...opt_props };
return cloned;
}
get type() {
return this._stub.type;
}
get body() {
return this._stub.body;
}
get target() {
return this._stub.target;
}
/** For consistency with WebAnnotation **/
get bodies() {
return this._stub.body;
}
selector = type => {
return this._stub.target.selector.find(t => t.type === type);
}
/** Shorthand for the 'exact' field of the TextQuoteSelector **/
get quote() {
return this.selector('TextQuoteSelector').exact;
}
/*******************************************/
/* Selection-specific properties & methods */
/*******************************************/
get isSelection() {
return true;
}
toAnnotation = () => {
const a = Object.assign({}, this._stub, {
'@context': 'http://www.w3.org/ns/anno.jsonld',
'type': 'Annotation',
'id': uuid()
});
return new WebAnnotation(a);
}
}

102
src/WebAnnotation.js Normal file
View File

@ -0,0 +1,102 @@
export default class WebAnnotation {
constructor(annotation) {
this._annotation = annotation;
}
/** For convenience - creates an empty web annotation **/
static create = args => {
const stub = {
'@context': 'http://www.w3.org/ns/anno.jsonld',
'type': 'Annotation',
'body': []
};
return new WebAnnotation({ ...stub, ...args });
}
/** Creates a copy of this annotation **/
clone = opt_props => {
return new WebAnnotation({ ...this._annotation, ...opt_props});
}
/** An equality check based on the underlying object or (if given) ID **/
isEqual(other) {
if (!other) {
return false;
} else if (this._annotation === other._annotation) {
return true;
} else if (!this._annotation.id || !other._annotation.id) {
return false;
}
return this._annotation.id === other._annotation.id
}
/*************************************/
/* Getters to forward properties of */
/* the underlying annotation */
/*************************************/
get id() {
return this._annotation.id;
}
get type() {
return this._annotation.type;
}
get motivation() {
return this._annotation.motivation;
}
get body() {
return this._annotation.body;
}
get target() {
return this._annotation.target;
}
/** Same as .body, but guaranteed to return an array **/
get bodies() {
return (Array.isArray(this._annotation.body)) ?
this._annotation.body : [ this._annotation.body ];
}
/** Only bodies are meant to be mutated by the application **/
set bodies(bodies) {
this._annotation.body = bodies;
}
/** Same as .target, but guaranteed to return an array **/
get targets() {
return (Array.isArray(this._annotation.target)) ?
this._annotation.target : [ this._annotation.target ];
}
/*****************************************/
/* Various access helpers and shorthands */
/*****************************************/
/** Selector of the given type **/
selector = type => {
return this._annotation.target.selector &&
this._annotation.target.selector.find(t => t.type === type);
}
/** Shorthand for the 'exact' field of the TextQuoteSelector **/
get quote() {
return this.selector('TextQuoteSelector').exact;
}
/** Shorthand for the 'start' field of the TextPositionSelector **/
get start() {
return this.selector('TextPositionSelector').start;
}
/** Shorthand for the 'end' field of the TextPositionSelector **/
get end() {
return this.selector('TextPositionSelector').end;
}
}

115
src/editor/Editor.jsx Normal file
View File

@ -0,0 +1,115 @@
import React, { useState, useRef, useEffect } from 'react';
import setPosition from './setPosition';
import TagWidget from './widgets/tags/TagWidget';
import TypeSelectorWidget from './widgets/type/TypeSelectorWidget';
import CommentWidget from './widgets/comments/CommentWidget';
/**
* The popup editor component.
*
* TODO instead of just updating the current annotation state,
* we could create a stack of revisions, and allow going back
* with CTRL+Z.
*/
const Editor = props => {
// The current state of the edited annotation vs. original
const [ currentAnnotation, setCurrentAnnotation ] = useState();
const [ currentReply, setCurrentReply ] = useState('');
// Reference to the DOM element, so we can set position
const element = useRef();
// Re-render: set derived annotation state & position the editor
useEffect(() => {
setCurrentAnnotation(props.annotation);
setCurrentReply('');
if (element.current)
setPosition(props.containerEl, element.current, props.bounds);
}, [ props.annotation ]);
const onAppendBody = body => setCurrentAnnotation(
currentAnnotation.clone({
body: [ ...currentAnnotation.bodies, body ]
})
);
const onUpdateBody = (previous, updated) => setCurrentAnnotation(
currentAnnotation.clone({
body: currentAnnotation.bodies.map(body => body === previous ? updated : body)
})
);
const onRemoveBody = body => setCurrentAnnotation(
currentAnnotation.clone({
body: currentAnnotation.bodies.filter(b => b !== body)
})
);
const onOk = _ => {
// If there is a non-empty reply, append it as a comment body
const updated = currentReply.trim() ?
currentAnnotation.clone({
body: [ ...currentAnnotation.bodies, { type: 'TextualBody', value: currentReply.trim() } ]
}) : currentAnnotation;
// Current annotation is either a selection (if it was created from
// scratch just now) or an annotation (if it existed already and was
// opened for editing)
if (updated.bodies.length === 0) {
if (updated.isSelection)
props.onCancel();
else
props.onAnnotationDeleted(props.annotation);
} else {
if (updated.isSelection)
props.onAnnotationCreated(updated.toAnnotation());
else
props.onAnnotationUpdated(updated, props.annotation);
}
};
return (
<div ref={element} className="r6o-editor">
<div className="arrow" />
<div className="inner">
<TypeSelectorWidget />
<CommentWidget
annotation={currentAnnotation}
currentReply={currentReply}
onUpdateComment={onUpdateBody}
onDeleteComment={onRemoveBody}
onUpdateReply={evt => setCurrentReply(evt.target.value.trim())}
onOk={onOk} />
<TagWidget
annotation={currentAnnotation}
onAddTag={onAppendBody}
onRemoveTag={onRemoveBody} />
{ props.readOnly ? (
<div className="footer">
<button
className="r6o-btn"
onClick={props.onCancel}>Close</button>
</div>
) : (
<div className="footer">
<button
className="r6o-btn outline"
onClick={props.onCancel}>Cancel</button>
<button
className="r6o-btn "
onClick={onOk}>Ok</button>
</div>
)}
</div>
</div>
)
}
export default Editor;

7
src/editor/index.js Normal file
View File

@ -0,0 +1,7 @@
import Editor from './Editor';
export { default as CommentWidget } from './widgets/comment/CommentWidget';
export { default as TagWidget } from './widgets/tag/TagWidget';
export { default as TagSelectorWidget } from './widgets/type/TypeSelectorWidget';
export default Editor;

35
src/editor/setPosition.js Normal file
View File

@ -0,0 +1,35 @@
/** Sets the editor position and determines a proper orientation **/
const setPosition = (containerEl, editorEl, annotationBounds) => {
// Container element offset
const { offsetLeft, offsetTop } = containerEl;
const { scrollX, scrollY } = window;
// Re-set orientation class
editorEl.className = 'r6o-editor';
// Default orientation
const { x, y, height, top, right } = annotationBounds;
editorEl.style.top = `${y + height + scrollY - offsetTop}px`;
editorEl.style.left = `${x + scrollX - offsetLeft}px`;
const defaultOrientation = editorEl.getBoundingClientRect();
if (defaultOrientation.right > window.innerWidth) {
// Default bounds clipped - flip horizontally
editorEl.classList.add('align-right');
editorEl.style.left = `${right - defaultOrientation.width + scrollX - offsetLeft}px`;
}
if (defaultOrientation.bottom > window.innerHeight) {
// Flip vertically
const annotationTop = top + scrollY; // Annotation top relative to parents
const containerBounds = containerEl.getBoundingClientRect();
const containerHeight = containerBounds.height + containerBounds.top + scrollY;
editorEl.classList.add('align-bottom');
editorEl.style.top = 'auto';
editorEl.style.bottom = `${containerHeight - annotationTop}px`;
}
}
export default setPosition;

View File

@ -0,0 +1,17 @@
import { useEffect } from 'react';
export default function useClickOutside(ref, callback) {
const onClickOutside = _ => {
if (ref.current && !ref.current.contains(event.target))
callback();
}
useEffect(() => {
document.addEventListener('mousedown', onClickOutside);
return () =>
document.removeEventListener('mousedown', onClickOutside);
});
}

View File

@ -0,0 +1,53 @@
import React, { useState } from 'react';
import DropdownMenu from './DropdownMenu';
import TextEntryField from './TextEntryField';
/** A single comment inside the CommentWidget **/
const Comment = props => {
const [ isEditable, setIsEditable ] = useState(false);
const [ isMenuVisible, setIsMenuVisible ] = useState(false);
const onMakeEditable = _ => {
setIsEditable(true);
setIsMenuVisible(false);
}
const onDelete = _ => {
props.onDelete(props.body);
setIsMenuVisible(false);
}
const onUpdateComment = evt => {
props.onUpdate(props.body, { ...props.body, value: evt.target.value });
}
return props.readOnly ? (
<div className="r6o-section comment">
{props.body.value}
</div>
) : (
<div className={ isEditable ? "r6o-section comment editable" : "r6o-section comment"}>
<TextEntryField
editable={isEditable}
content={props.body.value}
onChange={onUpdateComment}
onOk={props.onOk}
/>
<div
className={isMenuVisible ? "icon arrow-down menu-open" : "icon arrow-down"}
onClick={() => setIsMenuVisible(!isMenuVisible)} />
{ isMenuVisible &&
<DropdownMenu
onEdit={onMakeEditable}
onDelete={onDelete}
onClickOutside={() => setIsMenuVisible(false)} />
}
</div>
)
}
export default Comment;

View File

@ -0,0 +1,43 @@
import React from 'react';
import Comment from './Comment';
import TextEntryField from './TextEntryField';
/**
* Renders a list of comment bodies, followed by a 'reply' field.
*/
const CommentWidget = props => {
const commentBodies = props.annotation ?
props.annotation.bodies.filter(b => // No purpose or 'commenting', 'replying'
!b.hasOwnProperty('purpose') || b.purpose === 'commenting' || b.purpose === 'replying'
) : [];
return (
<>
{ commentBodies.map((body, idx) =>
<Comment
key={idx}
readOnly={props.readOnly}
body={body}
onUpdate={props.onUpdateComment}
onDelete={props.onDeleteComment}
onOk={props.onOk} />
)}
{ !props.readOnly && props.annotation &&
<div className="r6o-section comment editable">
<TextEntryField
content={props.currentReply}
editable={true}
placeholder={commentBodies.length > 0 ? 'Add a reply...' : 'Add a comment...'}
onChange={props.onUpdateReply}
onOk={() => props.onOk()}
/>
</div>
}
</>
)
}
export default CommentWidget;

View File

@ -0,0 +1,20 @@
import React, { useRef } from 'react';
import useClickOutside from '../../useClickOutside';
const DropdownMenu = props => {
const ref = useRef();
// Custom hook that notifies when clicked outside this component
useClickOutside(ref, () => props.onClickOutside());
return (
<ul ref={ref} className="comment-dropdown-menu">
<li onClick={props.onEdit}>Edit</li>
<li onClick={props.onDelete}>Delete</li>
</ul>
)
}
export default DropdownMenu;

View File

@ -0,0 +1,38 @@
import React, { Component } from 'react';
import ContentEditable from 'react-contenteditable';
/**
* A basic text entry field, for reuse in different widgets.
*
* Note that react-contenteditable seems to have compatibility
* issues with React hooks, therefore this component is
* implemented as a class.
*/
export default class TextEntryField extends Component {
// CTRL+Enter functions as Ok
onKeyDown = evt => {
if (evt.which === 13 && evt.ctrlKey)
this.props.onOk();
}
// Focus on render
onRender = ref => {
if (ref && this.props.editable)
ref.focus();
}
render() {
return (
<ContentEditable
innerRef={this.onRender}
className="r6o-editable-text"
html={this.props.content}
data-placeholder={this.props.placeholder || "Add a comment..."}
disabled={!this.props.editable}
onChange={this.props.onChange}
onKeyDown={this.onKeyDown} />
)
}
}

View File

@ -0,0 +1,62 @@
import React, { useState, useEffect } from 'react';
import { CSSTransition } from 'react-transition-group';
/** The basic freetext tag control from original Recogito **/
const TagWidget = props => {
const [ showDelete, setShowDelete ] = useState(false);
const [ newTag, setNewTag ] = useState('');
// Every body with a 'tagging' purpose is considered a tag
const tagBodies = props.annotation ?
props.annotation.bodies.filter(b => b.purpose === 'tagging') : [];
const toggle = tag => _ => {
if (showDelete === tag) // Removes delete button
setShowDelete(false);
else
setShowDelete(tag); // Sets delete button on a different tag
}
const onDelete = tag => evt => {
evt.stopPropagation();
props.onRemoveTag(tag);
}
const onKeyDown = evt => {
if (evt.which === 13) { // Enter
props.onAddTag({ type: 'TextualBody', purpose: 'tagging', value: newTag.trim() });
setNewTag(''); // Clear the input
}
}
return (
<div className="tags">
<ul>
{ tagBodies.map(tag =>
<li key={tag.value} onClick={toggle(tag.value)}>
<span className="label">{tag.value}</span>
<CSSTransition in={showDelete === tag.value} timeout={200} classNames="delete">
<span className="delete-wrapper" onClick={onDelete(tag)}>
<span className="delete">
<span className="icon">{'\uf014'}</span>
</span>
</span>
</CSSTransition>
</li>
)}
</ul>
<input
type="text"
value={newTag}
onChange={evt => setNewTag(evt.target.value)}
onKeyDown={onKeyDown}
placeholder="Add tag..." />
</div>
)
};
export default TagWidget;

View File

@ -0,0 +1,30 @@
import React from 'react';
/**
* The basic Place/Person/Event selector from original Recogito
*/
const TypeSelectorWidget = props => {
const onSelect = type => _ => {
props.onSelect && props.onSelect(type);
}
return (
<div className="type-selector">
<div className="type place" onClick={onSelect('PLACE')}>
<span className="icon">{'\uf041'}</span> Place
</div>
<div className="type person" onClick={onSelect('PERSON')}>
<span className="icon">{'\uf007'}</span> Person
</div>
<div className="type event" onClick={onSelect('EVENT')}>
<span className="icon">{'\uf005'}</span> Event
</div>
</div>
)
}
export default TypeSelectorWidget;

2
src/index.js Normal file
View File

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

View File

@ -0,0 +1,261 @@
import WebAnnotation from '../annotation/WebAnnotation';
const TEXT = 3; // HTML DOM node type for text nodes
const uniqueItems = items => Array.from(new Set(items))
export default class Highlighter {
constructor(element, formatter) {
this.el = element;
this.formatter = formatter;
}
init = annotations => {
// TODO - there are several performance optimzations that are not yet ported
// across from Recogito
annotations
// Discard all annotations without a TextPositionSelector
.filter(annotation => annotation.selector('TextPositionSelector'))
.forEach(annotation => this._addAnnotation(annotation));
}
_addAnnotation = annotation => {
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);
}
_findAnnotationSpans = annotation => {
// TODO index annotation to make this faster
const allAnnotationSpans = document.querySelectorAll('.annotation');
return Array.prototype.slice.call(allAnnotationSpans)
.filter(span => span.annotation.isEqual(annotation));
}
getAllAnnotations = () => {
// TODO index annotation to make this faster
const allAnnotationSpans = document.querySelectorAll('.annotation');
return Array.prototype.slice.call(allAnnotationSpans)
.map(span => span.annotation);
}
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();
}
}
_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 = `annotation ${extraClasses}`.trim());
}
bindAnnotation = (annotation, elements) => {
elements.forEach(el => {
el.annotation = annotation;
if (annotation.id)
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('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

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

View File

@ -0,0 +1,113 @@
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 { x, y } = offsetContainer.getBoundingClientRect();
const left = Math.round(clientBounds.left - x);
const top = Math.round(clientBounds.top - y);
return {
left : left,
top : top,
right : Math.round(left + clientBounds.width),
bottom: Math.round(top + 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) {
a.x = Math.min(a.x, b.x);
a.left = Math.min(a.left, b.left);
a.width = a.width + b.width;
a.right = Math.max(a.right + b.right);
};
if (previous) {
if (isConsecutive(previous, bbox))
extend(previous, bbox);
else
merged.push(bbox);
} else {
merged.push(bbox);
}
return merged;
}, []);
}

View File

@ -0,0 +1,248 @@
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; // Temporary hack
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

@ -0,0 +1,79 @@
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', '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

@ -0,0 +1,55 @@
/**
* 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('.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(`.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('annotation') ?
getAnnotationSpansRecursive(parent, spans) : spans;
}

View File

@ -0,0 +1,135 @@
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.classList.add('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', 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();
}
}
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));
}
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.classList.add('readonly');
else
this.svg.classList.remove('readonly');
}
}

View File

@ -0,0 +1,69 @@
// 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.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

@ -0,0 +1,29 @@
// 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

@ -0,0 +1,182 @@
import Connection from '../Connection';
import HoverEmphasis from './HoverEmphasis';
import { getNodeForEvent } from '../RelationUtils';
import EventEmitter from 'tiny-emitter';
import { WebAnnotation } from 'recogito-client-common';
/**
* 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('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('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 { x, y } = this.contentEl.getBoundingClientRect();
this.currentConnection.dragTo([ evt.pageX - x, evt.pageY - y]);
}
}
}
/**
* 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('.annotation', evt => {
if (this.currentHover)
this.hover();
this.hover(getNodeForEvent(evt).elements);
});
/** Clear hover emphasis **/
onLeaveAnnotation = delegatingHandler('.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

@ -0,0 +1,35 @@
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

@ -0,0 +1,115 @@
import React, { Component } from 'react';
import ContentEditable from 'react-contenteditable';
/**
* 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.
*
* Note that react-contenteditable seems to have compatibility
* issues with React hooks, therefore this component is implemented
* as a class.
*/
export default class RelationEditor extends Component {
constructor(props) {
super(props);
this.state = {
content: getContent(props.relation)
}
this.element = React.createRef();
}
componentDidMount() {
this.setPosition();
}
componentDidUpdate() {
this.setPosition();
}
componentWillReceiveProps(next) {
if (this.props.relation !== next.relation)
this.setState({ content : getContent(next.relation) });
}
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`;
setTimeout(() => el.querySelector('.input').focus(), 0);
}
}
onChange = evt =>
this.setState({ content: evt.target.value });
onKeyDown = evt => {
if (evt.which === 13) { // Enter = Submit
evt.preventDefault();
this.onSubmit();
} else if (evt.which === 27) {
this.props.onCancel();
}
}
onSubmit = () => {
const updatedAnnotation = this.props.relation.annotation.clone({
body: [{
type: 'TextualBody',
value: this.state.content,
purpose: 'tagging'
}]
});
const updatedRelation = { ...this.props.relation, annotation: updatedAnnotation };
// Return updated/before
this.props.onRelationUpdated(updatedRelation, this.props.relation);
}
onDelete = () =>
this.props.onRelationDeleted(this.props.relation);
render() {
return(
<div className="r6o-relation-editor" ref={this.element}>
<ContentEditable
className="input"
html={this.state.content}
data-placeholder="Tag..."
onChange={this.onChange}
onKeyDown={this.onKeyDown}
/>
<div className="buttons">
<span
className="icon delete"
onClick={this.onDelete}>{'\uf014'}</span>
<span
className="icon ok"
onClick={this.onSubmit}>{'\uf00c'}</span>
</div>
</div>
)
}
}

View File

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

View File

@ -0,0 +1,95 @@
import { trimRange, rangeToSelection, enableTouch } from './SelectionUtils';
import EventEmitter from 'tiny-emitter';
const IS_TOUCH = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
export default class SelectionHandler extends EventEmitter {
constructor(element, highlighter) {
super();
this.el = element;
this.highlighter = highlighter;
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('.annotation');
if (annotationSpan) {
this.emit('select', {
selection: this.highlighter.getAnnotationsAt(annotationSpan)[0],
clientRect: annotationSpan.getBoundingClientRect()
});
} else {
// De-select
this.emit('select', {});
}
} else {
const selectedRange = trimRange(selection.getRangeAt(0));
const stub = rangeToSelection(selectedRange, this.el);
const clientRect = selectedRange.getBoundingClientRect();
const spans = this.highlighter.wrapRange(selectedRange);
spans.forEach(span => span.className = 'selection');
this._clearNativeSelection();
this.emit('select', {
selection: stub,
clientRect
});
}
}
}
_clearNativeSelection = () => {
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();
}
}
clearSelection = () => {
this._currentSelection = null;
const spans = Array.prototype.slice.call(this.el.querySelectorAll('.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

@ -0,0 +1,79 @@
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([{
type: 'TextQuoteSelector',
exact: quote
}, {
type: 'TextPositionSelector',
start: start,
end: start + quote.length
}]);
};
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

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

49
src/utils/index.js Normal file
View File

@ -0,0 +1,49 @@
/**
* 'Deflates' the HTML contained in the given parent node.
* Deflation will completely drop empty text nodes, and replace
* multiple spaces, tabs, newlines with a single space. This way,
* character offsets in the markup will more closely represent
* character offsets experienced in the browser.
*/
export const deflateHTML = parent => {
deflateNodeList([ parent ]);
return parent;
}
const deflateNodeList = parents => {
// Deflates the immediate children of one parent (but not children of children)
const deflateOne = parent => {
return Array.from(parent.childNodes).reduce((compacted, node) => {
if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent.trim().length > 0) {
// Text node - trim
const trimmed = node.textContent.replace(/\s\s+/g, ' ');
return [...compacted, document.createTextNode(trimmed)];
} else {
// Empty text node - discard
return compacted;
}
} else {
return [...compacted, node];
}
}, []);
}
// Replace original children with deflated
parents.forEach(parent => {
const deflatedChildren = deflateOne(parent);
parent.innerHTML = '';
deflatedChildren.forEach(node => parent.appendChild(node));
});
// Then, get all children that have more children
const childrenWithChildren = parents.reduce((childrenWithChildren, parent) => {
return childrenWithChildren.concat(Array.from(parent.childNodes).filter(c => c.firstChild));
}, []);
// Recursion
if (childrenWithChildren.length > 0)
deflateNodeList(childrenWithChildren);
}

67
test/WebAnnotationTest.js Normal file
View File

@ -0,0 +1,67 @@
import assert from 'assert';
import WebAnnotation from "../src/WebAnnotation";
const fixtureAnnotation = {
"@context": "http://www.w3.org/ns/anno.jsonld",
type: "Annotation",
body: [
{
type: "TextualBody",
value: "This annotation was added via JS."
}
],
target: {
selector: [
{
type: "TextQuoteSelector",
exact: "that ingenious hero"
},
{
type: "TextPositionSelector",
start: 38,
end: 57
}
]
}
};
describe("WebAnnotation", function() {
describe("#isEqual()", function() {
it("should return true if the other is the same object", () => {
const a = new WebAnnotation(fixtureAnnotation);
const b = new WebAnnotation(fixtureAnnotation);
assert(a.isEqual(b));
});
it("should return false if either annotation has no ID set", () => {
const a = new WebAnnotation({
...fixtureAnnotation,
id: "https://www.example.com/anno1"
});
const b = new WebAnnotation({ ...fixtureAnnotation });
assert.strictEqual(a.isEqual(b), false);
assert.strictEqual(b.isEqual(a), false);
});
it("should return true iff annotation IDs do match", () => {
const a = new WebAnnotation({
...fixtureAnnotation,
id: "https://www.example.com/anno1"
});
const b = new WebAnnotation({
...fixtureAnnotation,
id: "https://www.example.com/anno2"
});
const c = new WebAnnotation({
...fixtureAnnotation,
id: "https://www.example.com/anno1",
body: [
{
type: "TextualBody",
value: "foobar"
}
]
});
assert.strictEqual(a.isEqual(b), false);
assert.strictEqual(a.isEqual(c), true);
});
});
});

View File

@ -0,0 +1,81 @@
@import "widgets/comment/comment";
@import "widgets/tag/tag";
@import "widgets/type/typeSelector";
.r6o-editor {
position:absolute;
z-index:99999;
margin-top:18px;
margin-left:-14px;
width:400px;
color:$standard-type;
.arrow {
position:absolute;
overflow:hidden;
top:-12px;
left:12px;
width:28px;
height:12px;
}
.arrow:after {
content:'';
position:absolute;
top:5px;
left:5px;
width:18px;
height:18px;
background-color:#fff;
@include rotate(45deg);
}
.inner {
background-color:#fff;
@include rounded-corners(2px);
@include box-shadow(2px, 2px, 42px, 0.4);
.r6o-section:first-child {
@include rounded-corners-top(2px);
}
.r6o-section {
border-bottom:1px solid $lightgrey-border-darker;
}
}
.footer {
text-align:right;
padding:8px 0;
.r6o-btn {
margin-right:8px;
}
}
}
.r6o-editor.align-right {
margin-left:8px;
.arrow {
left:auto;
right:12px;
}
}
.r6o-editor.align-bottom {
margin-bottom:14px;
.arrow {
top:auto;
bottom:-12px;
}
.arrow::after {
top:-11px;
box-shadow:none;
}
}

View File

@ -0,0 +1,13 @@
.r6o-editable-text {
padding:8px 10px;
max-height:120px;
overflow:auto;
outline:none;
min-height:3em;
font-size:14px;
}
.r6o-editable-text:empty:not(:focus):before {
content:attr(data-placeholder);
color:#c2c2c2;
}

View File

@ -0,0 +1,61 @@
.r6o-section.comment {
font-size:14px;
min-height:3em;
background-color:#fff;
position:relative;
.arrow-down {
position:absolute;
top:5px;
right:5px;
height: 20px;
width: 20px;
line-height: 14px;
font-size: 18px;
background-color:#fff;
text-align: center;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
border: 1px solid $lightgrey-border;
top: 9px;
right: 9px;
cursor: pointer;
@include rounded-corners(1px);
}
.arrow-down::before {
content:'\2304';
}
.arrow-down.menu-open {
border-color:$ocean;
}
.comment-dropdown-menu {
position:absolute;
top:32px;
right:8px;
background-color:#fff;
border:1px solid $lightgrey-border;
list-style-type:none;
margin:0;
padding:5px 0;
z-index:9999;
@include box-shadow(0, 2px, 4px, 0.2);
li {
padding:0 15px;
cursor:pointer;
}
li:hover {
background-color:$blueish-white;
}
}
}
.r6o-section.comment.editable {
background-color:$blueish-white;
}

View File

@ -0,0 +1,89 @@
.tags {
background-color:$blueish-white;
border-bottom:1px solid $lightgrey-border;
padding:1px 3px;
ul, li {
padding:0;
margin:0;
display:inline;
}
li {
display:inline-block;
margin:1px 1px 1px 0;
padding:0;
vertical-align:middle;
overflow:hidden;
font-size:12px;
background-color:#fff;
border:1px solid $lightgrey-border-darker;
cursor:pointer;
position:relative;
line-height:180%;
@include noselect();
@include rounded-corners(2px);
@include box-shadow(0, 0, 4px, 0.1);
.label {
padding:2px 8px;
display:inline-block;
}
.delete-wrapper {
display:inline-block;
padding:2px 0;
color:#fff;
width:0;
height:100%;
background-color:$ocean;
vertical-align:top;
.delete {
padding:2px 6px;
.icon {
height:11px;
padding-bottom:1px;
}
}
}
.delete-enter-active {
width:24px;
transition:width 200ms;
}
.delete-enter-done {
width:24px;
}
.delete-exit {
width:24px;
}
.delete-exit-active {
width:0;
transition:width 200ms;
}
}
input {
padding:0 3px;
min-width:80px;
outline:none;
border:none;
line-height:170%;
background-color:transparent;
color:$standard-type;
}
input::-webkit-input-placeholder { color:#c2c2c2; }
input::-moz-placeholder { color:#c2c2c2; }
input:-moz-placeholder { color:#c2c2c2; }
input:-ms-input-placeholder { color:#c2c2c2; }
}

View File

@ -0,0 +1,30 @@
.type-selector {
display:flex;
border-bottom:1px solid $lightgrey-border;
.type {
flex:1;
text-align:center;
padding:7px;
font-size:14px;
}
.type {
cursor:pointer;
border-right:1px solid $lightgrey-border;
}
.type:last-child {
border-right:none;
}
.type:hover {
background-color:$blueish-white-hi;;
}
.icon {
margin:0 3px 1px 0;
height:13px;
}
}

View File

@ -0,0 +1,4 @@
@import "includes/colors";
@import "includes/fonts";
@import "includes/mixins";
@import "includes/buttons";

View File

@ -0,0 +1,36 @@
.r6o-btn {
background-color:$ocean;
border:1px solid $ocean;
box-sizing:border-box;
color:#fff;
cursor:pointer;
display:inline-block;
font-size:14px;
margin:0;
outline:none;
text-decoration:none;
white-space:nowrap;
padding:6px 18px;
min-width:70px;
vertical-align:middle;
@include rounded-corners(2px);
* { vertical-align:middle; }
.icon { margin-right:4px; }
}
.r6o-btn:disabled {
border-color:$ocean-disabled !important;
background-color:$ocean-disabled !important;
}
.r6o-btn:hover {
background-color:$ocean-hover;
border-color:$ocean-hover;
}
.r6o-btn.outline {
border:1px solid $ocean;
color:$ocean;
background-color:transparent;
text-shadow:none;
}

View File

@ -0,0 +1,30 @@
/** ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **/
/** Base background colors **/
/** ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **/
// Recogito's light blue signature colour
$ocean:#4483c4;
// Slightly lighter version for hover effects on ocean-coloured buttons
$ocean-hover:#4f92d7;
// 'Pale' version of ocean for disabled buttons
$ocean-disabled:#a3c2e2;
// Pale blue used as alternative background colour
$blueish-white:#ecf0f1;
$blueish-white-hi:lighten($blueish-white, 3%);
// Used for accent borders on white background - e.g. annotation area header
$lightgrey-border:#e5e5e5;
$lightgrey-border-darker:#d6d7d9;
/** ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **/
/** Type colors **/
/** ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **/
// Standard 'black' (well, not quite) type
$standard-type:#3f3f3f;
// Icons, links, emphasized type on white background - e.g. in annotation area header
$lightblue-type:#9ca4b1;

View File

@ -0,0 +1,6 @@
@font-face {
font-family:'FontAwesome';
src:url('/fontawesome-webfont.ttf');
font-weight:normal;
font-style:normal;
}

View File

@ -0,0 +1,63 @@
@mixin rounded-corners($radius) {
-webkit-border-radius:$radius;
-khtml-border-radius:$radius;
-moz-border-radius:$radius;
border-radius:$radius;
}
@mixin rounded-corners-top($radius) {
-webkit-border-top-left-radius:$radius;
-webkit-border-top-right-radius:$radius;
-khtml-border-radius-topleft:$radius;
-khtml-border-radius-topright:$radius;
-moz-border-radius-topleft:$radius;
-moz-border-radius-topright:$radius;
border-top-left-radius:$radius;
border-top-right-radius:$radius;
}
@mixin rounded-corners-left($radius) {
-webkit-border-top-left-radius:$radius;
-webkit-border-bottom-left-radius:$radius;
-khtml-border-radius-topleft:$radius;
-khtml-border-radius-bottomleft:$radius;
-moz-border-radius-topleft:$radius;
-moz-border-radius-bottomleft:$radius;
border-top-left-radius:$radius;
border-bottom-left-radius:$radius;
}
@mixin rounded-corners-right($radius) {
-webkit-border-top-right-radius:$radius;
-webkit-border-bottom-right-radius:$radius;
-khtml-border-radius-topright:$radius;
-khtml-border-radius-bottomright:$radius;
-moz-border-radius-topright:$radius;
-moz-border-radius-bottomright:$radius;
border-top-right-radius:$radius;
border-bottom-right-radius:$radius;
}
@mixin box-shadow($x, $y, $radius, $opacity) {
-webkit-box-shadow:$x $y $radius rgba(0, 0, 0, $opacity);
-moz-box-shadow:$x $y $radius rgba(0, 0, 0, $opacity);
box-shadow:$x $y $radius rgba(0, 0, 0, $opacity);
}
@mixin rotate($angle) {
-webkit-backface-visibility:hidden;
-webkit-transform:rotate($angle);
-moz-transform:rotate($angle);
-ms-transform:rotate($angle);
-o-transform:rotate($angle);
transform:rotate($angle);
}
@mixin noselect() {
-webkit-touch-callout:none;
-webkit-user-select:none;
-khtml-user-select:none;
-moz-user-select:none;
-ms-user-select:none;
user-select:none;
}

View File

@ -0,0 +1,10 @@
.annotation {
background-color:#ffa50033;
border-bottom:2px solid orange;
cursor:pointer;
}
.selection {
background-color:#cfcfffa1;
cursor:pointer;
}

View File

@ -0,0 +1,69 @@
.r6o-relation-editor {
position:absolute;
@include box-shadow(0, 1px, 14px, 0.4);
@include rounded-corners(3px);
transform:translate(-50%, -50%);
background-color:#fff;
* {
line-height:31px;
box-sizing:border-box;
}
.input {
height:34px;
display:block;
padding:1px 6px;
margin-right:68px;
font-size:15px;
min-width:80px;
outline:none;
background-color:#fff;
cursor:text;
@include rounded-corners-left(3px);
}
.input:empty:before {
content:attr(data-placeholder);
color:darken($lightgrey-border-darker, 15%);
}
.buttons {
position:absolute;
display:inline-flex;
top:0;
right:0;
span {
height:34px;
display:inline-block;
width:34px;
text-align:center;
font-size:14px;
cursor:pointer;
padding:1px 0;
}
.delete {
background-color:#fff;
color:$standard-type;
border-left:1px solid $lightgrey-border;
}
.delete:hover {
background-color:#f6f6f6;
}
.ok {
background-color:$ocean;
color:#fff;
@include rounded-corners-right(3px);
}
.ok:hover {
background-color:$ocean-hover;
}
}
}

18
themes/default/theme.scss Normal file
View File

@ -0,0 +1,18 @@
@import "globals/globals";
@import "highlights/highlights";
@import "editor/editor";
@import "relations/editor";
.icon {
font-size:14px;
font-family:FontAwesome;
}
.noselect {
-webkit-touch-callout:none;
-webkit-user-select:none;
-khtml-user-select:none;
-moz-user-select:none;
-ms-user-select:none;
user-select:none;
}

42
webpack.config.js Normal file
View File

@ -0,0 +1,42 @@
const path = require('path');
const fs = require('fs');
const APP_DIR = fs.realpathSync(process.cwd());
const resolveAppPath = relativePath => path.resolve(APP_DIR, relativePath);
module.exports = {
entry: resolveAppPath('src'),
performance: {
hints: false
},
resolve: {
extensions: ['.js', '.jsx'],
alias: {
'themes': resolveAppPath('themes/default')
}
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
use: {
loader: 'babel-loader' ,
options: {
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": [
[
"@babel/plugin-proposal-class-properties"
]
]
}
}
},
{ test: /\.css$/, use: [ 'style-loader', 'css-loader'] },
{ test: /\.scss$/, use: [ 'style-loader', 'css-loader', 'sass-loader' ] }
]
}
}