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 {
_updateAnchors(anchors: Anchor[], notify: boolean) {
this.anchors = anchors;
this._clusterToolbar?.scheduleClusterUpdates();
if (notify) {
this._bucketBarClient.update(this.anchors);
}
......
......@@ -9,6 +9,8 @@ import type { HighlightCluster } from '../types/shared';
import ClusterToolbar from './components/ClusterToolbar';
import { createShadowRoot } from './util/shadow-root';
import { updateClusters } from './highlighter';
export type HighlightStyle = {
color: string;
secondColor: string;
......@@ -71,6 +73,7 @@ export class HighlightClusterController implements Destroyable {
private _features: IFeatureFlags;
private _outerContainer: HTMLElement;
private _shadowRoot: ShadowRoot;
private _updateTimeout?: number;
constructor(element: HTMLElement, options: { features: IFeatureFlags }) {
this._element = element;
......@@ -101,11 +104,21 @@ export class HighlightClusterController implements Destroyable {
}
destroy() {
clearTimeout(this._updateTimeout);
render(null, this._shadowRoot); // unload the Preact component
this._activate(false); // De-activate cluster styling
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
* applied styles for each cluster. This has no effect if this feature
......@@ -121,6 +134,14 @@ export class HighlightClusterController implements Destroyable {
this._activate(this._isActive());
}
_updateClusters() {
if (!this._isActive()) {
/* istanbul ignore next */
return;
}
updateClusters(this._element);
}
_isActive() {
return this._features.flagEnabled('styled_highlight_clusters');
}
......
import classnames from 'classnames';
import type { HighlightCluster } from '../types/shared';
import { isInPlaceholder } from './anchoring/placeholder';
import { isNodeInRange } from './range-util';
......@@ -12,6 +14,12 @@ type 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
* text layer.
......@@ -385,3 +393,146 @@ export function getBoundingClientRect(collection: HTMLElement[]): Rect {
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';
describe('HighlightClusterController', () => {
let fakeFeatures;
let fakeSetProperty;
let fakeUpdateClusters;
let toolbarProps;
let container;
let controllers;
......@@ -22,8 +24,11 @@ describe('HighlightClusterController', () => {
beforeEach(() => {
controllers = [];
fakeFeatures = new FeatureFlags();
fakeSetProperty = sinon.stub(document.documentElement.style, 'setProperty');
fakeUpdateClusters = sinon.stub();
container = document.createElement('div');
toolbarProps = {};
......@@ -34,6 +39,9 @@ describe('HighlightClusterController', () => {
$imports.$mock({
'./components/ClusterToolbar': FakeToolbar,
'./highlighter': {
updateClusters: fakeUpdateClusters,
},
});
});
......@@ -110,4 +118,35 @@ describe('HighlightClusterController', () => {
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 {
removeAllHighlights,
setHighlightsFocused,
setHighlightsVisible,
updateClusters,
} from '../highlighter';
/**
......@@ -49,7 +50,7 @@ function PDFPage({ showPlaceholder = false }) {
* component has been rendered
* @param {string} [cssClass] additional CSS class(es) to apply to the highlight
* and SVG rect elements
* @return {HTMLElement} - `<hypothesis-highlight>` element
* @return {HighlightElement[]} - `<hypothesis-highlight>` element
*/
function highlightPDFRange(pageContainer, cssClass = '') {
const textSpan = pageContainer.querySelector('.testText');
......@@ -375,7 +376,7 @@ describe('annotator/highlighter', () => {
*
* Returns all the highlight elements.
*/
function createHighlights(root) {
function createHighlights(root, cssClass = '') {
let highlights = [];
for (let i = 0; i < 3; i++) {
......@@ -385,12 +386,82 @@ describe('annotator/highlighter', () => {
range.setStartBefore(span.childNodes[0]);
range.setEndAfter(span.childNodes[0]);
root.appendChild(span);
highlights.push(...highlightRange(range));
highlights.push(...highlightRange(range, cssClass));
}
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', () => {
it('removes all highlight elements under the root element', () => {
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