Commit a4d8fa7f authored by Robert Knight's avatar Robert Knight

Re-implement the sidebar's vertical toolbar

This commit re-implements the vertical toolbar on the left edge of the sidebar
to make future changes to the toolbar UI easier (eg. some upcoming a11y changes)
and to decouple it from the rest of the annotator application (the toolbar and
`Sidebar`/`Guest` classes currently access each other's internals in a rather
haphazard way).

The new implementation has a similar code structure to the `Adder` toolbar that is
shown when selecting text. It consists of:

 1. A Preact component in `src/annotator/components/toolbar`
    which renders the toolbar UI according to the current state

 2. A controller class in `src/annotator/toolbar.js` which renders the
    Preact component into a container element and has provides properties
    that the `Sidebar` and `Guest` classes can use to update its state.

    The controller takes only the container element and callbacks
    associated with each button as inputs, so it no longer calls methods
    on the Guest/Sidebar.

    The sidebar and guest now update the toolbar UI by setting properties
    on the toolbar controller, rather than using a mixture of manual
    manipulation of the toolbar DOM and publishing events which get
    eventually translated to method calls on the toolbar.

    If we convert the remainder of the sidebar UI from jQuery/manual DOM
    updates to Preact in future, then we can remove the toolbar
    controller entirely and just render the toolbar component from
    within the sidebar component.
