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