@teipublisher/pb-components
Version:
Collection of webcomponents underlying TEI Publisher
1,200 lines (1,093 loc) • 37.7 kB
JavaScript
import '@polymer/paper-icon-button';
import { css, html } from "lit-element";
import tippy from 'tippy.js';
import uniqolor from "uniqolor/src/index";
import { PbView } from "./pb-view.js";
import { loadTippyStyles } from "./pb-popover.js";
import { get as i18n } from './pb-i18n.js';
/**
* Return the first child of ancestor which contains current.
* Used to adjust nested anchor points.
*
* @param {Node} current the anchor node
* @param {Node} ancestor the context ancestor node
* @returns {Node} first child of ancestor containing current
*/
function extendRange(current, ancestor) {
let parent = current;
while (parent.parentNode !== ancestor) {
parent = parent.parentElement;
}
return parent;
}
/**
* Check if the nodeToCheck should be ignored when computing offsets.
* Applies e.g. to footnote markers.
*
* @param {Node} nodeToCheck the node to check
* @returns true if node should be ignored
*/
function isSkippedNode(nodeToCheck) {
let node = nodeToCheck;
if (node.nodeType === Node.TEXT_NODE) {
node = node.parentNode;
}
const href = /** @type {Element} */ (node).getAttribute('href');
return href && /^#fn_.*$/.test(href);
}
/**
* For a given HTML node, compute the number of characters from the start
* of the parent element.
*
* @param {Node} node the node for which to compute an absolute offset
* @param {Number} offset start offset
* @returns {Number} absolute offset
*/
function absoluteOffset(container, node, offset) {
const walker = document.createTreeWalker(container);
walker.currentNode = node;
while (walker.previousNode()) {
const sibling = walker.currentNode;
if (!(sibling.nodeType === Node.ELEMENT_NODE || isSkippedNode(sibling))) {
// eslint-disable-next-line no-param-reassign
offset += sibling.textContent.length;
}
}
return offset;
}
/**
* Convert the start or end boundary of a browser range by computing
* the number of characters from the start of the parent element.
*
* @param {Node} node input node
* @param {Number} offset offset relative to the parent element
* @returns
*/
function rangeToPoint(node, offset, position = 'start') {
if (node.nodeType === Node.ELEMENT_NODE) {
const container = /** @type {Element} */ (node).closest('[data-tei]');
if (offset === 0) {
return {
parent: container.getAttribute('data-tei'),
offset: 0,
};
}
const child = container.childNodes[offset];
return {
parent: container.getAttribute('data-tei'),
offset: position === 'end' ? absoluteOffset(container, child, 0) - 1 : absoluteOffset(container, child, 0),
};
}
const container = /** @type {Element} */ (node.parentNode).closest('[data-tei]');
if (container) {
return {
parent: container.getAttribute('data-tei'),
offset: absoluteOffset(container, node, offset),
};
} else {
console.error('No container with data-tei found for %o', node.parentNode);
}
}
function ancestors(node, selector) {
let count = 0;
let parent = node.parentNode;
while (parent && parent !== node.getRootNode()) {
if (parent.classList.contains(selector)) {
count += 1;
}
parent = parent.parentNode;
}
return count;
}
/**
* Find the next text node after the current node.
* Descends into elements.
*
* @param {Node} node the current node
* @returns next text node or the current node if none is found
*/
function nextTextNode(context, node) {
const walker = document.createTreeWalker(context, NodeFilter.SHOW_TEXT);
walker.currentNode = node;
if (walker.nextNode()) {
return walker.currentNode;
}
return node;
}
/**
* Convert a point given as number of characters from the start of the container element
* to a coordinate relative to a DOM element.
*
* @param {Node} container the container element
* @param {*} offset absolute offset
* @returns
*/
function pointToRange(container, offset) {
let relOffset = offset;
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
// skip footnote links and empty text nodes (chrome sometimes inserts those)
if (!isSkippedNode(walker.currentNode) && walker.currentNode.textContent.length > 0) {
if (relOffset - walker.currentNode.textContent.length <= 0) {
return [walker.currentNode, relOffset];
}
relOffset -= walker.currentNode.textContent.length;
}
}
return null;
}
function kwicText(str, start, end, words = 3) {
let p0 = start - 1;
let count = 0;
while (p0 >= 0) {
if (/[\p{P}\s]/.test(str.charAt(p0))) {
while (p0 > 1 && /[\p{P}\s]/.test(str.charAt(p0 - 1))) {
p0 -= 1;
}
count += 1;
if (count === words) {
break;
}
}
p0 -= 1;
}
let p1 = end + 1;
count = 0;
while (p1 < str.length) {
if (/[\p{P}\s]/.test(str.charAt(p1))) {
while (p1 < str.length - 1 && /[\p{P}\s]/.test(str.charAt(p1 + 1))) {
p1 += 1;
}
count += 1;
if (count === words) {
break;
}
}
p1 += 1;
}
return `... ${str.substring(p0, start)}<mark>${str.substring(start, end)}</mark>${str.substring(end, p1 + 1)} ...`;
}
function collectText(node) {
let parent = node.parentElement;
if (parent.textContent.length < 40) {
parent = parent.parentNode;
}
const walker = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
let offset = 0;
let start = 0;
const str = [];
while (walker.nextNode()) {
if (walker.currentNode === node) {
start = offset;
}
offset += walker.currentNode.textContent.length;
str.push(walker.currentNode.textContent);
}
return [str.join(''), start];
}
function clearProperties(teiRange) {
const cleaned = {};
Object.keys(teiRange.properties).forEach((key) => {
const val = teiRange.properties[key];
if (val && val.length > 0) {
cleaned[key] = val;
}
});
return Object.assign(teiRange, { properties: cleaned });
}
/**
* An extended `PbView`, which supports annotations to be added
* and edited by the user. Handles mouse selection and keeps track
* of the annotations made.
*
* Interaction with the actual editing form is entirely done via events.
* The class itself does not provide any editing facility, except for
* handling deletions.
*
* @fires pb-annotations-loaded - fired after text was loaded and annotations were drawn
* @fires pb-selection-changed - fired when user selects text
* @fires pb-annotations-changed - fired when an annotation was added or changed
* @fires pb-annotation-detail - fired to request additional details about an annotation
* @fires pb-disable - if received, disables selection tracking, suppressing pb-selection-changed events
* @fires pb-enable - re-enables selection tracking
*/
class PbViewAnnotate extends PbView {
static get properties() {
return {
/**
* Configures the default annotation property containing the key for authority entries.
* Default: 'ref', corresponding to TEI attribute @ref. Change to 'corresp' or 'key' when
* using those attributes instead.
*
* You can also define a custom mapping of annotation types to key properties, e.g. if you would
* like to use @key for some elements, but @corresp for others.
*/
key: {
type: String
},
/**
* Optional mapping of annotation type names to key properties
*/
keyMap: {
type: Object,
attribute: 'key-map'
},
/**
* When searching the displayed text for other potential occurrences of an entity,
* should the search be done in case-sensitive manner?
*/
caseSensitive: {
type: Boolean
},
...super.properties,
};
}
constructor() {
super();
this.key = 'ref';
this.keyMap = {};
this.caseSensitive = false;
this._ranges = [];
this._rangesMap = new Map();
this._history = [];
this._disabled = false;
}
connectedCallback() {
super.connectedCallback();
let isMouseDown = false;
this._inHandler = false;
this._pendingCallback = null;
const scheduleCallback = (delay = 10) => {
this._pendingCallback = setTimeout(() => {
this._selectionChanged();
}, delay);
};
/** @param {Event} event */
this._eventHandler = event => {
if (event.type === 'selectionchange' || this._inHandler) {
return;
}
if (event.type === 'mousedown') {
isMouseDown = true;
}
if (event.type === 'mouseup') {
isMouseDown = false;
}
// If the user makes a selection with the mouse, wait until they release
// it before reporting a selection change.
if (isMouseDown) {
return;
}
this._cancelPendingCallback();
// Schedule a notification after a short delay. The delay serves two
// purposes:
//
// - If this handler was called as a result of a 'mouseup' event then the
// selection will not be updated until the next tick of the event loop.
// In this case we only need a short delay.
//
// - If the user is changing the selection with a non-mouse input (eg.
// keyboard or selection handles on mobile) this buffers updates and
// makes sure that we only report one when the update has stopped
// changing. In this case we want a longer delay.
const delay = event.type === 'mouseup' ? 10 : 100;
scheduleCallback(delay);
};
document.addEventListener('selectionchange', this._eventHandler.bind(this));
this.shadowRoot.addEventListener('mousedown', this._eventHandler.bind(this));
this.shadowRoot.addEventListener('mouseup', this._eventHandler.bind(this));
this.subscribeTo('pb-add-annotation', ev => this.addAnnotation(ev.detail));
this.subscribeTo('pb-edit-annotation', this._editAnnotation.bind(this));
this.subscribeTo('pb-refresh', () => {
this._ranges = [];
this._rangesMap.clear();
this._currentSelection = null;
this._clearMarkers();
this.emitTo('pb-annotations-changed', { ranges: this._ranges, refresh: true });
});
this.addEventListener('pb-disable', () => { this._disabled = true; });
this.addEventListener('pb-enable', () => { this._disabled = false; });
this._resizeHandler();
}
get annotations() {
return this._ranges;
}
set annotations(annoData) {
this._ranges = annoData;
this.updateAnnotations(true);
this._markIncompleteAnnotations();
this._initAnnotationColors();
this._annotationStyles();
}
saveHistory() {
this._history.push(JSON.stringify(this._ranges));
this.emitTo('pb-annotations-history', this._history);
}
getHistory() {
return this._history;
}
popHistory() {
if (this._history.length === 0) {
console.warn('<pb-view-annotate> history is empty');
return;
}
this._scrollTop = this.scrollTop;
const lastEntry = this._history.pop();
this._clearMarkers();
this._ranges = JSON.parse(lastEntry);
this._rangesMap.clear();
this._refresh();
this.emitTo('pb-annotations-changed', { ranges: this._ranges });
this.emitTo('pb-annotations-history', this._history);
}
clearHistory(data) {
this._history = data || [];
}
firstUpdated() {
super.firstUpdated();
this.enableScrollbar(false);
loadTippyStyles(this.shadowRoot, 'light-border');
}
render() {
return [...super.render(), html`<div id="marker-layer"></div>`];
}
zoom(direction) {
super.zoom(direction);
window.requestAnimationFrame(() => this.refreshMarkers());
}
getKey(type) {
return this.keyMap[type] || this.key;
}
_resizeHandler() {
let _pendingCallback = null;
const scheduleCallback = () => {
_pendingCallback = setTimeout(() => {
_pendingCallback = null;
this.refreshMarkers();
}, 200);
};
window.addEventListener('resize', () => {
if (!_pendingCallback) {
this._clearMarkers();
}
if (_pendingCallback) {
clearTimeout(_pendingCallback);
}
scheduleCallback();
});
}
_refresh(ev) {
super._refresh(ev);
if (ev && ev.detail && ev.detail.preserveScroll) {
this._scrollTop = this.scrollTop;
}
}
_handleContent() {
super._handleContent();
this.updateComplete.then(() => setTimeout(() => {
this._initAnnotationColors();
this._annotationStyles();
this.updateAnnotations();
this._markIncompleteAnnotations();
if (this._scrollTop) {
this.scrollTop = this._scrollTop;
this._scrollTop = undefined;
}
this.emitTo('pb-annotations-loaded');
}, 300));
}
_updateAnnotation(teiRange, silent = false, batch = false) {
const view = this.shadowRoot.getElementById('view');
const context = Array.from(view.querySelectorAll(`[data-tei="${teiRange.context}"]`)).filter(
node => node.closest('pb-popover') === null && node.getAttribute('rel') !== 'footnote',
)[0];
if (!context) {
return null;
}
const range = document.createRange();
const startPoint = pointToRange(context, teiRange.start);
const endPoint = pointToRange(context, teiRange.end);
if (!(startPoint && endPoint)) {
console.error('<pb-view-annotate> Invalid range for %o', context);
return null;
}
console.log('<pb-view-annotate> Range before adjust: %o %o', startPoint, endPoint);
if (startPoint[1] === startPoint[0].textContent.length) {
// try to find the next text node
const nextNode = nextTextNode(context, startPoint[0]);
// next text node is the endpoint: start there
if (nextNode === endPoint[0]) {
range.setStart(nextNode, 0);
// adjust startPoint for check below
startPoint[0] = nextNode;
startPoint[1] = 0;
} else {
range.setStartBefore(startPoint[0].nextSibling || nextNode);
}
} else if (startPoint[0] !== endPoint[0] && startPoint[1] === 0) {
range.setStartBefore(extendRange(startPoint[0], context));
} else {
range.setStart(startPoint[0], startPoint[1]);
}
if (startPoint[0] !== endPoint[0] && endPoint[0].textContent.length - 1 === endPoint[1]) {
range.setEndAfter(extendRange(endPoint[0], context));
} else {
range.setEnd(endPoint[0], endPoint[1]);
}
console.log('<pb-view-annotate> Range: %o', range);
const span = document.createElement('span');
const addClass = teiRange.properties[this.getKey(teiRange.type)] === '' ? 'incomplete' : '';
span.className = `annotation annotation-${teiRange.type} ${teiRange.type} ${addClass} ${teiRange.before ? 'before' : ''}`;
span.dataset.type = teiRange.type;
span.dataset.annotation = JSON.stringify(teiRange.properties);
try {
range.surroundContents(span);
} catch (e) {
if (silent) {
return null;
}
throw new Error('An error occurred. The annotation may not be displayed. You should consider saving and reloading the document.');
}
this._rangesMap.set(span, teiRange);
if (!batch) {
this.refreshMarkers();
}
return span;
}
updateAnnotations(silent = false) {
this._ranges.forEach((teiRange) => {
let span;
switch (teiRange.type) {
case 'delete':
span = this.shadowRoot.querySelector(`[data-tei="${teiRange.node}"]`);
if (span) {
this._deleteAnnotation(span);
} else {
console.error('Annotation %s not found', teiRange.context);
}
break;
case 'modify':
span = this.shadowRoot.querySelector(`[data-tei="${teiRange.node}"]`);
if (!span) {
console.error('<pb-view-annotate> Target node not found for %o', teiRange.node);
break;
}
span.dataset.annotation = JSON.stringify(teiRange.properties);
break;
default:
this._updateAnnotation(teiRange, silent, true);
break;
}
});
window.requestAnimationFrame(() => this.refreshMarkers());
}
_getSelection() {
return this.shadowRoot.getSelection
? this.shadowRoot.getSelection()
: window.getSelection();
}
_selectionChanged() {
if (this._disabled) {
return;
}
const selection = this._getSelection();
const range = this._selectedRange(selection);
if (range) {
let changed = false;
const ancestor = range.commonAncestorContainer;
if (ancestor.nodeType === Node.ELEMENT_NODE) {
if (range.startContainer.parentElement !== ancestor) {
const parent = extendRange(range.startContainer, ancestor);
range.setStartBefore(parent);
changed = true;
}
if (range.endContainer.parentElement !== ancestor) {
const parent = extendRange(range.endContainer, ancestor);
range.setEndAfter(parent);
changed = true;
}
}
this._markSelection(range);
this._currentSelection = range;
console.log('<pb-view-annotate> selection: %o', range);
if (changed) {
setTimeout(() => {
this._inHandler = true;
try {
selection.removeAllRanges();
selection.addRange(range);
} finally {
this._inHandler = false;
}
}, 100);
}
this.emitTo('pb-selection-changed', { hasContent: true, range, selected: selection.toString()});
} else {
this._clearSelection();
this.emitTo('pb-selection-changed', { hasContent: false });
}
}
_markSelection(range) {
const root = this.shadowRoot.getElementById('view');
const rootRect = root.getBoundingClientRect();
const markerLayer = this.shadowRoot.getElementById('marker-layer');
this._clearSelection();
const rects = range.getClientRects();
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
const marker = document.createElement('div');
marker.className = `selection-marker`;
marker.style.position = 'absolute';
marker.style.left = `${rect.left - rootRect.left}px`;
marker.style.top = `${rect.top - rootRect.top}px`;
marker.style.width = `${rect.width}px`;
marker.style.height = `${rect.height}px`;
marker.style.backgroundColor = `var(--pb-annotation-selection, #f9ea7678)`;
markerLayer.appendChild(marker);
}
}
_clearSelection() {
const markerLayer = this.shadowRoot.getElementById('marker-layer');
markerLayer.querySelectorAll('.selection-marker').forEach((oldMarker) => {
markerLayer.removeChild(oldMarker);
});
}
updateAnnotation(teiRange, batch = false) {
teiRange = clearProperties(teiRange);
const result = this._updateAnnotation(teiRange, batch);
if (result) {
this._ranges.push(teiRange);
this.emitTo('pb-annotations-changed', {
type: teiRange.type,
text: teiRange.text,
ranges: this._ranges,
});
}
return result;
}
addAnnotation(info) {
const range = info.range || this._currentSelection;
if (range.collapsed && !info.before) {
return null;
}
const startRange = rangeToPoint(range.startContainer, range.startOffset);
const endRange = rangeToPoint(range.endContainer, range.endOffset, 'end');
const adjustedRange = {
context: startRange.parent,
start: (info.position === 'after') ? endRange.offset : startRange.offset,
end: (info === undefined || info.position === 'before') ? startRange.offset : endRange.offset,
text: info.before ? '' : range.cloneContents().textContent,
before: info.before
};
if (info.type) {
adjustedRange.type = info.type;
}
if (info.properties) {
adjustedRange.properties = info.properties;
}
console.log('<pb-view-annotate> range adjusted: %o', adjustedRange);
this._ranges.push(clearProperties(adjustedRange));
this.emitTo('pb-annotations-changed', {
type: adjustedRange.type,
text: adjustedRange.text,
ranges: this._ranges,
});
this._checkAnnotationColor(adjustedRange.type);
return this._updateAnnotation(adjustedRange);
}
deleteAnnotation(span) {
// delete an existing annotation element in the TEI source
if (span.dataset.tei) {
// first check if we have pending modifications and remove them
const idx = this._ranges.findIndex(r => r.type === 'modify' && r.node === span.dataset.tei);
if (idx > -1) {
this._ranges.splice(idx, 1);
}
const context = span.parentNode.closest('[data-tei]');
const range = {
type: 'delete',
node: span.dataset.tei,
context: context.dataset.tei,
};
this._ranges.push(range);
} else {
const teiRange = this._rangesMap.get(span);
this._rangesMap.delete(span);
const pos = this._ranges.indexOf(teiRange);
console.log('<pb-view-annotate> deleting annotation %o', teiRange);
this._ranges.splice(pos, 1);
}
this._deleteAnnotation(span);
}
_deleteAnnotation(span) {
const newRange = document.createRange();
for (let i = 0; i < span.childNodes.length; i++) {
const copy = span.childNodes[i].cloneNode(true);
span.parentNode.insertBefore(copy, span);
if (i === 0) {
newRange.setStartBefore(copy);
}
if (i === span.childNodes.length - 1) {
newRange.setEndAfter(copy);
}
}
span.parentNode.removeChild(span);
this.emitTo('pb-annotations-changed', { ranges: this._ranges });
window.requestAnimationFrame(() => this.refreshMarkers());
this._inHandler = true;
try {
const selection = this._getSelection();
selection.removeAllRanges();
selection.addRange(newRange);
} catch(e) {
console.error('<pb-view-annotate> %s', e.message);
} finally {
this._inHandler = false;
}
}
editAnnotation(span, properties) {
if (span.dataset.tei) {
// TODO: check in _ranges if it has already been modified
const context = span.closest('[data-tei]');
let range = this._ranges.find(r => r.type === 'modify' && r.node === span.dataset.tei);
if (!range) {
range = {
type: 'modify',
node: span.dataset.tei,
context: context.dataset.tei,
};
this._ranges.push(range);
}
range.properties = properties;
range = clearProperties(range);
this.emitTo('pb-annotations-changed', { ranges: this._ranges });
} else {
let range = this._rangesMap.get(span);
if (range) {
range.properties = properties;
range = clearProperties(range);
this.emitTo('pb-annotations-changed', { ranges: this._ranges });
} else {
console.error('no range found for edit span %o', span);
}
}
const jsonOld = JSON.parse(span.dataset.annotation);
const json = Object.assign(jsonOld || {}, properties);
span.dataset.annotation = JSON.stringify(json);
if (json[this.getKey(span.dataset.type)] !== '') {
span.classList.remove('incomplete');
}
}
_editAnnotation(ev) {
this.editAnnotation(ev.detail.target, ev.detail.properties);
}
/**
*
* @returns {Range|null} the selected range, if any
*/
_selectedRange(selection) {
if (!selection || selection.rangeCount === 0) {
return null;
}
if (selection.anchorNode.getRootNode() !== this.shadowRoot) {
return null;
}
const range = selection.getRangeAt(0);
if (range.collapsed) {
return null;
}
return range;
}
_cancelPendingCallback() {
if (this._pendingCallback) {
clearTimeout(this._pendingCallback);
this._pendingCallback = null;
}
}
_createTooltip(span) {
if (span._tippy || !span.dataset.annotation) {
return;
}
const wrapper = document.createElement('div');
wrapper.className = 'annotation-popup';
const info = document.createElement('div');
info.className = 'info';
wrapper.appendChild(info);
const div = document.createElement('div');
div.className = 'toolbar';
const typeInd = document.createElement('span');
typeInd.className = 'annotation-type';
div.appendChild(typeInd);
if (span.dataset.annotation) {
const editBtn = document.createElement('paper-icon-button');
editBtn.setAttribute('icon', 'icons:create');
editBtn.setAttribute('title', i18n('annotations.edit'));
editBtn.addEventListener('click', () => {
const data = JSON.parse(span.dataset.annotation);
const text = span.textContent;
this.emitTo('pb-annotation-edit', Object.assign({}, { target: span, type: span.dataset.type, properties: data, text }));
});
div.appendChild(editBtn);
}
const delBtn = document.createElement('paper-icon-button');
delBtn.setAttribute('icon', 'icons:delete');
delBtn.setAttribute('title', i18n('annotations.delete'));
delBtn.addEventListener('click', () => {
this.saveHistory();
this.deleteAnnotation(span);
});
div.appendChild(delBtn);
wrapper.appendChild(div);
const root = this.shadowRoot.getElementById('view');
tippy(span, {
content: wrapper,
allowHTML: true,
interactive: true,
appendTo: root.nodeType === Node.DOCUMENT_NODE ? document.body : root,
theme: 'light-border',
hideOnClick: false,
maxWidth: 'auto',
trigger: 'click',
placement: 'left',
popperOptions: {
modifiers: [
{
name: 'flip',
options: {
fallbackPlacements: ['right', 'top', 'bottom'],
},
},
],
},
onTrigger: (instance, ev) => {
ev.preventDefault();
ev.stopPropagation();
const type = span.dataset.type;
const data = JSON.parse(span.dataset.annotation) || {};
const color = this._annotationColors.get(type);
typeInd.innerHTML = type;
typeInd.style.backgroundColor = `var(--pb-annotation-${type})`;
typeInd.style.color = `var(${color && color.isLight ? '--pb-color-primary' : '--pb-color-inverse'})`;
if (data[this.getKey(type)]) {
this.emitTo('pb-annotation-detail', {
type,
id: data[this.getKey(type)],
container: info,
span,
ready: () => instance.setContent(wrapper)
});
} else {
// show properties as key/value table
info.innerHTML = '';
const keys = Object.keys(data);
if (keys.length === 0) {
const p = document.createElement('p');
p.innerHTML = i18n('annotations.no-properties');
info.appendChild(p);
} else {
const table = document.createElement('table');
keys.forEach((key) => {
const tr = document.createElement('tr');
const tdKey = document.createElement('td');
tdKey.innerHTML = key;
tr.appendChild(tdKey);
const tdValue = document.createElement('td');
tdValue.innerHTML = JSON.stringify(data[key], null, 2);
tr.appendChild(tdValue);
table.appendChild(tr);
});
info.appendChild(table);
}
}
},
onClickOutside: (instance, ev) => {
instance.hideWithInteractivity(ev);
}
});
}
/**
* Create a marker for an annotation. Position it absolute next to the annotation.
*
* @param {HTMLElement} span the span for which to display the marker
* @param {DOMRectList} rootRect element with relative position
* @param {Number} margin additional margin to avoid overlapping markers
*/
_showMarker(span, root, rootRect, margin = 0) {
const rects = span.getClientRects();
const type = span.dataset.type;
if (!span.classList.contains('before')) {
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
const marker = document.createElement('div');
marker.className = `marker annotation-${type}`;
marker.style.position = 'absolute';
marker.style.left = `${rect.left - rootRect.left}px`;
marker.style.top = `${rect.top - rootRect.top + rect.height}px`;
marker.style.marginTop = `${margin}px`;
marker.style.width = `${rect.width}px`;
marker.style.height = `3px`;
marker.style.backgroundColor = `var(--pb-annotation-${type})`;
marker.part = 'annotation';
root.appendChild(marker);
}
}
this._createTooltip(span);
}
_clearMarkers() {
this.shadowRoot.getElementById('marker-layer').innerHTML = '';
}
/**
* For all annotations currently shown, create a marker element and position
* it absolute next to the annotation
*
* @param {HTMLElement} root element containing the markers
*/
refreshMarkers() {
const root = this.shadowRoot.getElementById('view');
const rootRect = root.getBoundingClientRect();
const markerLayer = this.shadowRoot.getElementById('marker-layer');
markerLayer.style.display = 'none';
this._clearMarkers();
root.querySelectorAll('.annotation')
.forEach(span => {
if (span._tippy) {
span._tippy.destroy();
}
this._showMarker(span, markerLayer, rootRect, ancestors(span, 'annotation') * 5);
});
markerLayer.style.display = 'block';
}
search(type, tokens) {
function escape(token) {
let regex = token.replace(/[/.?+*\\]/g, (m) => `\\${m}`)
.replace(/[\s\n\t]+/g, '\\s+');
if (/^\w/.test(regex)) {
regex = `\\b${regex}`;
}
if (/\w$/.test(regex)) {
regex = `${regex}\\b`;
}
return regex;
}
function filter(node) {
if (node.nodeType === Node.TEXT_NODE) {
return NodeFilter.FILTER_ACCEPT;
}
if (node.classList.contains('annotation-popup')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_SKIP;
}
filter.acceptNode = filter;
const result = [];
if (!tokens || tokens.length === 0) {
return result;
}
const expr = tokens.filter(token => token && token.length > 0)
.map(token => escape(token))
.join('|');
console.log(`<pb-view-annotate> Searching content for ${expr}...`);
const regex = new RegExp(expr, this.caseSensitive ? 'g' : 'gi');
const walker = document.createTreeWalker(
this.shadowRoot.getElementById('view'),
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
filter,
);
while (walker.nextNode()) {
let node = walker.currentNode;
const matches = Array.from(node.textContent.matchAll(regex));
for (const match of matches) {
const end = match.index + match[0].length;
let isAnnotated = false;
let ref = null;
const annoData = node.parentNode.dataset.annotation;
const annoType = node.parentNode.dataset.type;
if (annoData && annoType) {
const parsed = JSON.parse(annoData) || {};
isAnnotated = annoType === type;
ref = parsed[this.getKey(type)];
}
const startRange = rangeToPoint(node, match.index);
const endRange = rangeToPoint(node, end, 'end');
const [str, start] = collectText(node);
const entry = {
annotated: isAnnotated,
context: startRange.parent,
start: startRange.offset,
end: endRange.offset,
textNode: node,
kwic: kwicText(str, start + match.index, start + end),
};
entry[this.getKey(type)] = ref;
result.push(entry);
}
}
return result;
}
scrollTo(teiRange) {
const root = this.shadowRoot.getElementById('view');
const range = document.createRange();
if (teiRange.annotated) {
range.selectNode(teiRange.textNode);
} else {
const context = Array.from(root.querySelectorAll(`[data-tei="${teiRange.context}"]`)).filter(
node => node.closest('pb-popover') === null && node.getAttribute('rel') !== 'footnote',
)[0];
const startPoint = pointToRange(context, teiRange.start);
const endPoint = pointToRange(context, teiRange.end);
range.setStart(startPoint[0], startPoint[1]);
range.setEnd(endPoint[0], endPoint[1]);
}
const rootRect = root.getBoundingClientRect();
const rect = range.getBoundingClientRect();
let marker = root.querySelector('[part=highlight]');
if (!marker) {
marker = document.createElement('div');
marker.part = 'highlight';
marker.style.position = 'absolute';
root.appendChild(marker);
}
marker.style.left = `${rect.left - rootRect.left - 4}px`;
marker.style.top = `${rect.top - rootRect.top - 4}px`;
marker.style.width = `${rect.width + 4}px`;
marker.style.height = `${rect.height}px`;
range.startContainer.parentNode.scrollIntoView(true);
}
hideMarker() {
const root = this.shadowRoot.getElementById('view');
const marker = root.querySelector('[part=highlight]');
if (marker) {
marker.style.top = '-1000px';
}
}
_markIncompleteAnnotations() {
const elem = this.shadowRoot.getElementById('view')
elem.querySelectorAll('.annotation.authority').forEach((annotation) => {
if (annotation.dataset.type) {
const data = JSON.parse(annotation.dataset.annotation);
const key = this.getKey(annotation.dataset.type);
if (!data[key] || data[key].length === 0) {
annotation.classList.add('incomplete');
} else {
annotation.classList.remove('incomplete');
}
}
});
}
_initAnnotationColors() {
this._annotationColors = new Map();
const types = new Set();
const elem = this.shadowRoot.getElementById('view');
elem.querySelectorAll('.annotation').forEach((annotation) => {
if (annotation.dataset.type) {
types.add(annotation.dataset.type);
}
});
types.forEach((type) => {
this._annotationColors.set(type, uniqolor(`annotation-${type.repeat(4)}`, {
saturation: 70,
lightness: [30, 60]
}));
});
this.emitTo('pb-annotation-colors', { colors: this._annotationColors });
}
_checkAnnotationColor(type) {
if (this._annotationColors.has(type)) {
return;
}
this._annotationColors.set(type, uniqolor(`annotation-${type.repeat(4)}`, {
saturation: 70,
lightness: [30, 60]
}));
this._annotationStyles();
this.emitTo('pb-annotation-colors', { colors: this._annotationColors });
}
_annotationStyles() {
const view = this.shadowRoot.getElementById('view')
let styles = view.querySelector('_annotation-styles');
if (styles) {
styles.parentNode.removeChild(styles);
}
const colorDefs = [];
const classes = [];
this._annotationColors.forEach((color, type) => {
colorDefs.push(`--pb-annotation-${type}: ${color.color};`);
colorDefs.push(`--pb-annotation-${type}-border: 2px solid var(--pb-annotation-${type});`);
classes.push(`
.annotation-${type}::after {
background-color: var(--pb-annotation-${type});
border-color: var(--pb-annotation-${type});
color: var(${color.isLight ? '--pb-color-primary' : '--pb-color-inverse'});
}
.annotation-${type}.incomplete::after {
background: repeating-linear-gradient(
315deg,
var(--pb-annotation-${type}),
var(--pb-annotation-${type}) 5px,
var(${color.isLight ? '--pb-annotation-stripes-light' : '--pb-annotation-stripes-dark'}) 5px,
var(${color.isLight ? '--pb-annotation-stripes-light' : '--pb-annotation-stripes-dark'}) 10px
);
color: var(${color.isLight ? '--pb-color-primary' : '--pb-color-inverse'});
}
`);
});
const css = `
:host {
${colorDefs.join('\n')}
}
${classes.join('\n')}
`;
styles = document.createElement('style');
styles.className = '_annotation-styles';
styles.innerHTML = css;
view.insertBefore(styles, view.firstChild);
}
static get styles() {
return [
super.styles,
css`
.annotation-type {
display: inline-block;
text-align: right;
padding: 4px;
}
.annotation-popup .toolbar {
margin-top: 1em;
}
.annotation-popup table {
width: 100%;
}
.annotation-popup td:nth-child(1) {
font-weight: bold;
}
.annotation-popup td:nth-child(1)::after {
content: ': ';
}
.annotation {
pointer-events: none;
cursor: pointer;
}
.annotation::after {
content: attr(data-type);
margin-left: 4px;
pointer-events: all;
font-family: var(--pb-base-font-family);
font-size: .8rem;
font-style: normal;
font-weight: normal;
text-decoration: none;
font-variant: normal;
padding: 2px;
}
.annotation.before::after {
margin-left: 0;
border-radius: 4px;
}
[part=highlight] {
border: 3px solid rgb(255, 174, 0);
border-radius: 8px;
}`
];
}
};
customElements.define('pb-view-annotate', PbViewAnnotate);