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

Decaffeinate BucketBar

Make current set of tests pass
parent 9c82dbd0
......@@ -20,6 +20,7 @@ import iconSet from './icons';
registerIcons(iconSet);
import configFrom from './config/index';
import BucketBarPlugin from './plugin/bucket-bar';
import CrossFramePlugin from './plugin/cross-frame';
import DocumentPlugin from './plugin/document';
import Guest from './guest';
......@@ -27,11 +28,6 @@ import PDFPlugin from './plugin/pdf';
import PdfSidebar from './pdf-sidebar';
import Sidebar from './sidebar';
// Modules that are still written in CoffeeScript and need to be converted to
// JS.
// @ts-expect-error
import BucketBarPlugin from './plugin/bucket-bar';
const pluginClasses = {
// UI plugins
BucketBar: BucketBarPlugin,
......
$ = require('jquery')
{ default: Delegator } = require('../delegator')
scrollIntoView = require('scroll-into-view')
{ findClosestOffscreenAnchor, constructPositionPoints, buildBuckets } = require('../util/buckets')
highlighter = require('../highlighter')
BUCKET_SIZE = 16 # Regular bucket size
BUCKET_NAV_SIZE = BUCKET_SIZE + 6 # Bucket plus arrow (up/down)
BUCKET_TOP_THRESHOLD = 115 + BUCKET_NAV_SIZE # Toolbar
# Scroll to the next closest anchor off screen in the given direction.
scrollToClosest = (anchors, direction) ->
closest = findClosestOffscreenAnchor(anchors, direction)
scrollIntoView(closest.highlights[0])
module.exports = class BucketBar extends Delegator
# svg skeleton
html: """
<div class="annotator-bucket-bar">
</div>
"""
# buckets of annotations that overlap
buckets: []
# index for fast hit detection in the buckets
index: []
# tab elements
tabs: null
constructor: (element, options, annotator) ->
defaultOptions = {
# gapSize parameter is used by the clustering algorithm
# If an annotation is farther then this gapSize from the next bucket
# then that annotation will not be merged into the bucket
gapSize: 60
# Selectors for the scrollable elements on the page
scrollables: ['body']
}
super $(@html), Object.assign(defaultOptions, options)
if @options.container?
$(@options.container).append @element
else
$(element).append @element
@annotator = annotator
@highlighter = options.highlighter ? highlighter # Test seam.
$(window).on 'resize scroll', @update
for scrollable in @options.scrollables ? []
$(scrollable).on 'resize scroll', @update
destroy: ->
$(window).off 'resize scroll', @update
for scrollable in @options.scrollables ? []
$(scrollable).off 'resize scroll', @update
_collate: (a, b) ->
for i in [0..a.length-1]
if a[i] < b[i]
return -1
if a[i] > b[i]
return 1
return 0
# Update sometime soon
update: =>
return if @_updatePending?
@_updatePending = requestAnimationFrame =>
delete @_updatePending
@_update()
_update: ->
{above, below, points } = constructPositionPoints(@annotator.anchors)
fakeBuckets = buildBuckets(points)
# Accumulate the overlapping anchors into buckets.
# The algorithm goes like this:
# - Collate the points by sorting on position then delta (+1 or -1)
# - Reduce over the sorted points
# - For +1 points, add the anchor at this point to an array of
# "carried" anchors. If it already exists, increase the
# corresponding value in an array of counts which maintains the
# number of points that include this anchor.
# - For -1 points, decrement the value for the anchor at this point
# in the carried array of counts. If the count is now zero, remove the
# anchor from the carried array of anchors.
# - If this point is the first, last, sufficiently far from the previous,
# or there are no more carried anchors, add a bucket marker at this
# point.
# - Otherwise, if the last bucket was not isolated (the one before it
# has at least one anchor) then remove it and ensure that its
# anchors and the carried anchors are merged into the previous
# bucket.
{@buckets, @index} = points
.reduce ({buckets, index, carry}, [x, d, a], i, points) =>
if d > 0 # delta type is "top/start": Add annotation
if (j = carry.anchors.indexOf a) < 0 # If anchor not in carry
carry.anchors.unshift a # push on to front of carry
carry.counts.unshift 1 # initialize counts for this ... bucket?
else
carry.counts[j]++ # increment counts for this ...bucket?
else # delta type is "bottom/end": Remove annotation
j = carry.anchors.indexOf a # We're assuming this anchor is already in the carry-anchors...
if --carry.counts[j] is 0 # if carry.counts[j] === 1 — if the bucket with this anchor has exactly
# 1 thing in it
carry.anchors.splice j, 1 # Remove this anchor from `carry.anchors`
carry.counts.splice j, 1 # I'm guessing this..."closes" the last open anchor for now?
if (
(index.length is 0 or i is points.length - 1) or # Is this the first or last point?
carry.anchors.length is 0 or # A zero marker?
x - index[index.length-1] > @options.gapSize # A large gap?
) # Mark a new bucket.
buckets.push carry.anchors.slice()
index.push x
else
# Merge the previous bucket, making sure its predecessor contains
# all the carried anchors and the anchors in the previous
# bucket.
if buckets[buckets.length-2]?.length
last = buckets[buckets.length-2]
toMerge = buckets.pop()
index.pop()
else
last = buckets[buckets.length-1]
toMerge = []
last.push a0 for a0 in toMerge when a0 not in last
last.push a0 for a0 in carry.anchors when a0 not in last
{buckets, index, carry}
,
buckets: []
index: []
carry:
anchors: []
counts: []
latest: 0
@buckets.forEach (bucket, bi) =>
console.assert(@index[bi] is fakeBuckets.index[bi], 'index positions are equal')
bucket.forEach (anchor, ai) =>
console.assert(anchor is fakeBuckets.buckets[bi][ai], 'anchors are the same', anchor.annotation.$tag, fakeBuckets.buckets[bi][ai].annotation.$tag)
# Scroll up
@buckets.unshift [], above, []
@index.unshift 0, BUCKET_TOP_THRESHOLD - 1, BUCKET_TOP_THRESHOLD
# Scroll down
@buckets.push [], below, []
@index.push window.innerHeight - BUCKET_NAV_SIZE,
window.innerHeight - BUCKET_NAV_SIZE + 1,
window.innerHeight
# Calculate the total count for each bucket (without replies) and the
# maximum count.
max = 0
for b in @buckets
max = Math.max max, b.length
# Update the data bindings
element = @element
# Keep track of tabs to keep element creation to a minimum.
@tabs ||= $([])
# Remove any extra tabs and update @tabs.
@tabs.slice(@buckets.length).remove()
@tabs = @tabs.slice(0, @buckets.length)
# Create any new tabs if needed.
$.each @buckets.slice(@tabs.length), =>
div = $('<div/>').appendTo(element)
@tabs.push(div[0])
div.addClass('annotator-bucket-indicator')
# Focus corresponding highlights bucket when mouse is hovered
# TODO: This should use event delegation on the container.
.on 'mousemove', (event) =>
bucket = @tabs.index(event.currentTarget)
for anchor in @annotator.anchors
toggle = anchor in @buckets[bucket]
$(anchor.highlights).toggleClass('hypothesis-highlight-focused', toggle)
# Gets rid of them after
.on 'mouseout', (event) =>
bucket = @tabs.index(event.currentTarget)
for anchor in @buckets[bucket]
$(anchor.highlights).removeClass('hypothesis-highlight-focused')
# Does one of a few things when a tab is clicked depending on type
.on 'click', (event) =>
bucket = @tabs.index(event.currentTarget)
event.stopPropagation()
# If it's the upper tab, scroll to next anchor above
if (@isUpper bucket)
scrollToClosest(@buckets[bucket], 'up')
# If it's the lower tab, scroll to next anchor below
else if (@isLower bucket)
scrollToClosest(@buckets[bucket], 'down')
else
annotations = (anchor.annotation for anchor in @buckets[bucket])
@annotator.selectAnnotations annotations,
(event.ctrlKey or event.metaKey),
this._buildTabs(@tabs, @buckets)
_buildTabs: ->
@tabs.each (d, el) =>
el = $(el)
bucket = @buckets[d]
bucketLength = bucket?.length
title = if bucketLength != 1
"Show #{bucketLength} annotations"
else if bucketLength > 0
'Show one annotation'
el.attr('title', title)
el.toggleClass('upper', @isUpper(d))
el.toggleClass('lower', @isLower(d))
if @isUpper(d) or @isLower(d)
bucketSize = BUCKET_NAV_SIZE
else
bucketSize = BUCKET_SIZE
el.css({
top: (@index[d] + @index[d+1]) / 2
marginTop: -bucketSize / 2
display: unless bucketLength then 'none' else ''
})
if bucket
el.html("<div class='label'>#{bucketLength}</div>")
isUpper: (i) -> i == 1
isLower: (i) -> i == @index.length - 2
# Export constants
BucketBar.BUCKET_SIZE = BUCKET_SIZE
BucketBar.BUCKET_NAV_SIZE = BUCKET_NAV_SIZE
BucketBar.BUCKET_TOP_THRESHOLD = BUCKET_TOP_THRESHOLD
import $ from 'jquery';
import Delegator from '../delegator';
import scrollIntoView from 'scroll-into-view';
import {
findClosestOffscreenAnchor,
constructPositionPoints,
buildBuckets,
} from '../util/buckets';
const BUCKET_SIZE = 16; // Regular bucket size
const BUCKET_NAV_SIZE = BUCKET_SIZE + 6; // Bucket plus arrow (up/down)
const BUCKET_TOP_THRESHOLD = 115 + BUCKET_NAV_SIZE; // Toolbar
// 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 {
constructor(element, options, annotator) {
const defaultOptions = {
// gapSize parameter is used by the clustering algorithm
// If an annotation is farther then this gapSize from the next bucket
// then that annotation will not be merged into the bucket
// TODO: This is not currently used; reassess
gapSize: 60,
html: '<div class="annotator-bucket-bar"></div>',
// Selectors for the scrollable elements on the page
scrollables: ['body'],
};
const opts = { ...defaultOptions, ...options };
super($(opts.html), opts);
this.buckets = [];
this.index = [];
this.tabs = $([]);
if (this.options.container) {
$(this.options.container).append(this.element);
} else {
$(element).append(this.element);
}
this.annotator = annotator;
this.updateFunc = () => this.update();
$(window).on('resize scroll', this.updateFunc);
this.options.scrollables.forEach(scrollable => {
$(scrollable).on('scroll', this.updateFunc);
});
}
destroy() {
$(window).off('resize scroll', this.updateFunc);
this.options.scrollables.forEach(scrollable => {
$(scrollable).off('scroll', this.updateFunc);
});
}
update() {
if (this._updatePending) {
return;
}
this._updatePending = true;
requestAnimationFrame(() => {
const updated = this._update();
this._updatePending = false;
return updated;
});
}
_update() {
const { above, below, points } = constructPositionPoints(
this.annotator.anchors
);
const bucketInfo = buildBuckets(points);
this.buckets = bucketInfo.buckets;
this.index = bucketInfo.index;
// Scroll up
this.buckets.unshift([], above, []);
this.index.unshift(0, BUCKET_TOP_THRESHOLD - 1, BUCKET_TOP_THRESHOLD);
// Scroll down
this.buckets.push([], below, []);
this.index.push(
window.innerHeight - BUCKET_NAV_SIZE,
window.innerHeight - BUCKET_NAV_SIZE + 1,
window.innerHeight
);
// Remove any extra tabs and update tabs.
this.tabs.slice(this.buckets.length).remove();
this.tabs = this.tabs.slice(0, this.buckets.length);
// Create any new tabs if needed.
$.each(this.buckets.slice(this.tabs.length), () => {
const div = $('<div/>').appendTo(this.element);
this.tabs.push(div[0]);
div
.addClass('annotator-bucket-indicator')
// Focus corresponding highlights bucket when mouse is hovered
// TODO: This should use event delegation on the container.
.on('mousemove', event => {
const bucketIndex = this.tabs.index(event.currentTarget);
for (let anchor of this.annotator.anchors) {
const toggle = this.buckets[bucketIndex].includes(anchor);
$(anchor.highlights).toggleClass(
'hypothesis-highlight-focused',
toggle
);
}
})
.on('mouseout', event => {
const bucket = this.tabs.index(event.currentTarget);
this.buckets[bucket].forEach(anchor =>
$(anchor.highlights).removeClass('hypothesis-highlight-focused')
);
})
.on('click', event => {
const bucket = this.tabs.index(event.currentTarget);
event.stopPropagation();
// If it's the upper tab, scroll to next anchor above
if (this.isUpper(bucket)) {
scrollToClosest(this.buckets[bucket], 'up');
// If it's the lower tab, scroll to next anchor below
} else if (this.isLower(bucket)) {
scrollToClosest(this.buckets[bucket], 'down');
} else {
const annotations = this.buckets[bucket].map(
anchor => anchor.annotation
);
this.annotator.selectAnnotations(
annotations,
event.ctrlKey || event.metaKey
);
}
});
});
this._buildTabs();
}
_buildTabs() {
this.tabs.each((index, el) => {
let bucketSize;
el = $(el);
const bucket = this.buckets[index];
const bucketLength = bucket?.length;
const title = (() => {
if (bucketLength !== 1) {
return `Show ${bucketLength} annotations`;
} else if (bucketLength > 0) {
return 'Show one annotation';
}
return '';
})();
el.attr('title', title);
el.toggleClass('upper', this.isUpper(index));
el.toggleClass('lower', this.isLower(index));
if (this.isUpper(index) || this.isLower(index)) {
bucketSize = BUCKET_NAV_SIZE;
} else {
bucketSize = BUCKET_SIZE;
}
el.css({
top: (this.index[index] + this.index[index + 1]) / 2,
marginTop: -bucketSize / 2,
display: !bucketLength ? 'none' : '',
});
if (bucket) {
el.html(`<div class='label'>${bucketLength}</div>`);
}
});
}
isUpper(i) {
return i === 1;
}
isLower(i) {
return i === this.index.length - 2;
}
}
// Export constants
BucketBar.BUCKET_SIZE = BUCKET_SIZE;
BucketBar.BUCKET_NAV_SIZE = BUCKET_NAV_SIZE;
BucketBar.BUCKET_TOP_THRESHOLD = BUCKET_TOP_THRESHOLD;
import $ from 'jquery';
import BucketBar from '../bucket-bar';
import { $imports } from '../bucket-bar';
// Return DOM elements for non-empty bucket indicators in a `BucketBar`.
const nonEmptyBuckets = function (bucketBar) {
......@@ -13,22 +14,34 @@ const nonEmptyBuckets = function (bucketBar) {
};
const createMouseEvent = function (type, { ctrlKey, metaKey } = {}) {
// In a modern browser we could use `new MouseEvent` constructor and pass
// `ctrlKey` and `metaKey` via the init object.
const event = new Event(type);
event.ctrlKey = Boolean(ctrlKey);
event.metaKey = Boolean(metaKey);
return event;
return new MouseEvent(type, { ctrlKey, metaKey });
};
describe('BucketBar', function () {
let fakeAnnotator = null;
describe('BucketBar', () => {
let fakeAnnotator;
let fakeBucketUtil;
beforeEach(() => {
fakeAnnotator = {
anchors: [],
selectAnnotations: sinon.stub(),
};
fakeBucketUtil = {
findClosestOffscreenAnchor: sinon.stub(),
constructPositionPoints: sinon
.stub()
.returns({ above: [], below: [], points: [] }),
buildBuckets: sinon.stub().returns([]),
};
$imports.$mock({
'../util/buckets': fakeBucketUtil,
});
});
afterEach(() => {
$imports.$restore();
});
const createBucketBar = function (options) {
......@@ -46,43 +59,37 @@ describe('BucketBar', function () {
};
context('when a bucket is clicked', () => {
let bucketBar = null;
let fakeHighlighter = null;
let bucketBar;
beforeEach(() => {
fakeHighlighter = {
getBoundingClientRect() {
return { left: 0, top: 200, right: 200, bottom: 250 };
},
};
bucketBar = createBucketBar({ highlighter: fakeHighlighter });
bucketBar = createBucketBar();
// Create fake anchors and render buckets.
const anchors = [createAnchor()];
fakeBucketUtil.buildBuckets.returns({
index: [250],
buckets: [[anchors[0]]],
});
bucketBar.annotator.anchors = anchors;
return bucketBar._update();
bucketBar._update();
});
it.skip('selects the annotations', () => {
it('selects the annotations', () => {
// Click on the indicator for the non-empty bucket.
const bucketEls = nonEmptyBuckets(bucketBar);
assert.equal(bucketEls.length, 1);
bucketEls[0].dispatchEvent(createMouseEvent('click'));
const anns = bucketBar.annotator.anchors.map(anchor => anchor.annotation);
return assert.calledWith(
bucketBar.annotator.selectAnnotations,
anns,
false
);
assert.calledWith(bucketBar.annotator.selectAnnotations, anns, false);
});
return [
[
{ ctrlKey: true, metaKey: false },
{ ctrlKey: false, metaKey: true },
].forEach(({ ctrlKey, metaKey }) =>
it.skip('toggles selection of the annotations if Ctrl or Alt is pressed', () => {
it('toggles selection of the annotations if Ctrl or Alt is pressed', () => {
// Click on the indicator for the non-empty bucket.
const bucketEls = nonEmptyBuckets(bucketBar);
assert.equal(bucketEls.length, 1);
......@@ -93,11 +100,7 @@ describe('BucketBar', function () {
const anns = bucketBar.annotator.anchors.map(
anchor => anchor.annotation
);
return assert.calledWith(
bucketBar.annotator.selectAnnotations,
anns,
true
);
assert.calledWith(bucketBar.annotator.selectAnnotations, anns, true);
})
);
});
......@@ -107,7 +110,7 @@ describe('BucketBar', function () {
//
// Note: This could be tested using only the public APIs of the `BucketBar`
// class using the approach of the "when a bucket is clicked" tests above.
return describe('_buildTabs', function () {
describe('_buildTabs', () => {
const setup = function (tabs) {
const bucketBar = createBucketBar();
bucketBar.tabs = tabs;
......@@ -120,92 +123,92 @@ describe('BucketBar', function () {
return bucketBar;
};
it('creates a tab with a title', function () {
it('creates a tab with a title', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
bucketBar._buildTabs();
return assert.equal(tab.attr('title'), 'Show one annotation');
assert.equal(tab.attr('title'), 'Show one annotation');
});
it('creates a tab with a pluralized title', function () {
it('creates a tab with a pluralized title', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
bucketBar.buckets[0].push('Another Annotation?');
bucketBar._buildTabs();
return assert.equal(tab.attr('title'), 'Show 2 annotations');
assert.equal(tab.attr('title'), 'Show 2 annotations');
});
it('sets the tab text to the number of annotations', function () {
it('sets the tab text to the number of annotations', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
bucketBar.buckets[0].push('Another Annotation?');
bucketBar._buildTabs();
return assert.equal(tab.text(), '2');
assert.equal(tab.text(), '2');
});
it('sets the tab text to the number of annotations', function () {
it('sets the tab text to the number of annotations', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
bucketBar.buckets[0].push('Another Annotation?');
bucketBar._buildTabs();
return assert.equal(tab.text(), '2');
assert.equal(tab.text(), '2');
});
it('adds the class "upper" if the annotation is at the top', function () {
it('adds the class "upper" if the annotation is at the top', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
sinon.stub(bucketBar, 'isUpper').returns(true);
bucketBar._buildTabs();
return assert.equal(tab.hasClass('upper'), true);
assert.equal(tab.hasClass('upper'), true);
});
it('removes the class "upper" if the annotation is not at the top', function () {
it('removes the class "upper" if the annotation is not at the top', () => {
const tab = $('<div />').addClass('upper');
const bucketBar = setup(tab);
sinon.stub(bucketBar, 'isUpper').returns(false);
bucketBar._buildTabs();
return assert.equal(tab.hasClass('upper'), false);
assert.equal(tab.hasClass('upper'), false);
});
it('adds the class "lower" if the annotation is at the top', function () {
it('adds the class "lower" if the annotation is at the top', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
sinon.stub(bucketBar, 'isLower').returns(true);
bucketBar._buildTabs();
return assert.equal(tab.hasClass('lower'), true);
assert.equal(tab.hasClass('lower'), true);
});
it('removes the class "lower" if the annotation is not at the top', function () {
it('removes the class "lower" if the annotation is not at the top', () => {
const tab = $('<div />').addClass('lower');
const bucketBar = setup(tab);
sinon.stub(bucketBar, 'isLower').returns(false);
bucketBar._buildTabs();
return assert.equal(tab.hasClass('lower'), false);
assert.equal(tab.hasClass('lower'), false);
});
it('reveals the tab if there are annotations in the bucket', function () {
it('reveals the tab if there are annotations in the bucket', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
bucketBar._buildTabs();
return assert.equal(tab.css('display'), '');
assert.equal(tab.css('display'), '');
});
return it('hides the tab if there are no annotations in the bucket', function () {
it('hides the tab if there are no annotations in the bucket', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
bucketBar.buckets = [];
bucketBar._buildTabs();
return assert.equal(tab.css('display'), 'none');
assert.equal(tab.css('display'), 'none');
});
});
});
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