Commit 9906ff6f authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Make `BucketBar` use preact `Buckets`

Refactor `BucketBar` to render preact `Buckets` component. Lightly
refactor `anchorBuckets` utility function to return above and below
buckets as separate properties. Use SASS mixin for indicator button
styling.
parent 1bd15661
import Delegator from '../delegator'; import Delegator from '../delegator';
import scrollIntoView from 'scroll-into-view';
import { setHighlightsFocused } from '../highlighter'; import { createElement, render } from 'preact';
import { findClosestOffscreenAnchor, anchorBuckets } from '../util/buckets'; import Buckets from '../components/buckets';
/** import { anchorBuckets } from '../util/buckets';
* @typedef {import('../util/buckets').Bucket} Bucket
*/
// Scroll to the next closest anchor off screen in the given direction.
function scrollToClosest(anchors, direction) {
const closest = findClosestOffscreenAnchor(anchors, direction);
if (closest && closest.highlights?.length) {
scrollIntoView(closest.highlights[0]);
}
}
export default class BucketBar extends Delegator { export default class BucketBar extends Delegator {
constructor(element, options, annotator) { constructor(element, options, annotator) {
...@@ -31,11 +20,6 @@ export default class BucketBar extends Delegator { ...@@ -31,11 +20,6 @@ export default class BucketBar extends Delegator {
this.annotator = annotator; this.annotator = annotator;
/** @type {Bucket[]} */
this.buckets = [];
/** @type {HTMLElement[]} - Elements created in the bucket bar for each bucket */
this.tabs = [];
// The element to append this plugin's element to; defaults to the provided // The element to append this plugin's element to; defaults to the provided
// `element` unless a `container` option was provided // `element` unless a `container` option was provided
let container = /** @type {HTMLElement} */ (element); let container = /** @type {HTMLElement} */ (element);
...@@ -80,22 +64,6 @@ export default class BucketBar extends Delegator { ...@@ -80,22 +64,6 @@ export default class BucketBar extends Delegator {
}); });
} }
/**
* Focus or unfocus the anchor highlights in the bucket indicated by `index`
*
* @param {number} index - The bucket's index in the `this.buckets` array
* @param {boolean} toggle - Should this set of highlights be focused (or
* un-focused)?
*/
updateHighlightFocus(index, toggle) {
if (index > 0 && this.buckets[index] && !this.isNavigationBucket(index)) {
const bucket = this.buckets[index];
bucket.anchors.forEach(anchor => {
setHighlightsFocused(anchor.highlights || [], toggle);
});
}
}
update() { update() {
if (this._updatePending) { if (this._updatePending) {
return; return;
...@@ -108,101 +76,17 @@ export default class BucketBar extends Delegator { ...@@ -108,101 +76,17 @@ export default class BucketBar extends Delegator {
} }
_update() { _update() {
this.buckets = anchorBuckets(this.annotator.anchors); const buckets = anchorBuckets(this.annotator.anchors);
render(
// The following affordances attempt to reuse existing DOM elements <Buckets
// when reconstructing bucket "tabs" to cut down on the number of elements above={buckets.above}
// created and added to the DOM below={buckets.below}
buckets={buckets.buckets}
// Only leave as many "tab" elements attached to the DOM as there are onSelectAnnotations={(annotations, toggle) =>
// buckets this.annotator.selectAnnotations(annotations, toggle)
this.tabs.slice(this.buckets.length).forEach(tabEl => tabEl.remove()); }
/>,
// And cut the "tabs" collection down to the size of buckets, too this.element
/** @type {HTMLElement[]} */
this.tabs = this.tabs.slice(0, this.buckets.length);
// If the number of "tabs" currently in the DOM is too small (fewer than
// buckets), fill that gap by creating new elements (and adding event
// listeners to them)
this.buckets.slice(this.tabs.length).forEach(() => {
const tabEl = document.createElement('div');
this.tabs.push(tabEl);
// Note that these elements are reused as buckets change, meaning that
// any given tab element will correspond to a different bucket over time.
// However, we know that we have one "tab" per bucket, in order,
// so we can look up the correct bucket for a tab at event time.
// Focus and unfocus highlights on mouse events
tabEl.addEventListener('mousemove', () => {
this.updateHighlightFocus(this.tabs.indexOf(tabEl), true);
});
tabEl.addEventListener('mouseout', () => {
this.updateHighlightFocus(this.tabs.indexOf(tabEl), false);
});
// Select the annotations (in the sidebar)
// that have anchors within the clicked bucket
tabEl.addEventListener('click', event => {
event.stopPropagation();
const index = this.tabs.indexOf(tabEl);
const bucket = this.buckets[index];
if (!bucket) {
return;
}
if (this.isLower(index)) {
scrollToClosest(bucket.anchors, 'down');
} else if (this.isUpper(index)) {
scrollToClosest(bucket.anchors, 'up');
} else {
const annotations = bucket.anchors.map(anchor => anchor.annotation);
this.annotator.selectAnnotations(
annotations,
event.ctrlKey || event.metaKey
); );
} }
});
this.element.appendChild(tabEl);
});
this._buildTabs();
}
_buildTabs() {
this.tabs.forEach((tabEl, index) => {
const anchorCount = this.buckets[index].anchors.length;
tabEl.className = 'annotator-bucket-indicator';
tabEl.style.top = `${this.buckets[index].position}px`;
tabEl.style.display = '';
if (anchorCount) {
tabEl.innerHTML = `<div class="label">${this.buckets[index].anchors.length}</div>`;
if (anchorCount === 1) {
tabEl.setAttribute('title', 'Show one annotation');
} else {
tabEl.setAttribute('title', `Show ${anchorCount} annotations`);
}
} else {
tabEl.style.display = 'none';
}
tabEl.classList.toggle('upper', this.isUpper(index));
tabEl.classList.toggle('lower', this.isLower(index));
});
}
isUpper(i) {
return i === 0;
}
isLower(i) {
return i === this.buckets.length - 1;
}
isNavigationBucket(i) {
return this.isUpper(i) || this.isLower(i);
}
} }
...@@ -11,6 +11,15 @@ import { getBoundingClientRect } from '../highlighter'; ...@@ -11,6 +11,15 @@ import { getBoundingClientRect } from '../highlighter';
* appear in the bucket bar. * appear in the bucket bar.
*/ */
/**
* @typedef BucketSet
* @prop {Bucket} above - A single bucket containing all of the anchors that
* are offscreen upwards
* @prop {Bucket} below - A single bucket containing all of the anchors that are
* offscreen downwards
* @prop {Bucket[]} buckets - On-screen buckets
*/
/** /**
* @typedef WorkingBucket * @typedef WorkingBucket
* @prop {Anchor[]} anchors - The anchors in this bucket * @prop {Anchor[]} anchors - The anchors in this bucket
...@@ -131,7 +140,7 @@ function getAnchorPositions(anchors) { ...@@ -131,7 +140,7 @@ function getAnchorPositions(anchors) {
* Compute buckets * Compute buckets
* *
* @param {Anchor[]} anchors * @param {Anchor[]} anchors
* @return {Bucket[]} * @return {BucketSet}
*/ */
export function anchorBuckets(anchors) { export function anchorBuckets(anchors) {
const anchorPositions = getAnchorPositions(anchors); const anchorPositions = getAnchorPositions(anchors);
...@@ -214,15 +223,20 @@ export function anchorBuckets(anchors) { ...@@ -214,15 +223,20 @@ export function anchorBuckets(anchors) {
} }
// Add an upper "navigation" bucket with offscreen-above anchors // Add an upper "navigation" bucket with offscreen-above anchors
buckets.unshift({ const above = {
anchors: Array.from(aboveScreen), anchors: Array.from(aboveScreen),
position: BUCKET_TOP_THRESHOLD, position: BUCKET_TOP_THRESHOLD,
}); };
// Add a lower "navigation" bucket with offscreen-below anchors // Add a lower "navigation" bucket with offscreen-below anchors
buckets.push({ const below = {
anchors: Array.from(belowScreen), anchors: Array.from(belowScreen),
position: window.innerHeight - BUCKET_BOTTOM_THRESHOLD, position: window.innerHeight - BUCKET_BOTTOM_THRESHOLD,
}); };
return buckets;
return {
above,
below,
buckets,
};
} }
...@@ -114,57 +114,66 @@ describe('annotator/util/buckets', () => { ...@@ -114,57 +114,66 @@ describe('annotator/util/buckets', () => {
}); });
describe('anchorBuckets', () => { describe('anchorBuckets', () => {
it('puts anchors that are above the screen into the first bucket', () => { it('puts anchors that are above the screen into the `above` bucket', () => {
const buckets = anchorBuckets(fakeAnchors); const bucketSet = anchorBuckets(fakeAnchors);
assert.deepEqual(buckets[0].anchors, [fakeAnchors[0], fakeAnchors[1]]); assert.deepEqual(bucketSet.above.anchors, [
fakeAnchors[0],
fakeAnchors[1],
]);
}); });
it('puts anchors that are below the screen into the last bucket', () => { it('puts anchors that are below the screen into the `below` bucket', () => {
const buckets = anchorBuckets(fakeAnchors); const bucketSet = anchorBuckets(fakeAnchors);
assert.deepEqual(buckets[buckets.length - 1].anchors, [ assert.deepEqual(bucketSet.below.anchors, [
fakeAnchors[4], fakeAnchors[4],
fakeAnchors[5], fakeAnchors[5],
]); ]);
}); });
it('puts on-screen anchors into a bucket', () => { it('puts on-screen anchors into a buckets', () => {
const buckets = anchorBuckets(fakeAnchors); const bucketSet = anchorBuckets(fakeAnchors);
assert.deepEqual(buckets[1].anchors, [fakeAnchors[2], fakeAnchors[3]]); assert.deepEqual(bucketSet.buckets[0].anchors, [
fakeAnchors[2],
fakeAnchors[3],
]);
}); });
it('puts anchors into separate buckets if more than 60px separates their boxes', () => { it('puts anchors into separate buckets if more than 60px separates their boxes', () => {
fakeAnchors[2].highlights = [201, 15]; // bottom 216 fakeAnchors[2].highlights = [201, 15]; // bottom 216
fakeAnchors[3].highlights = [301, 15]; // top 301 - more than 60px from 216 fakeAnchors[3].highlights = [301, 15]; // top 301 - more than 60px from 216
const buckets = anchorBuckets(fakeAnchors); const bucketSet = anchorBuckets(fakeAnchors);
assert.deepEqual(buckets[1].anchors, [fakeAnchors[2]]); assert.deepEqual(bucketSet.buckets[0].anchors, [fakeAnchors[2]]);
assert.deepEqual(buckets[2].anchors, [fakeAnchors[3]]); assert.deepEqual(bucketSet.buckets[1].anchors, [fakeAnchors[3]]);
}); });
it('puts overlapping anchors into a shared bucket', () => { it('puts overlapping anchors into a shared bucket', () => {
fakeAnchors[2].highlights = [201, 200]; // Bottom 401 fakeAnchors[2].highlights = [201, 200]; // Bottom 401
fakeAnchors[3].highlights = [285, 100]; // Bottom 385 fakeAnchors[3].highlights = [285, 100]; // Bottom 385
const buckets = anchorBuckets(fakeAnchors); const bucketSet = anchorBuckets(fakeAnchors);
assert.deepEqual(buckets[1].anchors, [fakeAnchors[2], fakeAnchors[3]]); assert.deepEqual(bucketSet.buckets[0].anchors, [
fakeAnchors[2],
fakeAnchors[3],
]);
}); });
it('positions the bucket at v. midpoint of the box containing all bucket anchors', () => { it('positions the bucket at vertical midpoint of the box containing all bucket anchors', () => {
fakeAnchors[2].highlights = [200, 50]; // Top 200 fakeAnchors[2].highlights = [200, 50]; // Top 200
fakeAnchors[3].highlights = [225, 75]; // Bottom 300 fakeAnchors[3].highlights = [225, 75]; // Bottom 300
const buckets = anchorBuckets(fakeAnchors); const bucketSet = anchorBuckets(fakeAnchors);
assert.equal(buckets[1].position, 250); assert.equal(bucketSet.buckets[0].position, 250);
}); });
it('only buckets annotations that have highlights', () => { it('only buckets annotations that have highlights', () => {
const badAnchor = { highlights: [] }; const badAnchor = { highlights: [] };
fakeAnchors.push(badAnchor); fakeAnchors.push(badAnchor);
const buckets = anchorBuckets([badAnchor]); const bucketSet = anchorBuckets([badAnchor]);
assert.equal(buckets.length, 2); assert.equal(bucketSet.buckets.length, 0);
assert.isEmpty(buckets[0].anchors); // Holder for above-screen anchors assert.isEmpty(bucketSet.above.anchors); // Holder for above-screen anchors
assert.isEmpty(buckets[1].anchors); // Holder for below-screen anchors assert.isEmpty(bucketSet.below.anchors); // Holder for below-screen anchors
}); });
it('sorts anchors by top position', () => { it('sorts anchors by top position', () => {
const buckets = anchorBuckets([ const bucketSet = anchorBuckets([
fakeAnchors[3], fakeAnchors[3],
fakeAnchors[2], fakeAnchors[2],
fakeAnchors[5], fakeAnchors[5],
...@@ -172,9 +181,18 @@ describe('annotator/util/buckets', () => { ...@@ -172,9 +181,18 @@ describe('annotator/util/buckets', () => {
fakeAnchors[0], fakeAnchors[0],
fakeAnchors[1], fakeAnchors[1],
]); ]);
assert.deepEqual(buckets[0].anchors, [fakeAnchors[0], fakeAnchors[1]]); assert.deepEqual(bucketSet.above.anchors, [
assert.deepEqual(buckets[1].anchors, [fakeAnchors[2], fakeAnchors[3]]); fakeAnchors[0],
assert.deepEqual(buckets[2].anchors, [fakeAnchors[4], fakeAnchors[5]]); fakeAnchors[1],
]);
assert.deepEqual(bucketSet.buckets[0].anchors, [
fakeAnchors[2],
fakeAnchors[3],
]);
assert.deepEqual(bucketSet.below.anchors, [
fakeAnchors[4],
fakeAnchors[5],
]);
}); });
it('returns only above- and below-screen anchors if none are on-screen', () => { it('returns only above- and below-screen anchors if none are on-screen', () => {
...@@ -183,12 +201,15 @@ describe('annotator/util/buckets', () => { ...@@ -183,12 +201,15 @@ describe('annotator/util/buckets', () => {
fakeAnchors[3].highlights = [1100, 75]; fakeAnchors[3].highlights = [1100, 75];
fakeAnchors[4].highlights = [1200, 100]; fakeAnchors[4].highlights = [1200, 100];
fakeAnchors[5].highlights = [1300, 75]; fakeAnchors[5].highlights = [1300, 75];
const buckets = anchorBuckets(fakeAnchors); const bucketSet = anchorBuckets(fakeAnchors);
assert.equal(buckets.length, 2); assert.equal(bucketSet.buckets.length, 0);
// Above-screen // Above-screen
assert.deepEqual(buckets[0].anchors, [fakeAnchors[0], fakeAnchors[1]]); assert.deepEqual(bucketSet.above.anchors, [
fakeAnchors[0],
fakeAnchors[1],
]);
// Below-screen // Below-screen
assert.deepEqual(buckets[1].anchors, [ assert.deepEqual(bucketSet.below.anchors, [
fakeAnchors[2], fakeAnchors[2],
fakeAnchors[3], fakeAnchors[3],
fakeAnchors[4], fakeAnchors[4],
......
@use "../mixins/buttons";
@use "../mixins/reset"; @use "../mixins/reset";
@use "../mixins/utils"; @use "../mixins/utils";
@use "../variables" as var; @use "../variables" as var;
...@@ -20,126 +21,31 @@ ...@@ -20,126 +21,31 @@
background: rgba(0, 0, 0, 0.08); background: rgba(0, 0, 0, 0.08);
} }
.annotator-bucket-indicator { .buckets,
box-sizing: border-box; .bucket {
background: var.$white;
border: solid 1px var.$grey-3;
border-radius: 2px 4px 4px 2px;
right: 0;
pointer-events: all;
position: absolute; position: absolute;
line-height: 1; right: 0;
height: 16px;
width: 26px;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0);
text-align: center;
cursor: pointer;
// Vertically center the element, which is 16px high
margin-top: -8px;
.label {
@include reset.reset-box-model;
@include reset.reset-font;
background: none;
color: var.$color-text--light;
font-weight: bold;
font-family: var.$sans-font-family;
font-size: var.$annotator-bucket-bar-font-size;
line-height: var.$annotator-bucket-bar-line-height;
margin: 0 auto;
} }
&:before, .bucket-button {
&:after { // Need pointer events again. Necessary because of `pointer-events` rule
content: ''; // in `.annotator-bucket-bar`
right: 100%; pointer-events: all;
top: 50%;
position: absolute;
// NB: use of 'inset' here fixes jagged diagonals in FF
// https://github.com/zurb/foundation/issues/2230
border: inset transparent;
height: 0;
width: 0;
} }
&:before { .bucket-button--left {
border-width: 8px; // Center the indicator vertically (the element is 16px tall)
border-right: 5px solid var.$grey-3;
margin-top: -8px; margin-top: -8px;
@include buttons.indicator--left;
} }
&:after { .bucket-button--up {
border-width: 7px; @include buttons.indicator--up;
border-right: 4px solid var.$white; // Vertically center the element (which is 22px high)
margin-top: -7px;
}
&.lower,
&.upper {
@include utils.shadow;
z-index: 1;
&:before,
&:after {
left: 50%;
bottom: 100%;
right: auto;
border-right: solid transparent;
margin-top: 0;
}
& .label {
// Vertical alignment tweak to better center the label in the indicator
margin-top: -1px;
}
}
&.upper {
border-radius: 2px 2px 4px 4px;
// Vertically center the element (which is 22px high) by adding a negative
// top margin in conjunction with an inline style `top` position (set
// in code)
margin-top: -11px; margin-top: -11px;
&:before,
&:after {
top: auto;
bottom: 100%;
}
&:before {
border-width: 13px;
border-bottom: 6px solid var.$grey-3;
margin-left: -13px;
} }
&:after { .bucket-button--down {
border-width: 12px; @include buttons.indicator--down;
border-bottom: 5px solid var.$white;
margin-left: -12px;
}
}
&.lower {
margin-top: 0;
border-radius: 4px 4px 2px 2px;
&:before,
&:after {
bottom: auto;
top: 100%;
}
&:before {
border-width: 13px;
border-top: 6px solid var.$grey-3;
margin-left: -13px;
}
&:after {
border-width: 12px;
border-top: 5px solid var.$white;
margin-left: -12px;
}
}
} }
} }
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