parent a813d4e4
import { mount } from 'enzyme';
import { createElement } from 'preact';
import Toolbar from '../toolbar';
const noop = () => {};
describe('Toolbar', () => {
const createToolbar = props =>
mount(
<Toolbar
closeSidebar={noop}
createAnnotation={noop}
toggleHighlights={noop}
toggleSidebar={noop}
isSidebarOpen={false}
showHighlights={false}
newAnnotationType="note"
useMinimalControls={false}
{...props}
/>
);
const findButton = (wrapper, label) =>
wrapper.find(`button[title="${label}"]`);
it('renders nothing if `useMinimalControls` is true and the sidebar is closed', () => {
const wrapper = createToolbar({ useMinimalControls: true });
assert.isFalse(wrapper.find('button').exists());
});
it('renders only the Close button if `useMinimalControls` is true', () => {
const wrapper = createToolbar({
useMinimalControls: true,
isSidebarOpen: true,
});
assert.equal(wrapper.find('button').length, 1);
assert.isTrue(findButton(wrapper, 'Close annotation sidebar').exists());
});
it('renders the normal controls if `useMinimalControls` is false', () => {
const wrapper = createToolbar({ useMinimalControls: false });
assert.isFalse(findButton(wrapper, 'Close annotation sidebar').exists());
assert.isTrue(findButton(wrapper, 'Show annotation sidebar').exists());
assert.isTrue(findButton(wrapper, 'Show highlights').exists());
assert.isTrue(findButton(wrapper, 'New page note').exists());
});
it('shows the "New page note" button if `newAnnotationType` is `note`', () => {
const wrapper = createToolbar({ newAnnotationType: 'note' });
assert.isTrue(findButton(wrapper, 'New page note').exists());
});
it('shows the "New annotation" button if `newAnnotationType` is `annotation`', () => {
const wrapper = createToolbar({ newAnnotationType: 'annotation' });
assert.isTrue(findButton(wrapper, 'New annotation').exists());
});
it('toggles the sidebar when the sidebar toggle is clicked', () => {
const toggleSidebar = sinon.stub();
const wrapper = createToolbar({ isSidebarOpen: false, toggleSidebar });
findButton(wrapper, 'Show annotation sidebar').simulate('click');
assert.calledWith(toggleSidebar);
wrapper.setProps({ isSidebarOpen: true });
findButton(wrapper, 'Show annotation sidebar').simulate('click');
assert.calledWith(toggleSidebar);
});
it('toggles highlight visibility when the highlights toggle is clicked', () => {
const toggleHighlights = sinon.stub();
const wrapper = createToolbar({ showHighlights: false, toggleHighlights });
findButton(wrapper, 'Show highlights').simulate('click');
assert.calledWith(toggleHighlights);
wrapper.setProps({ showHighlights: true });
findButton(wrapper, 'Show highlights').simulate('click');
assert.calledWith(toggleHighlights);
});
});
import classnames from 'classnames';
import propTypes from 'prop-types';
import { createElement } from 'preact';
function ToolbarButton({
buttonRef,
extraClasses,
label,
icon,
onClick,
selected,
}) {
const handleClick = event => {
event.stopPropagation();
onClick();
};
return (
<button
// This currently uses the icon font. This needs to be converted to
// `SvgIcon`.
className={classnames('annotator-frame-button', extraClasses, icon)}
aria-label={label}
aria-pressed={selected}
onClick={handleClick}
ref={buttonRef}
title={label}
/>
);
}
ToolbarButton.propTypes = {
buttonRef: propTypes.any,
extraClasses: propTypes.string,
label: propTypes.string.isRequired,
icon: propTypes.string.isRequired,
onClick: propTypes.func.isRequired,
selected: propTypes.bool,
};
/**
* Controls on the edge of the sidebar for opening/closing the sidebar,
* controlling highlight visibility and creating new page notes.
*/
export default function Toolbar({
closeSidebar,
createAnnotation,
isSidebarOpen,
newAnnotationType,
showHighlights,
toggleHighlights,
toggleSidebar,
toggleSidebarRef,
useMinimalControls = false,
}) {
return (
<div>
{useMinimalControls && isSidebarOpen && (
<ToolbarButton
extraClasses="annotator-frame-button--sidebar_close"
label="Close annotation sidebar"
icon="h-icon-close"
onClick={closeSidebar}
/>
)}
{!useMinimalControls && (
<ToolbarButton
extraClasses="annotator-frame-button--sidebar_toggle"
buttonRef={toggleSidebarRef}
label="Show annotation sidebar"
icon={isSidebarOpen ? 'h-icon-chevron-right' : 'h-icon-chevron-left'}
selected={isSidebarOpen}
onClick={toggleSidebar}
/>
)}
{!useMinimalControls && (
<ToolbarButton
label="Show highlights"
icon={showHighlights ? 'h-icon-visibility' : 'h-icon-visibility-off'}
selected={showHighlights}
onClick={toggleHighlights}
/>
)}
{!useMinimalControls && (
<ToolbarButton
label={
newAnnotationType === 'note' ? 'New page note' : 'New annotation'
}
icon={
newAnnotationType === 'note' ? 'h-icon-note' : 'h-icon-annotate'
}
onClick={createAnnotation}
/>
)}
</div>
);
}
Toolbar.propTypes = {
/**
* Callback for the "Close sidebar" button. This button is only shown when
* `useMinimalControls` is true and the sidebar is open.
*/
closeSidebar: propTypes.func.isRequired,
/** Callback for the "Create page note" button. */
createAnnotation: propTypes.func.isRequired,
/** Is the sidebar currently visible? */
isSidebarOpen: propTypes.bool.isRequired,
newAnnotationType: propTypes.oneOf(['annotation', 'note']).isRequired,
/** Are highlights currently visible in the document? */
showHighlights: propTypes.bool.isRequired,
/** Callback to toggle visibility of highlights in the document. */
toggleHighlights: propTypes.func.isRequired,
/** Callback to toggle the visibility of the sidebar. */
toggleSidebar: propTypes.func.isRequired,
/**
* Ref that gets set to the toolbar button for toggling the sidebar.
* This is exposed to enable the drag-to-resize functionality of this
* button.
*/
toggleSidebarRef: propTypes.any,
/**
* If true, all controls are hidden except for the "Close sidebar" button
* when the sidebar is open.
*/
useMinimalControls: propTypes.bool,
};
......@@ -425,11 +425,7 @@ module.exports = class Guest extends Delegator
return
@selectedRanges = [range]
$('.annotator-toolbar .h-icon-note')
.attr('title', 'New Annotation')
.removeClass('h-icon-note')
.addClass('h-icon-annotate');
@toolbar?.newAnnotationType = 'note'
{left, top, arrowDirection} = this.adderCtrl.target(focusRect, isBackwards)
this.adderCtrl.annotationsForSelection = annotationsForSelection()
......@@ -438,11 +434,7 @@ module.exports = class Guest extends Delegator
_onClearSelection: () ->
this.adderCtrl.hide()
@selectedRanges = []
$('.annotator-toolbar .h-icon-annotate')
.attr('title', 'New Page Note')
.removeClass('h-icon-annotate')
.addClass('h-icon-note');
@toolbar?.newAnnotationType = 'annotation'
selectAnnotations: (annotations, toggle) ->
if toggle
......@@ -502,3 +494,4 @@ module.exports = class Guest extends Delegator
@element.removeClass(SHOW_HIGHLIGHTS_CLASS)
@visibleHighlights = shouldShowHighlights
@toolbar?.highlightsVisible = shouldShowHighlights
......@@ -24,13 +24,11 @@ import BucketBarPlugin from './plugin/bucket-bar';
import CrossFramePlugin from './plugin/cross-frame';
import DocumentPlugin from './plugin/document';
import PDFPlugin from './plugin/pdf';
import ToolbarPlugin from './plugin/toolbar';
import Sidebar from './sidebar';
const pluginClasses = {
// UI plugins
BucketBar: BucketBarPlugin,
Toolbar: ToolbarPlugin,
// Document type plugins
PDF: PDFPlugin,
......
import $ from 'jquery';
import Toolbar from '../toolbar';
describe('Toolbar', () => {
let container;
/**
* Fake implementation of the `annotator` property of the toolbar instance.
*/
let fakeAnnotator;
let currentToolbar;
function createToolbar() {
const toolbar = new Toolbar(container);
toolbar.annotator = fakeAnnotator;
toolbar.pluginInit();
currentToolbar = toolbar;
return toolbar;
}
function findButton(toolbar, title) {
return toolbar.element[0].querySelector(`[title="${title}"]`);
}
function isPressed(button) {
return button.getAttribute('aria-pressed') === 'true';
}
beforeEach(() => {
// The toolbar currently relies on a bunch of not-obviously public
// properties of the `Sidebar` instance, including the DOM structure :(
fakeAnnotator = {
createAnnotation: sinon.stub(),
frame: $('<div class="annotator-collapsed"></div>'),
hide: sinon.stub(),
options: {
showHighlights: 'always',
openSidebar: false,
},
setAllVisibleHighlights: sinon.stub(),
show: sinon.stub(),
visibleHighlights: true,
};
fakeAnnotator.show.callsFake(() =>
fakeAnnotator.frame.removeClass('annotator-collapsed')
);
fakeAnnotator.hide.callsFake(() =>
fakeAnnotator.frame.addClass('annotator-collapsed')
);
fakeAnnotator.setAllVisibleHighlights.callsFake(state => {
fakeAnnotator.visibleHighlights = state;
currentToolbar.publish('setVisibleHighlights', state);
});
container = document.createElement('div');
});
afterEach(() => {
container.remove();
});
it('shows button for opening and closing sidebar', () => {
const toolbar = createToolbar();
const button = findButton(toolbar, 'Toggle or Resize Sidebar');
assert.ok(button, 'open/close toggle button not found');
assert.isFalse(isPressed(button));
button.click();
assert.called(fakeAnnotator.show);
assert.isTrue(isPressed(button));
button.click();
assert.called(fakeAnnotator.hide);
assert.isFalse(isPressed(button));
});
// nb. The "Close Sidebar" button is only shown when using the "Clean" theme.
it('shows button for closing the sidebar', () => {
const toolbar = createToolbar();
const button = findButton(toolbar, 'Close Sidebar');
button.click();
assert.called(fakeAnnotator.hide);
});
it('shows open/close toggle button as pressed if sidebar is open on startup', () => {
fakeAnnotator.options.openSidebar = true;
const toolbar = createToolbar();
const button = findButton(toolbar, 'Toggle or Resize Sidebar');
assert.isTrue(isPressed(button));
});
it('shows button for toggling highlight visibility', () => {
const toolbar = createToolbar();
const button = findButton(toolbar, 'Toggle Highlights Visibility');
assert.ok(button, 'highlight visibility toggle button not found');
assert.isTrue(isPressed(button));
button.click();
assert.calledWith(fakeAnnotator.setAllVisibleHighlights, false);
assert.isFalse(isPressed(button));
button.click();
assert.calledWith(fakeAnnotator.setAllVisibleHighlights, true);
assert.isTrue(isPressed(button));
});
it('shows highlight button as un-pressed if highlights are hidden on startup', () => {
fakeAnnotator.options.showHighlights = 'never';
const toolbar = createToolbar();
const button = findButton(toolbar, 'Toggle Highlights Visibility');
assert.isFalse(isPressed(button));
});
it('shows button for creating new page notes', () => {
const toolbar = createToolbar();
const button = findButton(toolbar, 'New Page Note');
assert.ok(button, 'page note button not found');
button.click();
assert.called(fakeAnnotator.createAnnotation);
assert.called(fakeAnnotator.show);
});
});
Plugin = require('../plugin')
$ = require('jquery')
makeButton = (item) ->
anchor = $('<button></button>')
.attr('href', '')
.attr('title', item.title)
.attr('name', item.name)
.attr('aria-pressed', item.ariaPressed)
.on(item.on)
.addClass('annotator-frame-button')
.addClass(item.class)
button = $('<li></li>').append(anchor)
return button[0]
module.exports = class Toolbar extends Plugin
HIDE_CLASS = 'annotator-hide'
events:
'setVisibleHighlights': 'onSetVisibleHighlights'
html: '<div class="annotator-toolbar"></div>'
pluginInit: ->
@annotator.toolbar = @toolbar = $(@html)
if @options.container?
$(@options.container).append @toolbar
else
$(@element).append @toolbar
# Get the parsed configuration to determine the initial state of the buttons.
# nb. This duplicates state that lives elsewhere. To avoid it getting out
# of sync, it would be better if that initial state were passed in.
config = @annotator.options
highlightsAreVisible = config.showHighlights == 'always'
isSidebarOpen = config.openSidebar
items = [
"title": "Close Sidebar"
"class": "annotator-frame-button--sidebar_close h-icon-close"
"name": "sidebar-close"
"on":
"click": (event) =>
event.preventDefault()
event.stopPropagation()
@annotator.hide()
@toolbar.find('[name=sidebar-close]').hide();
,
"title": "Toggle or Resize Sidebar"
"ariaPressed": isSidebarOpen
"class": "annotator-frame-button--sidebar_toggle h-icon-chevron-left"
"name": "sidebar-toggle"
"on":
"click": (event) =>
event.preventDefault()
event.stopPropagation()
collapsed = @annotator.frame.hasClass('annotator-collapsed')
if collapsed
@annotator.show()
event.target.setAttribute('aria-pressed', true);
else
@annotator.hide()
event.target.setAttribute('aria-pressed', false);
,
"title": "Toggle Highlights Visibility"
"class": if highlightsAreVisible then 'h-icon-visibility' else 'h-icon-visibility-off'
"name": "highlight-visibility"
"ariaPressed": highlightsAreVisible
"on":
"click": (event) =>
event.preventDefault()
event.stopPropagation()
state = not @annotator.visibleHighlights
@annotator.setAllVisibleHighlights state
,
"title": "New Page Note"
"class": "h-icon-note"
"name": "insert-comment"
"on":
"click": (event) =>
event.preventDefault()
event.stopPropagation()
@annotator.createAnnotation()
@annotator.show()
]
@buttons = $(makeButton(item) for item in items)
list = $('<ul></ul>')
@buttons.appendTo(list)
@toolbar.append(list)
# Remove focus from the anchors when clicked, this removes the focus
# styles intended only for keyboard navigation. IE/FF apply the focus
# psuedo-class to a clicked element.
@toolbar.on('mouseup', 'a', (event) -> $(event.target).blur())
onSetVisibleHighlights: (state) ->
if state
@element.find('[name=highlight-visibility]')
.removeClass('h-icon-visibility-off')
.addClass('h-icon-visibility')
.attr('aria-pressed', 'true')
else
@element.find('[name=highlight-visibility]')
.removeClass('h-icon-visibility')
.addClass('h-icon-visibility-off')
.attr('aria-pressed', 'false')
disableMinimizeBtn: () ->
$('[name=sidebar-toggle]').remove();
disableHighlightsBtn: () ->
$('[name=highlight-visibility]').remove();
disableNewNoteBtn: () ->
$('[name=insert-comment]').remove();
disableCloseBtn: () ->
$('[name=sidebar-close]').remove();
getWidth: () ->
return parseInt(window.getComputedStyle(this.toolbar[0]).width)
hideCloseBtn: () ->
$('[name=sidebar-close]').hide();
showCloseBtn: () ->
$('[name=sidebar-close]').show();
showCollapseSidebarBtn: () ->
$('[name=sidebar-toggle]')
.removeClass('h-icon-chevron-left')
.addClass('h-icon-chevron-right')
showExpandSidebarBtn: () ->
$('[name=sidebar-toggle]')
.removeClass('h-icon-chevron-right')
.addClass('h-icon-chevron-left')
$ = require('jquery')
Hammer = require('hammerjs')
Host = require('./host')
......@@ -6,6 +7,8 @@ Host = require('./host')
{ default: events } = require('../shared/bridge-events')
{ default: features } = require('./features')
{ ToolbarController } = require('./toolbar')
# Minimum width to which the frame can be resized.
MIN_RESIZE = 280
......@@ -16,8 +19,6 @@ module.exports = class Sidebar extends Host
TextSelection: {}
BucketBar:
container: '.annotator-frame'
Toolbar:
container: '.annotator-frame'
renderFrame: null
gestureState: null
......@@ -25,30 +26,45 @@ module.exports = class Sidebar extends Host
constructor: (element, config) ->
if config.theme == 'clean' || config.externalContainerSelector
delete config.pluginClasses.BucketBar
# TODO - Make this work again.
if config.externalContainerSelector
delete config.pluginClasses.Toolbar
super
this.hide()
if config.openSidebar || config.annotations || config.query || config.group
this.on 'panelReady', => this.show()
if @plugins.BucketBar?
@plugins.BucketBar.element.on 'click', (event) => this.show()
if @plugins.Toolbar?
@toolbarWidth = @plugins.Toolbar.getWidth()
if config.theme == 'clean'
@plugins.Toolbar.disableMinimizeBtn()
@plugins.Toolbar.disableHighlightsBtn()
@plugins.Toolbar.disableNewNoteBtn()
toolbarContainer = document.createElement('div')
@toolbar = new ToolbarController(toolbarContainer, {
createAnnotation: => this.createAnnotation()
setSidebarOpen: (open) =>
if open
this.show()
else
@plugins.Toolbar.disableCloseBtn()
this.hide()
setHighlightsVisible: (show) => this.setAllVisibleHighlights(show)
})
@toolbar.useMinimalControls = config.theme == 'clean'
if @frame
# If using our own container frame for the sidebar, add the toolbar to
# it.
@frame.prepend(toolbarContainer)
@toolbarWidth = @toolbar.getWidth()
else
# If using a host-page provided container for the sidebar, the toolbar is
# not shown.
@toolbarWidth = 0
this._setupGestures()
this.hide()
# The partner-provided callback functions.
serviceConfig = config.services?[0]
if serviceConfig
......@@ -96,7 +112,7 @@ module.exports = class Sidebar extends Host
this
_setupGestures: ->
$toggle = @toolbar.find('[name=sidebar-toggle]')
$toggle = $(@toolbar.sidebarToggleButton)
if $toggle[0]
# Prevent any default gestures on the handle
......@@ -246,10 +262,7 @@ module.exports = class Sidebar extends Host
@frame.css 'margin-left': "#{-1 * @frame.width()}px"
@frame.removeClass 'annotator-collapsed'
if @plugins.Toolbar?
@plugins.Toolbar.showCollapseSidebarBtn();
@plugins.Toolbar.showCloseBtn();
@toolbar.sidebarOpen = true
if @options.showHighlights == 'whenSidebarOpen'
@setVisibleHighlights(true)
......@@ -261,9 +274,7 @@ module.exports = class Sidebar extends Host
@frame.css 'margin-left': ''
@frame.addClass 'annotator-collapsed'
if @plugins.Toolbar?
@plugins.Toolbar.hideCloseBtn();
@plugins.Toolbar.showExpandSidebarBtn();
@toolbar.sidebarOpen = false
if @options.showHighlights == 'whenSidebarOpen'
@setVisibleHighlights(false)
......@@ -279,6 +290,3 @@ module.exports = class Sidebar extends Host
setAllVisibleHighlights: (shouldShowHighlights) ->
@crossframe.call('setVisibleHighlights', shouldShowHighlights)
# Let the Toolbar know about this event
this.publish 'setVisibleHighlights', shouldShowHighlights
......@@ -15,12 +15,14 @@ describe 'Sidebar', ->
fakeCrossFrame = null
sidebarConfig = {pluginClasses: {}}
FakeToolbarController = null
fakeToolbar = null
before ->
sinon.stub(window, 'requestAnimationFrame').yields()
after ->
window.requestAnimationFrame.restore();
$imports.$restore()
createSidebar = (config={}) ->
config = Object.assign({}, sidebarConfig, config)
......@@ -41,37 +43,69 @@ describe 'Sidebar', ->
fakeCrossFrame.call = sandbox.spy()
fakeCrossFrame.destroy = sandbox.stub()
fakeToolbar = {}
fakeToolbar.disableMinimizeBtn = sandbox.spy()
fakeToolbar.disableHighlightsBtn = sandbox.spy()
fakeToolbar.disableNewNoteBtn = sandbox.spy()
fakeToolbar.disableCloseBtn = sandbox.spy()
fakeToolbar.hideCloseBtn = sandbox.spy()
fakeToolbar.showCloseBtn = sandbox.spy()
fakeToolbar.showExpandSidebarBtn = sandbox.spy()
fakeToolbar.showCollapseSidebarBtn = sandbox.spy()
fakeToolbar.getWidth = sandbox.stub()
fakeToolbar.destroy = sandbox.stub()
fakeToolbar = {
getWidth: sinon.stub().returns(100)
useMinimalControls: false,
sidebarOpen: false,
newAnnotationType: 'note',
highlightsVisible: false,
sidebarToggleButton: document.createElement('button')
}
FakeToolbarController = sinon.stub().returns(fakeToolbar)
fakeBucketBar = {}
fakeBucketBar.element = {on: sandbox.stub()}
fakeBucketBar.element = $('<div></div>')
fakeBucketBar.destroy = sandbox.stub()
CrossFrame = sandbox.stub()
CrossFrame.returns(fakeCrossFrame)
Toolbar = sandbox.stub()
Toolbar.returns(fakeToolbar)
BucketBar = sandbox.stub()
BucketBar.returns(fakeBucketBar)
sidebarConfig.pluginClasses['CrossFrame'] = CrossFrame
sidebarConfig.pluginClasses['Toolbar'] = Toolbar
sidebarConfig.pluginClasses['BucketBar'] = BucketBar
$imports.$mock({
'./toolbar': {
ToolbarController: FakeToolbarController
}
})
afterEach ->
sandbox.restore()
$imports.$restore();
describe 'toolbar buttons', ->
it 'shows or hides sidebar when toolbar button is clicked', ->
sidebar = createSidebar({})
sinon.stub(sidebar, 'show')
sinon.stub(sidebar, 'hide')
FakeToolbarController.args[0][1].setSidebarOpen(true)
assert.called(sidebar.show)
FakeToolbarController.args[0][1].setSidebarOpen(false)
assert.called(sidebar.hide)
it 'shows or hides highlights when toolbar button is clicked', ->
sidebar = createSidebar({})
sinon.stub(sidebar, 'setAllVisibleHighlights')
FakeToolbarController.args[0][1].setHighlightsVisible(true)
assert.calledWith(sidebar.setAllVisibleHighlights, true)
sidebar.setAllVisibleHighlights.resetHistory()
FakeToolbarController.args[0][1].setHighlightsVisible(false)
assert.calledWith(sidebar.setAllVisibleHighlights, false)
it 'creates an annotation when toolbar button is clicked', ->
sidebar = createSidebar({})
sinon.stub(sidebar, 'createAnnotation')
FakeToolbarController.args[0][1].createAnnotation()
assert.called(sidebar.createAnnotation)
describe 'crossframe listeners', ->
emitEvent = (event, args...) ->
......@@ -331,6 +365,12 @@ describe 'Sidebar', ->
sidebar.show()
assert.isFalse sidebar.visibleHighlights
it 'updates the toolbar', ->
sidebar = createSidebar()
sidebar.show()
assert.equal(fakeToolbar.sidebarOpen, true)
describe '#hide', ->
it 'hides highlights if "showHighlights" is set to "whenSidebarOpen"', ->
......@@ -341,32 +381,28 @@ describe 'Sidebar', ->
assert.isFalse sidebar.visibleHighlights
it 'updates the toolbar', ->
sidebar = createSidebar()
sidebar.show()
sidebar.hide()
assert.equal(fakeToolbar.sidebarOpen, false)
describe '#setAllVisibleHighlights', ->
it 'sets the state through crossframe and emits', ->
sidebar = createSidebar({})
sandbox.stub(sidebar, 'publish')
sidebar.setAllVisibleHighlights(true)
assert.calledWith(fakeCrossFrame.call, 'setVisibleHighlights', true)
assert.calledWith(sidebar.publish, 'setVisibleHighlights', true)
context 'Hide toolbar buttons', ->
it 'disables minimize btn for the clean theme', ->
sidebar = createSidebar(config={theme: 'clean'})
assert.called(sidebar.plugins.Toolbar.disableMinimizeBtn)
it 'disables toolbar highlights btn for the clean theme', ->
sidebar = createSidebar(config={theme: 'clean'})
assert.called(sidebar.plugins.Toolbar.disableHighlightsBtn)
it 'disables new note btn for the clean theme', ->
it 'hides toolbar controls when using the "clean" theme', ->
sidebar = createSidebar(config={theme: 'clean'})
assert.equal(fakeToolbar.useMinimalControls, true)
assert.called(sidebar.plugins.Toolbar.disableNewNoteBtn)
it 'shows toolbar controls when using the default theme', ->
createSidebar({})
assert.equal(fakeToolbar.useMinimalControls, false)
describe 'layout change notifier', ->
......@@ -374,7 +410,7 @@ describe 'Sidebar', ->
assertLayoutValues = (args, expectations) ->
expected = Object.assign {
width: DEFAULT_WIDTH,
width: DEFAULT_WIDTH + fakeToolbar.getWidth(),
height: DEFAULT_HEIGHT,
expanded: true
}, expectations
......@@ -419,18 +455,22 @@ describe 'Sidebar', ->
assert.calledTwice layoutChangeHandlerSpy
assertLayoutValues layoutChangeHandlerSpy.lastCall.args[0], {
expanded: false,
width: 0,
width: fakeToolbar.getWidth(),
}
it 'notifies when sidebar is panned left', ->
sidebar.gestureState = { initial: -DEFAULT_WIDTH }
sidebar.onPan({type: 'panleft', deltaX: -50})
assertLayoutValues layoutChangeHandlerSpy.lastCall.args[0], { width: 400 }
assertLayoutValues layoutChangeHandlerSpy.lastCall.args[0], {
width: DEFAULT_WIDTH + 50 + fakeToolbar.getWidth()
}
it 'notifies when sidebar is panned right', ->
sidebar.gestureState = { initial: -DEFAULT_WIDTH }
sidebar.onPan({type: 'panright', deltaX: 50})
assertLayoutValues layoutChangeHandlerSpy.lastCall.args[0], { width: 300 }
assertLayoutValues layoutChangeHandlerSpy.lastCall.args[0], {
width: DEFAULT_WIDTH - 50 + fakeToolbar.getWidth()
}
describe 'with the frame in an external container', ->
sidebar = null
......@@ -465,7 +505,10 @@ describe 'Sidebar', ->
it 'notifies when sidebar changes expanded state', ->
sidebar.show()
assert.calledOnce layoutChangeHandlerSpy
assertLayoutValues layoutChangeHandlerSpy.lastCall.args[0], {expanded: true}
assertLayoutValues layoutChangeHandlerSpy.lastCall.args[0], {
expanded: true
width: DEFAULT_WIDTH
}
sidebar.hide()
assert.calledTwice layoutChangeHandlerSpy
......@@ -498,7 +541,6 @@ describe 'Sidebar', ->
sidebar = createSidebar({ theme: 'clean' })
assert.isUndefined(sidebar.plugins.BucketBar)
it 'does not have the BucketBar or Toolbar plugin if an external container is provided', ->
it 'does not have the BucketBar if an external container is provided', ->
sidebar = createSidebar({ externalContainerSelector: '.' + EXTERNAL_CONTAINER_SELECTOR })
assert.isUndefined(sidebar.plugins.BucketBar)
assert.isUndefined(sidebar.plugins.Toolbar)
import { ToolbarController, $imports } from '../toolbar';
describe('ToolbarController', () => {
let toolbarProps;
let container;
const createToolbar = options => {
return new ToolbarController(container, {
...options,
});
};
beforeEach(() => {
container = document.createElement('div');
toolbarProps = {};
const FakeToolbar = props => {
toolbarProps = props;
return null;
};
$imports.$mock({
'./components/toolbar': FakeToolbar,
});
});
afterEach(() => {
$imports.$restore();
});
it('has expected default state', () => {
const controller = createToolbar();
assert.equal(controller.useMinimalControls, false);
assert.equal(controller.sidebarOpen, false);
assert.equal(controller.highlightsVisible, false);
assert.equal(controller.newAnnotationType, 'note');
});
it('re-renders when `useMinimalControls` changes', () => {
const controller = createToolbar();
assert.include(toolbarProps, {
useMinimalControls: false,
});
controller.useMinimalControls = true;
assert.include(toolbarProps, {
useMinimalControls: true,
});
});
it('re-renders when `sidebarOpen` changes', () => {
const controller = createToolbar();
assert.include(toolbarProps, {
isSidebarOpen: false,
});
controller.sidebarOpen = true;
assert.include(toolbarProps, {
isSidebarOpen: true,
});
});
it('re-renders when `highlightsVisible` changes', () => {
const controller = createToolbar();
assert.include(toolbarProps, {
showHighlights: false,
});
controller.highlightsVisible = true;
assert.include(toolbarProps, {
showHighlights: true,
});
});
it('re-renders when `newAnnotationType` changes', () => {
const controller = createToolbar();
assert.include(toolbarProps, {
newAnnotationType: 'note',
});
controller.newAnnotationType = 'annotation';
assert.include(toolbarProps, {
newAnnotationType: 'annotation',
});
});
it('toggles sidebar visibility', () => {
const setSidebarOpen = sinon.stub();
const controller = createToolbar({ setSidebarOpen });
toolbarProps.toggleSidebar();
assert.calledWith(setSidebarOpen, true);
controller.sidebarOpen = true;
toolbarProps.toggleSidebar();
assert.calledWith(setSidebarOpen, false);
});
it('closes the sidebar', () => {
const setSidebarOpen = sinon.stub();
const controller = createToolbar({ setSidebarOpen });
controller.useMinimalControls = true;
toolbarProps.closeSidebar();
assert.calledWith(setSidebarOpen, false);
});
it('toggles highlight visibility', () => {
const setHighlightsVisible = sinon.stub();
const controller = createToolbar({ setHighlightsVisible });
toolbarProps.toggleHighlights();
assert.calledWith(setHighlightsVisible, true);
controller.highlightsVisible = true;
toolbarProps.toggleHighlights();
assert.calledWith(setHighlightsVisible, false);
});
it('creates an annotation', () => {
const createAnnotation = sinon.stub();
const setSidebarOpen = sinon.stub();
createToolbar({ createAnnotation, setSidebarOpen });
toolbarProps.createAnnotation();
assert.called(createAnnotation);
assert.called(setSidebarOpen);
});
describe('#getWidth', () => {
it(`returns the toolbar's width`, () => {
assert.isNumber(createToolbar().getWidth());
});
});
describe('#sidebarToggleButton', () => {
it(`returns a reference to the sidebar toggle button`, () => {
const controller = createToolbar();
toolbarProps.toggleSidebarRef.current = 'a-button';
assert.equal(controller.sidebarToggleButton, 'a-button');
});
});
});
import { createElement, createRef, render } from 'preact';
import Toolbar from './components/toolbar';
/**
* @typedef ToolbarOptions
* @prop {() => any} createAnnotation
* @prop {(open: boolean) => any} setSidebarOpen
* @prop {(visible: boolean) => any} setHighlightsVisible
*/
/**
* Controller for the toolbar on the edge of the sidebar.
*
* This toolbar provides controls for opening and closing the sidebar, toggling
* highlight visibility etc.
*/
export class ToolbarController {
/**
* @param {HTMLElement} container - Element into which the toolbar is rendered
* @param {ToolbarOptions} options
*/
constructor(container, options) {
const { createAnnotation, setSidebarOpen, setHighlightsVisible } = options;
this._container = container;
this._container.className = 'annotator-toolbar';
this._useMinimalControls = false;
this._newAnnotationType = 'note';
this._highlightsVisible = false;
this._sidebarOpen = false;
this._closeSidebar = () => setSidebarOpen(false);
this._toggleSidebar = () => setSidebarOpen(!this._sidebarOpen);
this._toggleHighlights = () =>
setHighlightsVisible(!this._highlightsVisible);
this._createAnnotation = () => {
createAnnotation();
setSidebarOpen(true);
};
/** Reference to the sidebar toggle button. */
this._sidebarToggleButton = createRef();
this.render();
}
getWidth() {
return this._container.getBoundingClientRect().width;
}
/**
* Set whether the toolbar is in the "minimal controls" mode where
* only the "Close" button is shown.
*/
set useMinimalControls(minimal) {
this._useMinimalControls = minimal;
this.render();
}
get useMinimalControls() {
return this._useMinimalControls;
}
/**
* Update the toolbar to reflect whether the sidebar is open or not.
*/
set sidebarOpen(open) {
this._sidebarOpen = open;
this.render();
}
get sidebarOpen() {
return this._sidebarOpen;
}
/**
* Update the toolbar to reflect whether the "Create annotation" button will
* create a page note (if there is no selection) or an annotation (if there is
* a selection).
*/
set newAnnotationType(type) {
this._newAnnotationType = type;
this.render();
}
get newAnnotationType() {
return this._newAnnotationType;
}
/**
* Update the toolbar to reflect whether highlights are currently visible.
*/
set highlightsVisible(visible) {
this._highlightsVisible = visible;
this.render();
}
get highlightsVisible() {
return this._highlightsVisible;
}
/**
* Return the DOM element that toggles the sidebar's visibility.
*
* @type {HTMLButtonElement}
*/
get sidebarToggleButton() {
return this._sidebarToggleButton.current;
}
render() {
render(
<Toolbar
closeSidebar={this._closeSidebar}
createAnnotation={this._createAnnotation}
newAnnotationType={this._newAnnotationType}
isSidebarOpen={this._sidebarOpen}
showHighlights={this._highlightsVisible}
toggleHighlights={this._toggleHighlights}
toggleSidebar={this._toggleSidebar}
toggleSidebarRef={this._sidebarToggleButton}
useMinimalControls={this.useMinimalControls}
/>,
this._container
);
}
}
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