Initial import
This commit is contained in:
commit
f155c5751d
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"presets": [
|
||||
"@babel/preset-env"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-class-properties"
|
||||
]
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
node_modules
|
|
@ -0,0 +1,3 @@
|
|||
# recogito-client-core
|
||||
|
||||
Core functions, classes and components for RecogitoJS.
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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} />
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
export { default as WebAnnotation } from './WebAnnotation';
|
||||
export { default as Selection } from './Selection';
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { default as Highlighter } from './Highlighter';
|
|
@ -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;
|
||||
}, []);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import EventEmitter from 'tiny-emitter';
|
||||
import CONST from './SVGConst';
|
||||
|
||||
const escapeHtml = unsafe => {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export default class Handle extends EventEmitter {
|
||||
|
||||
constructor(label, svg) {
|
||||
super();
|
||||
|
||||
this.svg = svg;
|
||||
|
||||
this.g = document.createElementNS(CONST.NAMESPACE, 'g');
|
||||
this.text = document.createElementNS(CONST.NAMESPACE, 'text');
|
||||
this.rect = document.createElementNS(CONST.NAMESPACE, 'rect');
|
||||
this.arrow = document.createElementNS(CONST.NAMESPACE, 'path');
|
||||
|
||||
// Append first and init afterwards, so we can query text width/height
|
||||
this.g.appendChild(this.rect);
|
||||
this.g.appendChild(this.text);
|
||||
this.g.appendChild(this.arrow);
|
||||
this.svg.appendChild(this.g);
|
||||
|
||||
this.g.setAttribute('class', 'handle');
|
||||
|
||||
this.text.innerHTML = escapeHtml(label);
|
||||
|
||||
this.bounds = this.text.getBBox();
|
||||
|
||||
this.text.setAttribute('dy', 2);
|
||||
this.text.setAttribute('dx', - Math.round(this.bounds.width / 2));
|
||||
|
||||
this.rect.setAttribute('rx', 2); // Rounded corners
|
||||
this.rect.setAttribute('ry', 2);
|
||||
this.rect.setAttribute('width', Math.round(this.bounds.width) + 5);
|
||||
this.rect.setAttribute('height', Math.round(this.bounds.height));
|
||||
|
||||
this.arrow.setAttribute('class', '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);
|
||||
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export { default as RelationsLayer } from './RelationsLayer';
|
||||
export { default as RelationEditor } from './editor/RelationEditor';
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { default as SelectionHandler } from './SelectionHandler';
|
|
@ -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);
|
||||
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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; }
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
@import "includes/colors";
|
||||
@import "includes/fonts";
|
||||
@import "includes/mixins";
|
||||
@import "includes/buttons";
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,6 @@
|
|||
@font-face {
|
||||
font-family:'FontAwesome';
|
||||
src:url('/fontawesome-webfont.ttf');
|
||||
font-weight:normal;
|
||||
font-style:normal;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
.annotation {
|
||||
background-color:#ffa50033;
|
||||
border-bottom:2px solid orange;
|
||||
cursor:pointer;
|
||||
}
|
||||
|
||||
.selection {
|
||||
background-color:#cfcfffa1;
|
||||
cursor:pointer;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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' ] }
|
||||
]
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue