Commit c3a2ebaf authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Update highlight data, ordering to support styling SVG nested highlights

parent 5c94d10f
...@@ -613,6 +613,7 @@ export class Guest implements Annotator, Destroyable { ...@@ -613,6 +613,7 @@ export class Guest implements Annotator, Destroyable {
_updateAnchors(anchors: Anchor[], notify: boolean) { _updateAnchors(anchors: Anchor[], notify: boolean) {
this.anchors = anchors; this.anchors = anchors;
this._clusterToolbar?.scheduleClusterUpdates();
if (notify) { if (notify) {
this._bucketBarClient.update(this.anchors); this._bucketBarClient.update(this.anchors);
} }
......
...@@ -9,6 +9,8 @@ import type { HighlightCluster } from '../types/shared'; ...@@ -9,6 +9,8 @@ import type { HighlightCluster } from '../types/shared';
import ClusterToolbar from './components/ClusterToolbar'; import ClusterToolbar from './components/ClusterToolbar';
import { createShadowRoot } from './util/shadow-root'; import { createShadowRoot } from './util/shadow-root';
import { updateClusters } from './highlighter';
export type HighlightStyle = { export type HighlightStyle = {
color: string; color: string;
secondColor: string; secondColor: string;
...@@ -71,6 +73,7 @@ export class HighlightClusterController implements Destroyable { ...@@ -71,6 +73,7 @@ export class HighlightClusterController implements Destroyable {
private _features: IFeatureFlags; private _features: IFeatureFlags;
private _outerContainer: HTMLElement; private _outerContainer: HTMLElement;
private _shadowRoot: ShadowRoot; private _shadowRoot: ShadowRoot;
private _updateTimeout?: number;
constructor(element: HTMLElement, options: { features: IFeatureFlags }) { constructor(element: HTMLElement, options: { features: IFeatureFlags }) {
this._element = element; this._element = element;
...@@ -101,11 +104,21 @@ export class HighlightClusterController implements Destroyable { ...@@ -101,11 +104,21 @@ export class HighlightClusterController implements Destroyable {
} }
destroy() { destroy() {
clearTimeout(this._updateTimeout);
render(null, this._shadowRoot); // unload the Preact component render(null, this._shadowRoot); // unload the Preact component
this._activate(false); // De-activate cluster styling this._activate(false); // De-activate cluster styling
this._outerContainer.remove(); this._outerContainer.remove();
} }
/**
* Indicate that the set of highlights in the document has been dirtied and we
* should schedule an update to highlight data attributes and stacking order.
*/
scheduleClusterUpdates() {
clearTimeout(this._updateTimeout);
this._updateTimeout = setTimeout(() => this._updateClusters(), 100);
}
/** /**
* Set initial values for :root CSS custom properties (variables) based on the * Set initial values for :root CSS custom properties (variables) based on the
* applied styles for each cluster. This has no effect if this feature * applied styles for each cluster. This has no effect if this feature
...@@ -121,6 +134,14 @@ export class HighlightClusterController implements Destroyable { ...@@ -121,6 +134,14 @@ export class HighlightClusterController implements Destroyable {
this._activate(this._isActive()); this._activate(this._isActive());
} }
_updateClusters() {
if (!this._isActive()) {
/* istanbul ignore next */
return;
}
updateClusters(this._element);
}
_isActive() { _isActive() {
return this._features.flagEnabled('styled_highlight_clusters'); return this._features.flagEnabled('styled_highlight_clusters');
} }
......
import classnames from 'classnames'; import classnames from 'classnames';
import type { HighlightCluster } from '../types/shared';
import { isInPlaceholder } from './anchoring/placeholder'; import { isInPlaceholder } from './anchoring/placeholder';
import { isNodeInRange } from './range-util'; import { isNodeInRange } from './range-util';
...@@ -12,6 +14,12 @@ type HighlightProps = { ...@@ -12,6 +14,12 @@ type HighlightProps = {
export type HighlightElement = HTMLElement & HighlightProps; export type HighlightElement = HTMLElement & HighlightProps;
export const clusterValues: HighlightCluster[] = [
'user-annotations',
'user-highlights',
'other-content',
];
/** /**
* Return the canvas element underneath a highlight element in a PDF page's * Return the canvas element underneath a highlight element in a PDF page's
* text layer. * text layer.
...@@ -385,3 +393,146 @@ export function getBoundingClientRect(collection: HTMLElement[]): Rect { ...@@ -385,3 +393,146 @@ export function getBoundingClientRect(collection: HTMLElement[]): Rect {
right: Math.max(acc.right, r.right), right: Math.max(acc.right, r.right),
})); }));
} }
/**
* Add metadata and manipulate ordering of all highlights in `element` to
* allow styling of nested, clustered highlights.
*/
export function updateClusters(element: Element) {
setNestingData(getHighlights(element));
updateSVGHighlightOrdering(element);
}
/**
* Is `el` a highlight element? Work around inconsistency between HTML documents
* (`tagName` is upper-case) and XHTML documents (`tagName` is lower-case)
*/
const isHighlightElement = (el: Element): boolean =>
el.tagName.toLowerCase() === 'hypothesis-highlight';
/**
* Return the closest generation of HighlightElements to `element`.
*
* If `element` is itself a HighlightElement, return immediate children that
* are also HighlightElements.
*
* Otherwise, return all HighlightElements that have no parent HighlightElement,
* i.e. the outermost highlights within `element`.
*/
function getHighlights(element: Element) {
let highlights;
if (isHighlightElement(element)) {
highlights = Array.from(element.children).filter(isHighlightElement);
} else {
highlights = Array.from(
element.getElementsByTagName('hypothesis-highlight')
).filter(
highlight =>
!highlight.parentElement || !isHighlightElement(highlight.parentElement)
);
}
return highlights as HighlightElement[];
}
/**
* Get all of the SVG highlights within `root`, grouped by layer. A PDF
* document may have multiple layers of SVG highlights, typically one per page.
*
* @return a Map of layer Elements to all SVG highlights within that
* layer Element
*/
function getSVGHighlights(root?: Element): Map<Element, HighlightElement[]> {
const svgHighlights: Map<Element, HighlightElement[]> = new Map();
for (const layer of (root ?? document).getElementsByClassName(
'hypothesis-highlight-layer'
)) {
svgHighlights.set(
layer,
Array.from(
layer.querySelectorAll('.hypothesis-svg-highlight')
) as HighlightElement[]
);
}
return svgHighlights;
}
/**
* Walk a tree of <hypothesis-highlight> elements, adding `data-nesting-level`
* and `data-cluster-level` data attributes to <hypothesis-highlight>s and
* their associated SVG highlight (<rect>) elements.
*
* - `data-nesting-level` - generational depth of the applicable
* `<hypothesis-highlight>` relative to outermost `<hypothesis-highlight>`.
* - `data-cluster-level` - number of `<hypothesis-highlight>` generations
* since the cluster value changed.
*
* @param highlightEls - A collection of sibling <hypothesis-highlight>
* elements
* @param parentCluster - The cluster value of the parent highlight to
* `highlightEls`, if any
* @param nestingLevel - The nesting "level", relative to the outermost
* <hypothesis-highlight> element (0-based)
* @param parentClusterLevel - The parent's nesting depth, per its cluster
* value (`parentCluster`). i.e. How many levels since the cluster value
* changed? This allows for nested styling of highlights of the same cluster
* value.
*/
function setNestingData(
highlightEls: HighlightElement[],
parentCluster = '',
nestingLevel = 0,
parentClusterLevel = 0
) {
for (const hEl of highlightEls) {
const elCluster =
clusterValues.find(cv => hEl.classList.contains(cv)) ?? 'other-content';
const elClusterLevel =
parentCluster && elCluster === parentCluster ? parentClusterLevel + 1 : 0;
hEl.setAttribute('data-nesting-level', `${nestingLevel}`);
hEl.setAttribute('data-cluster-level', `${elClusterLevel}`);
if (hEl.svgHighlight) {
hEl.svgHighlight.setAttribute('data-nesting-level', `${nestingLevel}`);
hEl.svgHighlight.setAttribute('data-cluster-level', `${elClusterLevel}`);
}
setNestingData(
getHighlights(hEl),
elCluster /* parentCluster */,
nestingLevel + 1 /* nestingLevel */,
elClusterLevel /* parentClusterLevel */
);
}
}
/**
* Ensure that SVG <rect> elements are ordered correctly: inner (nested)
* highlights should be visible on top of outer highlights.
*
* All SVG <rect>s drawn for a PDF page are siblings. To ensure that the
* <rect>s associated with outer highlights don't show up on top of (and thus
* obscure) nested highlights, order the <rects> by their `data-nesting-level`
* value if they are not already.
*/
function updateSVGHighlightOrdering(element: Element) {
const nestingLevel = (el: Element) =>
parseInt(el.getAttribute('data-nesting-level') ?? '0', 10);
for (const [layer, layerHighlights] of getSVGHighlights(element)) {
const correctlyOrdered = layerHighlights.every((svgEl, idx, allEls) => {
if (idx === 0) {
return true;
}
return nestingLevel(svgEl) >= nestingLevel(allEls[idx - 1]);
});
if (!correctlyOrdered) {
layerHighlights.sort((a, b) => nestingLevel(a) - nestingLevel(b));
layer.replaceChildren(...layerHighlights);
}
}
}
...@@ -7,6 +7,8 @@ import { FeatureFlags } from '../features'; ...@@ -7,6 +7,8 @@ import { FeatureFlags } from '../features';
describe('HighlightClusterController', () => { describe('HighlightClusterController', () => {
let fakeFeatures; let fakeFeatures;
let fakeSetProperty; let fakeSetProperty;
let fakeUpdateClusters;
let toolbarProps; let toolbarProps;
let container; let container;
let controllers; let controllers;
...@@ -22,8 +24,11 @@ describe('HighlightClusterController', () => { ...@@ -22,8 +24,11 @@ describe('HighlightClusterController', () => {
beforeEach(() => { beforeEach(() => {
controllers = []; controllers = [];
fakeFeatures = new FeatureFlags(); fakeFeatures = new FeatureFlags();
fakeSetProperty = sinon.stub(document.documentElement.style, 'setProperty'); fakeSetProperty = sinon.stub(document.documentElement.style, 'setProperty');
fakeUpdateClusters = sinon.stub();
container = document.createElement('div'); container = document.createElement('div');
toolbarProps = {}; toolbarProps = {};
...@@ -34,6 +39,9 @@ describe('HighlightClusterController', () => { ...@@ -34,6 +39,9 @@ describe('HighlightClusterController', () => {
$imports.$mock({ $imports.$mock({
'./components/ClusterToolbar': FakeToolbar, './components/ClusterToolbar': FakeToolbar,
'./highlighter': {
updateClusters: fakeUpdateClusters,
},
}); });
}); });
...@@ -110,4 +118,35 @@ describe('HighlightClusterController', () => { ...@@ -110,4 +118,35 @@ describe('HighlightClusterController', () => {
assert.equal(fakeSetProperty.callCount, 3); assert.equal(fakeSetProperty.callCount, 3);
}); });
describe('updating highlight element data and ordering', () => {
let clock;
beforeEach(() => {
clock = sinon.useFakeTimers();
fakeFeatures.update({ styled_highlight_clusters: true });
});
afterEach(() => {
clock.restore();
});
it('schedules a debounced task to update highlights', () => {
const controller = createToolbar();
controller.scheduleClusterUpdates();
assert.notCalled(fakeUpdateClusters);
clock.tick(1);
assert.notCalled(fakeUpdateClusters);
controller.scheduleClusterUpdates();
controller.scheduleClusterUpdates();
clock.tick(150);
assert.calledOnce(fakeUpdateClusters);
});
});
}); });
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
removeAllHighlights, removeAllHighlights,
setHighlightsFocused, setHighlightsFocused,
setHighlightsVisible, setHighlightsVisible,
updateClusters,
} from '../highlighter'; } from '../highlighter';
/** /**
...@@ -49,7 +50,7 @@ function PDFPage({ showPlaceholder = false }) { ...@@ -49,7 +50,7 @@ function PDFPage({ showPlaceholder = false }) {
* component has been rendered * component has been rendered
* @param {string} [cssClass] additional CSS class(es) to apply to the highlight * @param {string} [cssClass] additional CSS class(es) to apply to the highlight
* and SVG rect elements * and SVG rect elements
* @return {HTMLElement} - `<hypothesis-highlight>` element * @return {HighlightElement[]} - `<hypothesis-highlight>` element
*/ */
function highlightPDFRange(pageContainer, cssClass = '') { function highlightPDFRange(pageContainer, cssClass = '') {
const textSpan = pageContainer.querySelector('.testText'); const textSpan = pageContainer.querySelector('.testText');
...@@ -375,7 +376,7 @@ describe('annotator/highlighter', () => { ...@@ -375,7 +376,7 @@ describe('annotator/highlighter', () => {
* *
* Returns all the highlight elements. * Returns all the highlight elements.
*/ */
function createHighlights(root) { function createHighlights(root, cssClass = '') {
let highlights = []; let highlights = [];
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
...@@ -385,12 +386,82 @@ describe('annotator/highlighter', () => { ...@@ -385,12 +386,82 @@ describe('annotator/highlighter', () => {
range.setStartBefore(span.childNodes[0]); range.setStartBefore(span.childNodes[0]);
range.setEndAfter(span.childNodes[0]); range.setEndAfter(span.childNodes[0]);
root.appendChild(span); root.appendChild(span);
highlights.push(...highlightRange(range)); highlights.push(...highlightRange(range, cssClass));
} }
return highlights; return highlights;
} }
describe('updateClusters', () => {
const nestingLevel = el =>
parseInt(el.getAttribute('data-nesting-level'), 10);
const clusterLevel = el =>
parseInt(el.getAttribute('data-cluster-level'), 10);
it('sets nesting data on highlight elements', () => {
const container = document.createElement('div');
render(<PDFPage />, container);
const highlights = [
...highlightPDFRange(container, 'user-annotations'),
...highlightPDFRange(container, 'user-annotations'),
...highlightPDFRange(container, 'user-annotations'),
...highlightPDFRange(container, 'other-content'),
];
updateClusters(container);
assert.equal(nestingLevel(highlights[0]), 0);
assert.equal(nestingLevel(highlights[1]), 1);
assert.equal(nestingLevel(highlights[2]), 2);
assert.equal(nestingLevel(highlights[3]), 3);
assert.equal(clusterLevel(highlights[0]), 0);
assert.equal(clusterLevel(highlights[1]), 1);
assert.equal(clusterLevel(highlights[2]), 2);
assert.equal(clusterLevel(highlights[3]), 0);
});
it('sets nesting data on SVG highlights', () => {
const container = document.createElement('div');
render(<PDFPage />, container);
const highlights = [
...highlightPDFRange(container, 'user-annotations'),
...highlightPDFRange(container, 'user-annotations'),
];
updateClusters(container);
assert.equal(nestingLevel(highlights[0].svgHighlight), 0);
assert.equal(nestingLevel(highlights[1].svgHighlight), 1);
assert.equal(clusterLevel(highlights[0].svgHighlight), 0);
assert.equal(clusterLevel(highlights[1].svgHighlight), 1);
});
it('reorders SVG highlights based on nesting level', () => {
const container = document.createElement('div');
render(<PDFPage />, container);
// SVG highlights for these highlights will be added in order.
// These first three highlights will nest.
highlightPDFRange(container, 'user-annotations');
highlightPDFRange(container, 'user-annotations');
highlightPDFRange(container, 'other-content');
// these second three highlights are outer highlights
createHighlights(container.querySelector('.textLayer'));
updateClusters(container);
const orderedNestingLevels = Array.from(
container.querySelectorAll('rect')
).map(el => nestingLevel(el));
assert.deepEqual(orderedNestingLevels, [0, 0, 0, 0, 1, 2]);
});
});
describe('removeAllHighlights', () => { describe('removeAllHighlights', () => {
it('removes all highlight elements under the root element', () => { it('removes all highlight elements under the root element', () => {
const root = document.createElement('div'); const root = document.createElement('div');
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment