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 scrollIntoView from 'scroll-into-view';
import { setHighlightsFocused } from '../highlighter';
import { findClosestOffscreenAnchor, anchorBuckets } from '../util/buckets';
import { createElement, render } from 'preact';
import Buckets from '../components/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]);
}
}
import { anchorBuckets } from '../util/buckets';
export default class BucketBar extends Delegator {
constructor(element, options, annotator) {
......@@ -31,11 +20,6 @@ export default class BucketBar extends Delegator {
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
// `element` unless a `container` option was provided
let container = /** @type {HTMLElement} */ (element);
......@@ -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() {
if (this._updatePending) {
return;
......@@ -108,101 +76,17 @@ export default class BucketBar extends Delegator {
}
_update() {
this.buckets = anchorBuckets(this.annotator.anchors);
// The following affordances attempt to reuse existing DOM elements
// when reconstructing bucket "tabs" to cut down on the number of elements
// created and added to the DOM
// Only leave as many "tab" elements attached to the DOM as there are
// buckets
this.tabs.slice(this.buckets.length).forEach(tabEl => tabEl.remove());
// And cut the "tabs" collection down to the size of buckets, too
/** @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
const buckets = anchorBuckets(this.annotator.anchors);
render(
<Buckets
above={buckets.above}
below={buckets.below}
buckets={buckets.buckets}
onSelectAnnotations={(annotations, toggle) =>
this.annotator.selectAnnotations(annotations, toggle)
}
/>,
this.element
);
}
});
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';
* 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
* @prop {Anchor[]} anchors - The anchors in this bucket
......@@ -131,7 +140,7 @@ function getAnchorPositions(anchors) {
* Compute buckets
*
* @param {Anchor[]} anchors
* @return {Bucket[]}
* @return {BucketSet}
*/
export function anchorBuckets(anchors) {
const anchorPositions = getAnchorPositions(anchors);
......@@ -214,15 +223,20 @@ export function anchorBuckets(anchors) {
}
// Add an upper "navigation" bucket with offscreen-above anchors
buckets.unshift({
const above = {
anchors: Array.from(aboveScreen),
position: BUCKET_TOP_THRESHOLD,
});
};
// Add a lower "navigation" bucket with offscreen-below anchors
buckets.push({
const below = {
anchors: Array.from(belowScreen),
position: window.innerHeight - BUCKET_BOTTOM_THRESHOLD,
});
return buckets;
};
return {
above,
below,
buckets,
};
}
......@@ -114,57 +114,66 @@ describe('annotator/util/buckets', () => {
});
describe('anchorBuckets', () => {
it('puts anchors that are above the screen into the first bucket', () => {
const buckets = anchorBuckets(fakeAnchors);
assert.deepEqual(buckets[0].anchors, [fakeAnchors[0], fakeAnchors[1]]);
it('puts anchors that are above the screen into the `above` bucket', () => {
const bucketSet = anchorBuckets(fakeAnchors);
assert.deepEqual(bucketSet.above.anchors, [
fakeAnchors[0],
fakeAnchors[1],
]);
});
it('puts anchors that are below the screen into the last bucket', () => {
const buckets = anchorBuckets(fakeAnchors);
assert.deepEqual(buckets[buckets.length - 1].anchors, [
it('puts anchors that are below the screen into the `below` bucket', () => {
const bucketSet = anchorBuckets(fakeAnchors);
assert.deepEqual(bucketSet.below.anchors, [
fakeAnchors[4],
fakeAnchors[5],
]);
});
it('puts on-screen anchors into a bucket', () => {
const buckets = anchorBuckets(fakeAnchors);
assert.deepEqual(buckets[1].anchors, [fakeAnchors[2], fakeAnchors[3]]);
it('puts on-screen anchors into a buckets', () => {
const bucketSet = anchorBuckets(fakeAnchors);
assert.deepEqual(bucketSet.buckets[0].anchors, [
fakeAnchors[2],
fakeAnchors[3],
]);
});
it('puts anchors into separate buckets if more than 60px separates their boxes', () => {
fakeAnchors[2].highlights = [201, 15]; // bottom 216
fakeAnchors[3].highlights = [301, 15]; // top 301 - more than 60px from 216
const buckets = anchorBuckets(fakeAnchors);
assert.deepEqual(buckets[1].anchors, [fakeAnchors[2]]);
assert.deepEqual(buckets[2].anchors, [fakeAnchors[3]]);
const bucketSet = anchorBuckets(fakeAnchors);
assert.deepEqual(bucketSet.buckets[0].anchors, [fakeAnchors[2]]);
assert.deepEqual(bucketSet.buckets[1].anchors, [fakeAnchors[3]]);
});
it('puts overlapping anchors into a shared bucket', () => {
fakeAnchors[2].highlights = [201, 200]; // Bottom 401
fakeAnchors[3].highlights = [285, 100]; // Bottom 385
const buckets = anchorBuckets(fakeAnchors);
assert.deepEqual(buckets[1].anchors, [fakeAnchors[2], fakeAnchors[3]]);
const bucketSet = anchorBuckets(fakeAnchors);
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[3].highlights = [225, 75]; // Bottom 300
const buckets = anchorBuckets(fakeAnchors);
assert.equal(buckets[1].position, 250);
const bucketSet = anchorBuckets(fakeAnchors);
assert.equal(bucketSet.buckets[0].position, 250);
});
it('only buckets annotations that have highlights', () => {
const badAnchor = { highlights: [] };
fakeAnchors.push(badAnchor);
const buckets = anchorBuckets([badAnchor]);
assert.equal(buckets.length, 2);
assert.isEmpty(buckets[0].anchors); // Holder for above-screen anchors
assert.isEmpty(buckets[1].anchors); // Holder for below-screen anchors
const bucketSet = anchorBuckets([badAnchor]);
assert.equal(bucketSet.buckets.length, 0);
assert.isEmpty(bucketSet.above.anchors); // Holder for above-screen anchors
assert.isEmpty(bucketSet.below.anchors); // Holder for below-screen anchors
});
it('sorts anchors by top position', () => {
const buckets = anchorBuckets([
const bucketSet = anchorBuckets([
fakeAnchors[3],
fakeAnchors[2],
fakeAnchors[5],
......@@ -172,9 +181,18 @@ describe('annotator/util/buckets', () => {
fakeAnchors[0],
fakeAnchors[1],
]);
assert.deepEqual(buckets[0].anchors, [fakeAnchors[0], fakeAnchors[1]]);
assert.deepEqual(buckets[1].anchors, [fakeAnchors[2], fakeAnchors[3]]);
assert.deepEqual(buckets[2].anchors, [fakeAnchors[4], fakeAnchors[5]]);
assert.deepEqual(bucketSet.above.anchors, [
fakeAnchors[0],
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', () => {
......@@ -183,12 +201,15 @@ describe('annotator/util/buckets', () => {
fakeAnchors[3].highlights = [1100, 75];
fakeAnchors[4].highlights = [1200, 100];
fakeAnchors[5].highlights = [1300, 75];
const buckets = anchorBuckets(fakeAnchors);
assert.equal(buckets.length, 2);
const bucketSet = anchorBuckets(fakeAnchors);
assert.equal(bucketSet.buckets.length, 0);
// Above-screen
assert.deepEqual(buckets[0].anchors, [fakeAnchors[0], fakeAnchors[1]]);
assert.deepEqual(bucketSet.above.anchors, [
fakeAnchors[0],
fakeAnchors[1],
]);
// Below-screen
assert.deepEqual(buckets[1].anchors, [
assert.deepEqual(bucketSet.below.anchors, [
fakeAnchors[2],
fakeAnchors[3],
fakeAnchors[4],
......
@use "../mixins/buttons";
@use "../mixins/reset";
@use "../mixins/utils";
@use "../variables" as var;
......@@ -20,126 +21,31 @@
background: rgba(0, 0, 0, 0.08);
}
.annotator-bucket-indicator {
box-sizing: border-box;
background: var.$white;
border: solid 1px var.$grey-3;
border-radius: 2px 4px 4px 2px;
right: 0;
pointer-events: all;
.buckets,
.bucket {
position: absolute;
line-height: 1;
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;
right: 0;
}
&:before,
&:after {
content: '';
right: 100%;
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;
.bucket-button {
// Need pointer events again. Necessary because of `pointer-events` rule
// in `.annotator-bucket-bar`
pointer-events: all;
}
&:before {
border-width: 8px;
border-right: 5px solid var.$grey-3;
.bucket-button--left {
// Center the indicator vertically (the element is 16px tall)
margin-top: -8px;
@include buttons.indicator--left;
}
&:after {
border-width: 7px;
border-right: 4px solid var.$white;
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)
.bucket-button--up {
@include buttons.indicator--up;
// Vertically center the element (which is 22px high)
margin-top: -11px;
&:before,
&:after {
top: auto;
bottom: 100%;
}
&:before {
border-width: 13px;
border-bottom: 6px solid var.$grey-3;
margin-left: -13px;
}
&:after {
border-width: 12px;
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;
}
}
.bucket-button--down {
@include buttons.indicator--down;
}
}
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