From d693991679a77697379675978143385b29afb269 Mon Sep 17 00:00:00 2001 From: Sumedha Pramod Date: Fri, 26 May 2017 12:10:39 +0530 Subject: [PATCH] New: Initial mobile optimization of annotation dialogs (#146) - Annotations will still remain view-only --- src/lib/annotations/AnnotationDialog.js | 185 +++++++++++++----- src/lib/annotations/AnnotationThread.js | 15 +- src/lib/annotations/Annotator.js | 34 +++- src/lib/annotations/Annotator.scss | 4 +- src/lib/annotations/MobileAnnotator.scss | 126 ++++++++++++ .../__tests__/AnnotationDialog-test.html | 8 +- .../__tests__/AnnotationDialog-test.js | 121 +++++++++++- .../__tests__/AnnotationThread-test.js | 43 +++- .../annotations/__tests__/Annotator-test.js | 40 +++- src/lib/annotations/annotationConstants.js | 2 + src/lib/annotations/doc/DocAnnotator.js | 4 +- src/lib/annotations/doc/DocHighlightDialog.js | 162 ++++++++------- .../doc/__tests__/DocAnnotator-test.js | 1 + .../doc/__tests__/DocHighlightDialog-test.js | 110 ++++++++++- src/lib/annotations/image/ImageAnnotator.js | 1 + .../image/__tests__/ImageAnnotator-test.js | 1 + src/lib/viewers/BaseViewer.js | 6 +- src/lib/viewers/__tests__/BaseViewer-test.js | 4 +- src/lib/viewers/doc/DocBaseViewer.js | 8 +- .../doc/__tests__/DocBaseViewer-test.js | 6 + src/lib/viewers/image/ImageBaseViewer.js | 26 ++- src/lib/viewers/image/ImageViewer.js | 10 +- .../image/__tests__/ImageBaseViewer-test.js | 28 ++- .../image/__tests__/ImageViewer-test.js | 11 +- 24 files changed, 763 insertions(+), 193 deletions(-) create mode 100644 src/lib/annotations/MobileAnnotator.scss diff --git a/src/lib/annotations/AnnotationDialog.js b/src/lib/annotations/AnnotationDialog.js index 9e274f66c..3263925c1 100644 --- a/src/lib/annotations/AnnotationDialog.js +++ b/src/lib/annotations/AnnotationDialog.js @@ -4,7 +4,7 @@ import * as annotatorUtil from './annotatorUtil'; import * as constants from './annotationConstants'; import { CLASS_ACTIVE, CLASS_HIDDEN } from '../constants'; import { decodeKeydown } from '../util'; -import { ICON_DELETE } from '../icons/icons'; +import { ICON_CLOSE, ICON_DELETE } from '../icons/icons'; @autobind class AnnotationDialog extends EventEmitter { //-------------------------------------------------------------------------- @@ -64,6 +64,25 @@ import { ICON_DELETE } from '../icons/icons'; * @return {void} */ show() { + // Populate mobile annotations dialog with annotations information + if (this.isMobile) { + this.element = document.querySelector(`.${constants.CLASS_MOBILE_ANNOTATION_DIALOG}`); + annotatorUtil.showElement(this.element); + this.element.appendChild(this.dialogEl); + + if (this.highlightDialogEl && !this.hasComments) { + this.element.classList.add('bp-plain-highlight'); + + const headerEl = this.element.querySelector('.bp-annotation-mobile-header'); + headerEl.classList.add(CLASS_HIDDEN); + } + + const dialogCloseButtonEl = this.element.querySelector('.bp-annotation-dialog-close'); + dialogCloseButtonEl.addEventListener('click', this.hideMobileDialog); + + this.bindDOMListeners(); + } + const textAreaEl = this.hasAnnotations ? this.element.querySelector(constants.SELECTOR_REPLY_TEXTAREA) : this.element.querySelector(constants.SELECTOR_ANNOTATION_TEXTAREA); @@ -76,7 +95,9 @@ import { ICON_DELETE } from '../icons/icons'; // Position and show - we need to reposition every time since the DOM // could have changed from zooming - this.position(); + if (!this.isMobile) { + this.position(); + } // Activate appropriate textarea if (this.hasAnnotations) { @@ -102,6 +123,29 @@ import { ICON_DELETE } from '../icons/icons'; } } + /** + * Hides and resets the shared mobile dialog. + * + * @return {void} + */ + hideMobileDialog() { + if (!this.element) { + return; + } + + // Clear annotations from dialog + this.element.innerHTML = ` +
+ +
`.trim(); + this.element.classList.remove('bp-plain-highlight'); + + const dialogCloseButtonEl = this.element.querySelector('.bp-annotation-dialog-close'); + dialogCloseButtonEl.removeEventListener('click', this.hideMobileDialog); + + annotatorUtil.hideElement(this.element); + } + /** * Hides the dialog. * @@ -109,6 +153,9 @@ import { ICON_DELETE } from '../icons/icons'; * @return {void} */ hide() { + if (this.isMobile) { + this.hideMobileDialog(); + } annotatorUtil.hideElement(this.element); this.deactivateReply(); } @@ -171,52 +218,28 @@ import { ICON_DELETE } from '../icons/icons'; */ setup(annotations) { // Generate HTML of dialog - this.element = document.createElement('div'); - this.element.setAttribute('data-type', 'annotation-dialog'); - this.element.classList.add(constants.CLASS_ANNOTATION_DIALOG); - this.element.innerHTML = ` -
-
-
- -
- - -
-
-
-
-
- -
- - -
-
-
- `.trim(); + this.dialogEl = this.generateDialogEl(annotations.length); + this.dialogEl.classList.add('annotation-container'); + + if (!this.isMobile) { + this.element = document.createElement('div'); + this.element.setAttribute('data-type', 'annotation-dialog'); + this.element.classList.add(constants.CLASS_ANNOTATION_DIALOG); + this.element.innerHTML = '
'; + this.element.appendChild(this.dialogEl); + + // Adding thread number to dialog + if (annotations.length > 0) { + this.element.dataset.threadNumber = annotations[0].thread; + } - // Adding thread number to dialog - if (annotations.length > 0) { - this.element.dataset.threadNumber = annotations[0].thread; + this.bindDOMListeners(); } // Add annotation elements annotations.forEach((annotation) => { this.addAnnotationElement(annotation); }); - - this.bindDOMListeners(); } /** @@ -229,9 +252,12 @@ import { ICON_DELETE } from '../icons/icons'; this.element.addEventListener('keydown', this.keydownHandler); this.element.addEventListener('click', this.clickHandler); this.element.addEventListener('mouseup', this.stopPropagation); - this.element.addEventListener('mouseenter', this.mouseenterHandler); - this.element.addEventListener('mouseleave', this.mouseleaveHandler); this.element.addEventListener('wheel', this.stopPropagation); + + if (!this.isMobile) { + this.element.addEventListener('mouseenter', this.mouseenterHandler); + this.element.addEventListener('mouseleave', this.mouseleaveHandler); + } } /** @@ -244,9 +270,12 @@ import { ICON_DELETE } from '../icons/icons'; this.element.removeEventListener('keydown', this.keydownHandler); this.element.removeEventListener('click', this.clickHandler); this.element.removeEventListener('mouseup', this.stopPropagation); - this.element.removeEventListener('mouseenter', this.mouseenterHandler); - this.element.removeEventListener('mouseleave', this.mouseleaveHandler); this.element.removeEventListener('wheel', this.stopPropagation); + + if (!this.isMobile) { + this.element.removeEventListener('mouseenter', this.mouseenterHandler); + this.element.removeEventListener('mouseleave', this.mouseleaveHandler); + } } /** @@ -429,7 +458,7 @@ import { ICON_DELETE } from '../icons/icons';
`.trim(); - const annotationContainerEl = this.element.querySelector(constants.SELECTOR_COMMENTS_CONTAINER); + const annotationContainerEl = this.dialogEl.querySelector(constants.SELECTOR_COMMENTS_CONTAINER); annotationContainerEl.appendChild(annotationEl); } @@ -467,7 +496,11 @@ import { ICON_DELETE } from '../icons/icons'; * @return {void} */ activateReply() { - const replyTextEl = this.element.querySelector(constants.SELECTOR_REPLY_TEXTAREA); + if (!this.dialogEl) { + return; + } + + const replyTextEl = this.dialogEl.querySelector(constants.SELECTOR_REPLY_TEXTAREA); // Don't activate if reply textarea is already active const isActive = replyTextEl.classList.contains(CLASS_ACTIVE); @@ -480,8 +513,10 @@ import { ICON_DELETE } from '../icons/icons'; annotatorUtil.showElement(replyButtonEls); // Auto scroll annotations dialog to bottom where new comment was added - const annotationsEl = this.element.querySelector('.annotation-container'); - annotationsEl.scrollTop = annotationsEl.scrollHeight - annotationsEl.clientHeight; + const annotationsEl = this.dialogEl.querySelector('.annotation-container'); + if (annotationsEl) { + annotationsEl.scrollTop = annotationsEl.scrollHeight - annotationsEl.clientHeight; + } } /** @@ -492,12 +527,12 @@ import { ICON_DELETE } from '../icons/icons'; * @return {void} */ deactivateReply(clearText) { - if (!this.element) { + if (!this.dialogEl) { return; } - const replyTextEl = this.element.querySelector(constants.SELECTOR_REPLY_TEXTAREA); - const replyButtonEls = replyTextEl.parentNode.querySelector(constants.SELECTOR_BUTTON_CONTAINER); + const replyTextEl = this.dialogEl.querySelector(constants.SELECTOR_REPLY_TEXTAREA); + const replyButtonEls = this.dialogEl.querySelector(constants.SELECTOR_BUTTON_CONTAINER); annotatorUtil.resetTextarea(replyTextEl, clearText); annotatorUtil.hideElement(replyButtonEls); @@ -506,8 +541,10 @@ import { ICON_DELETE } from '../icons/icons'; } // Auto scroll annotations dialog to bottom where new comment was added - const annotationsEl = this.element.querySelector('.annotation-container'); - annotationsEl.scrollTop = annotationsEl.scrollHeight - annotationsEl.clientHeight; + const annotationsEl = this.dialogEl.querySelector('.annotation-container'); + if (annotationsEl) { + annotationsEl.scrollTop = annotationsEl.scrollHeight - annotationsEl.clientHeight; + } } /** @@ -570,6 +607,46 @@ import { ICON_DELETE } from '../icons/icons'; deleteAnnotation(annotationID) { this.emit('annotationdelete', { annotationID }); } + + /** + * Generates the annotation dialog DOM element + * + * @private + * @param {number} numAnnotations - length of annotations array + * @return {HTMLElement} Annotation dialog DOM element + */ + generateDialogEl(numAnnotations) { + const dialogEl = document.createElement('div'); + dialogEl.innerHTML = ` +
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
`.trim(); + return dialogEl; + } } export default AnnotationDialog; diff --git a/src/lib/annotations/AnnotationThread.js b/src/lib/annotations/AnnotationThread.js index 00b567302..271593fac 100644 --- a/src/lib/annotations/AnnotationThread.js +++ b/src/lib/annotations/AnnotationThread.js @@ -47,6 +47,7 @@ import { ICON_PLACED_ANNOTATION } from '../icons/icons'; this.thread = data.thread || ''; this.type = data.type; this.locale = data.locale; + this.isMobile = data.isMobile; this.setup(); } @@ -284,6 +285,10 @@ import { ICON_PLACED_ANNOTATION } from '../icons/icons'; this.createDialog(); this.bindCustomListenersOnDialog(); + if (this.dialog) { + this.dialog.isMobile = this.isMobile; + } + this.setupElement(); } @@ -311,7 +316,10 @@ import { ICON_PLACED_ANNOTATION } from '../icons/icons'; this.element.addEventListener('click', this.showDialog); this.element.addEventListener('mouseenter', this.showDialog); - this.element.addEventListener('mouseleave', this.mouseoutHandler); + + if (!this.isMobile) { + this.element.addEventListener('mouseleave', this.mouseoutHandler); + } } /** @@ -327,7 +335,10 @@ import { ICON_PLACED_ANNOTATION } from '../icons/icons'; this.element.removeEventListener('click', this.showDialog); this.element.removeEventListener('mouseenter', this.showDialog); - this.element.removeEventListener('mouseleave', this.mouseoutHandler); + + if (!this.isMobile) { + this.element.removeEventListener('mouseleave', this.mouseoutHandler); + } } /** diff --git a/src/lib/annotations/Annotator.js b/src/lib/annotations/Annotator.js index cc6e8ba79..f2d4429c4 100644 --- a/src/lib/annotations/Annotator.js +++ b/src/lib/annotations/Annotator.js @@ -4,7 +4,8 @@ import Notification from '../Notification'; import AnnotationService from './AnnotationService'; import * as constants from './annotationConstants'; import * as annotatorUtil from './annotatorUtil'; -import { CLASS_ACTIVE, SELECTOR_BOX_PREVIEW_BTN_ANNOTATE } from '../constants'; +import { CLASS_ACTIVE, SELECTOR_BOX_PREVIEW_BTN_ANNOTATE, CLASS_HIDDEN } from '../constants'; +import { ICON_CLOSE } from '../icons/icons'; import './Annotator.scss'; @autobind class Annotator extends EventEmitter { @@ -39,6 +40,7 @@ import './Annotator.scss'; this.fileVersionId = data.fileVersionId; this.locale = data.locale; this.validationErrorDisplayed = false; + this.isMobile = data.isMobile; } /** @@ -77,11 +79,37 @@ import './Annotator.scss'; canAnnotate: this.canAnnotate }); + // Set up mobile annotations dialog + if (this.isMobile) { + this.setupMobileDialog(); + } + this.setScale(1); this.setupAnnotations(); this.showAnnotations(); } + /** + * Sets up the shared mobile dialog element. + * + * @return {void} + */ + setupMobileDialog() { + // Generate HTML of dialog + const mobileDialogEl = document.createElement('div'); + mobileDialogEl.setAttribute('data-type', 'annotation-dialog'); + mobileDialogEl.classList.add(constants.CLASS_MOBILE_ANNOTATION_DIALOG); + mobileDialogEl.classList.add(constants.CLASS_ANNOTATION_DIALOG); + mobileDialogEl.classList.add(CLASS_HIDDEN); + + mobileDialogEl.innerHTML = ` +
+ +
`.trim(); + + this.container.appendChild(mobileDialogEl); + } + /** * Fetches and shows saved annotations. * @@ -207,7 +235,7 @@ import './Annotator.scss'; if (this.isInPointMode()) { this.notification.hide(); - this.emit('pointmodeexit'); + this.emit('annotationmodeexit'); this.annotatedElement.classList.remove(constants.CLASS_ANNOTATION_POINT_MODE); if (buttonEl) { buttonEl.classList.remove(CLASS_ACTIVE); @@ -220,7 +248,7 @@ import './Annotator.scss'; } else { this.notification.show(__('notification_annotation_mode')); - this.emit('pointmodeenter'); + this.emit('annotationmodeenter'); this.annotatedElement.classList.add(constants.CLASS_ANNOTATION_POINT_MODE); if (buttonEl) { buttonEl.classList.add(CLASS_ACTIVE); diff --git a/src/lib/annotations/Annotator.scss b/src/lib/annotations/Annotator.scss index 72a16e3fc..a5e756814 100644 --- a/src/lib/annotations/Annotator.scss +++ b/src/lib/annotations/Annotator.scss @@ -108,7 +108,6 @@ $avatar-color-9: #f22c44; cursor: default; // Overrides cursor: none from annotation mode position: absolute; text-align: left; - width: 282px; // Hard-coded width to solve layout issues z-index: 9999; // Annotation dialog should be above other content .annotation-container { @@ -119,6 +118,7 @@ $avatar-color-9: #f22c44; overflow-y: auto; padding: 15px; white-space: normal; + width: 282px; // Hard-coded width to solve layout issues } .bp-textarea { @@ -438,3 +438,5 @@ $avatar-color-9: #f22c44; .bp-point-annotation-mode .textLayer .endOfContent { pointer-events: none; } + +@import './MobileAnnotator'; diff --git a/src/lib/annotations/MobileAnnotator.scss b/src/lib/annotations/MobileAnnotator.scss new file mode 100644 index 000000000..9b65621f7 --- /dev/null +++ b/src/lib/annotations/MobileAnnotator.scss @@ -0,0 +1,126 @@ +.bp-mobile-annotation-dialog { + background: white; + border-top: 0; + height: 100%; + top: 0; + width: 100%; // Hard-coded width to solve layout issues +} + +.bp-mobile-annotation-dialog.bp-annotation-dialog { + .annotation-container { + background-color: $white; + border: 0; + border-radius: 4px; + height: 100%; + overflow-x: hidden; + overflow-y: auto; + padding: 15px 15px 60px; + position: absolute; + width: 100%; + } + + .bp-annotation-mobile-header { + align-items: center; + background-color: $white; + border-bottom: 1px solid #ccc; + display: flex; + height: 48px; + justify-content: space-between; + padding: 0; + } + + .bp-annotation-dialog-close { + background: none; + border: 0; + display: inline-block; + height: 48px; + line-height: 1; + margin-top: -1px; + padding: 3px; + vertical-align: middle; + width: 48px; + + .icon { + fill: fade-out($better-black, .25); + } + + &:hover .icon { + fill: $better-black; + } + } + + .bp-textarea { + font-size: 16px; + line-height: 16px; + min-width: 100%; + } + + .comment-text { + width: 100%; + } + + .profile-image-container { + width: 43px; + + img { + height: 40px; + width: 40px; + } + } + + .profile-container { + .user-name { + font-size: 16px; + } + + .comment-date { + font-size: 14px; + } + } + + .bp-btn { + font-size: 16; + } + + .comment-text, + .delete-confirmation-message { + font-size: 15px; + } + + .delete-comment-btn { + svg { + height: 24px; + width: 24px; + } + } +} + +/* Highlight dialog */ +.bp-mobile-annotation-dialog.bp-plain-highlight { + border-bottom: 1px solid #ccc; + height: 47px; // includes mobile header & highlight dialog + top: auto; +} + +.bp-mobile-annotation-dialog .bp-annotation-highlight-dialog { + border: none; + color: $fours; + font-size: 16px; + line-height: 16px; + min-width: 100%; + padding: 15px; + position: absolute; + text-align: center; + z-index: 9999; + + .bp-annotations-highlight-btns button { + width: 50%; + } + + &.cannot-annotate { + .bp-add-highlight-btn, + .bp-highlight-comment-btn { + display: none; + } + } +} diff --git a/src/lib/annotations/__tests__/AnnotationDialog-test.html b/src/lib/annotations/__tests__/AnnotationDialog-test.html index 5ed98fbe4..2d4a1caa3 100644 --- a/src/lib/annotations/__tests__/AnnotationDialog-test.html +++ b/src/lib/annotations/__tests__/AnnotationDialog-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/annotations/__tests__/AnnotationDialog-test.js b/src/lib/annotations/__tests__/AnnotationDialog-test.js index 825505b25..d85c23a5c 100644 --- a/src/lib/annotations/__tests__/AnnotationDialog-test.js +++ b/src/lib/annotations/__tests__/AnnotationDialog-test.js @@ -27,6 +27,7 @@ describe('lib/annotations/AnnotationDialog', () => { document.querySelector('.annotated-element').appendChild(dialog.element); stubs.emit = sandbox.stub(dialog, 'emit'); + dialog.isMobile = false; }); afterEach(() => { @@ -135,6 +136,45 @@ describe('lib/annotations/AnnotationDialog', () => { expect(textArea).to.have.class(CLASS_ACTIVE); expect(dialog.activateReply).to.not.be.called; }); + + it('should populate the mobile dialog if using a mobile browser', () => { + dialog.isMobile = true; + dialog.highlightDialogEl = null; + stubs.show = sandbox.stub(annotatorUtil, 'showElement'); + stubs.bind = sandbox.stub(dialog, 'bindDOMListeners'); + + dialog.show(); + expect(stubs.show).to.be.calledWith(dialog.element); + expect(stubs.bind).to.be.called; + expect(dialog.position).to.not.be.called; + }); + + it('should hide the mobile header if a plain highlight', () => { + dialog.isMobile = true; + dialog.highlightDialogEl = {}; + dialog.hasComments = false; + stubs.show = sandbox.stub(annotatorUtil, 'showElement'); + stubs.bind = sandbox.stub(dialog, 'bindDOMListeners'); + + dialog.show(); + expect(dialog.element).to.have.class('bp-plain-highlight'); + }); + }); + + describe('hideMobileDialog()', () => { + it('should do nothing if the dialog element does not exist', () => { + dialog.element = null; + stubs.hide = sandbox.stub(annotatorUtil, 'hideElement'); + dialog.hideMobileDialog(); + expect(stubs.hide).to.not.be.called; + }); + + it('should hide and reset the mobile annotations dialog', () => { + dialog.element = document.querySelector('.bp-mobile-annotation-dialog'); + stubs.hide = sandbox.stub(annotatorUtil, 'hideElement'); + dialog.hideMobileDialog(); + expect(stubs.hide).to.be.called; + }); }); describe('hide()', () => { @@ -142,6 +182,13 @@ describe('lib/annotations/AnnotationDialog', () => { dialog.hide(); expect(dialog.element).to.have.class(CLASS_HIDDEN); }); + + it('should hide the mobile dialog if using a mobile browser', () => { + dialog.isMobile = true; + sandbox.stub(dialog, 'hideMobileDialog'); + dialog.hide(); + expect(dialog.hideMobileDialog).to.be.called; + }); }); describe('addAnnotation()', () => { @@ -202,10 +249,13 @@ describe('lib/annotations/AnnotationDialog', () => { }); describe('setup()', () => { - it('should set up HTML element, add annotations to the dialog, and bind DOM listeners', () => { + beforeEach(() => { + const dialogEl = document.createElement('div'); + sandbox.stub(dialog, 'generateDialogEl').returns(dialogEl); stubs.bind = sandbox.stub(dialog, 'bindDOMListeners'); + stubs.add = sandbox.stub(dialog, 'addAnnotationElement'); - const annotationData = new Annotation({ + stubs.annotation = new Annotation({ annotationID: 'someID', text: 'blah', user: {}, @@ -213,9 +263,12 @@ describe('lib/annotations/AnnotationDialog', () => { thread: 1 }); - dialog.setup([annotationData]); + dialog.isMobile = false; + }); + + it('should set up HTML element, add annotations to the dialog, and bind DOM listeners', () => { + dialog.setup([stubs.annotation]); expect(dialog.element).to.not.be.null; - expect(dialog.element.querySelector(['[data-annotation-id="someID"]'])).to.not.be.null; expect(dialog.element.dataset.threadNumber).to.equal('1'); expect(stubs.bind).to.be.called; }); @@ -224,6 +277,13 @@ describe('lib/annotations/AnnotationDialog', () => { dialog.setup([]); expect(dialog.element.dataset.threadNumber).to.be.undefined; }); + + it('should not create dialog element if using a mobile browser', () => { + dialog.isMobile = true; + dialog.setup([stubs.annotation, stubs.annotation]); + expect(stubs.bind).to.not.be.called; + expect(stubs.add).to.be.calledTwice; + }); }); describe('bindDOMListeners()', () => { @@ -238,6 +298,19 @@ describe('lib/annotations/AnnotationDialog', () => { expect(stubs.add).to.be.calledWith('mouseleave', sinon.match.func); expect(stubs.add).to.be.calledWith('wheel', sinon.match.func); }); + + it('should not bind mouseenter/leave events for mobile browsers', () => { + stubs.add = sandbox.stub(dialog.element, 'addEventListener'); + dialog.isMobile = true; + + dialog.bindDOMListeners(); + expect(stubs.add).to.be.calledWith('keydown', sinon.match.func); + expect(stubs.add).to.be.calledWith('click', sinon.match.func); + expect(stubs.add).to.be.calledWith('mouseup', sinon.match.func); + expect(stubs.add).to.not.be.calledWith('mouseenter', sinon.match.func); + expect(stubs.add).to.not.be.calledWith('mouseleave', sinon.match.func); + expect(stubs.add).to.be.calledWith('wheel', sinon.match.func); + }); }); describe('unbindDOMListeners()', () => { @@ -252,6 +325,19 @@ describe('lib/annotations/AnnotationDialog', () => { expect(stubs.remove).to.be.calledWith('mouseleave', sinon.match.func); expect(stubs.remove).to.be.calledWith('wheel', sinon.match.func); }); + + it('should not bind mouseenter/leave events for mobile browsers', () => { + stubs.remove = sandbox.stub(dialog.element, 'removeEventListener'); + dialog.isMobile = true; + + dialog.unbindDOMListeners(); + expect(stubs.remove).to.be.calledWith('keydown', sinon.match.func); + expect(stubs.remove).to.be.calledWith('click', sinon.match.func); + expect(stubs.remove).to.be.calledWith('mouseup', sinon.match.func); + expect(stubs.remove).to.not.be.calledWith('mouseenter', sinon.match.func); + expect(stubs.remove).to.not.be.calledWith('mouseleave', sinon.match.func); + expect(stubs.remove).to.be.calledWith('wheel', sinon.match.func); + }); }); describe('keydownHandler()', () => { @@ -575,6 +661,13 @@ describe('lib/annotations/AnnotationDialog', () => { }); describe('activateReply()', () => { + it('should do nothing if the dialogEl does not exist', () => { + dialog.dialogEl = null; + sandbox.stub(annotatorUtil, 'showElement'); + dialog.activateReply(); + expect(annotatorUtil.showElement).to.not.be.called; + }); + it('should do nothing if reply textarea is already active', () => { const replyTextEl = dialog.element.querySelector(constants.SELECTOR_REPLY_TEXTAREA); replyTextEl.classList.add('bp-is-active'); @@ -603,7 +696,7 @@ describe('lib/annotations/AnnotationDialog', () => { describe('deactivateReply()', () => { it('should do nothing if element does not exist', () => { - dialog.element = null; + dialog.dialogEl = null; sandbox.stub(annotatorUtil, 'resetTextarea'); dialog.deactivateReply(); @@ -729,4 +822,22 @@ describe('lib/annotations/AnnotationDialog', () => { expect(stubs.emit).to.be.calledWith('annotationdelete', { annotationID: 1 }); }); }); + + describe('generateDialogEl', () => { + it('should generate a blank annotations dialog element', () => { + const dialogEl = dialog.generateDialogEl(0); + const createSectionEl = dialogEl.querySelector('[data-section="create"]'); + const showSectionEl = dialogEl.querySelector('[data-section="show"]'); + expect(createSectionEl).to.not.have.class(CLASS_HIDDEN); + expect(showSectionEl).to.have.class(CLASS_HIDDEN); + }); + + it('should generate an annotations dialog element with annotations', () => { + const dialogEl = dialog.generateDialogEl(1); + const createSectionEl = dialogEl.querySelector('[data-section="create"]'); + const showSectionEl = dialogEl.querySelector('[data-section="show"]'); + expect(createSectionEl).to.have.class(CLASS_HIDDEN); + expect(showSectionEl).to.not.have.class(CLASS_HIDDEN); + }); + }); }); diff --git a/src/lib/annotations/__tests__/AnnotationThread-test.js b/src/lib/annotations/__tests__/AnnotationThread-test.js index d95c23b4a..968918827 100644 --- a/src/lib/annotations/__tests__/AnnotationThread-test.js +++ b/src/lib/annotations/__tests__/AnnotationThread-test.js @@ -22,6 +22,7 @@ describe('lib/annotations/AnnotationThread', () => { annotations: [], annotationService: {}, fileVersionId: '1', + isMobile: false, location: {}, threadID: '2', thread: '1', @@ -184,6 +185,7 @@ describe('lib/annotations/AnnotationThread', () => { annotations: [stubs.annotation], annotationService, fileVersionId: '1', + isMobile: false, location: {}, threadID: '2', thread: '1', @@ -305,10 +307,12 @@ describe('lib/annotations/AnnotationThread', () => { }); it('should setup dialog', () => { + thread.dialog = {}; thread.setup(); expect(stubs.create).to.be.called; expect(stubs.bind).to.be.called; expect(stubs.setup).to.be.called; + expect(thread.dialog.isMobile).to.equal(thread.isMobile); }); it('should set state to pending if thread is initialized with no annotations', () => { @@ -322,6 +326,7 @@ describe('lib/annotations/AnnotationThread', () => { annotations: [{}], annotationService: {}, fileVersionId: '1', + isMobile: false, location: {}, threadID: '2', thread: '1', @@ -345,27 +350,61 @@ describe('lib/annotations/AnnotationThread', () => { }); describe('bindDOMListeners()', () => { - it('should bind DOM listeners', () => { + beforeEach(() => { thread.element = document.createElement('div'); stubs.add = sandbox.stub(thread.element, 'addEventListener'); + thread.isMobile = false; + }); + it('should do nothing if element does not exist', () => { + thread.element = null; + thread.bindDOMListeners(); + expect(stubs.add).to.not.be.called; + }); + + it('should bind DOM listeners', () => { thread.bindDOMListeners(); expect(stubs.add).to.be.calledWith('click', sinon.match.func); expect(stubs.add).to.be.calledWith('mouseenter', sinon.match.func); expect(stubs.add).to.be.calledWith('mouseleave', sinon.match.func); }); + + it('should not add mouseleave listener for mobile browsers', () => { + thread.isMobile = true; + thread.bindDOMListeners(); + expect(stubs.add).to.be.calledWith('click', sinon.match.func); + expect(stubs.add).to.be.calledWith('mouseenter', sinon.match.func); + expect(stubs.add).to.not.be.calledWith('mouseleave', sinon.match.func); + }); }); describe('unbindDOMListeners()', () => { - it('should unbind DOM listeners', () => { + beforeEach(() => { thread.element = document.createElement('div'); stubs.remove = sandbox.stub(thread.element, 'removeEventListener'); + thread.isMobile = false; + }); + + it('should do nothing if element does not exist', () => { + thread.element = null; + thread.unbindDOMListeners(); + expect(stubs.remove).to.not.be.called; + }); + it('should unbind DOM listeners', () => { thread.unbindDOMListeners(); expect(stubs.remove).to.be.calledWith('click', sinon.match.func); expect(stubs.remove).to.be.calledWith('mouseenter', sinon.match.func); expect(stubs.remove).to.be.calledWith('mouseleave', sinon.match.func); }); + + it('should not add mouseleave listener for mobile browsers', () => { + thread.isMobile = true; + thread.unbindDOMListeners(); + expect(stubs.remove).to.be.calledWith('click', sinon.match.func); + expect(stubs.remove).to.be.calledWith('mouseenter', sinon.match.func); + expect(stubs.remove).to.not.be.calledWith('mouseleave', sinon.match.func); + }); }); describe('bindCustomListenersOnDialog()', () => { diff --git a/src/lib/annotations/__tests__/Annotator-test.js b/src/lib/annotations/__tests__/Annotator-test.js index 7637f9ffc..e5823b1b8 100644 --- a/src/lib/annotations/__tests__/Annotator-test.js +++ b/src/lib/annotations/__tests__/Annotator-test.js @@ -21,6 +21,7 @@ describe('lib/annotations/Annotator', () => { container: document, annotationService: {}, fileVersionId: 1, + isMobile: false, options: {} }); @@ -85,21 +86,40 @@ describe('lib/annotations/Annotator', () => { }); describe('init()', () => { - it('should set scale and setup annotations', () => { + beforeEach(() => { const annotatedEl = document.querySelector('.annotated-element'); sandbox.stub(annotator, 'getAnnotatedEl').returns(annotatedEl); - const scaleStub = sandbox.stub(annotator, 'setScale'); - const setupAnnotations = sandbox.stub(annotator, 'setupAnnotations'); - const showAnnotations = sandbox.stub(annotator, 'showAnnotations'); + + stubs.scale = sandbox.stub(annotator, 'setScale'); + stubs.setup = sandbox.stub(annotator, 'setupAnnotations'); + stubs.show = sandbox.stub(annotator, 'showAnnotations'); + stubs.setupMobileDialog = sandbox.stub(annotator, 'setupMobileDialog'); annotator.canAnnotate = true; + }); + it('should set scale and setup annotations', () => { annotator.init(); - - expect(scaleStub).to.be.called; - expect(setupAnnotations).to.be.called; - expect(showAnnotations).to.be.called; + expect(stubs.scale).to.be.called; + expect(stubs.setup).to.be.called; + expect(stubs.show).to.be.called; expect(annotator.annotationService).to.not.be.null; }); + + it('should setup mobile dialog for mobile browsers', () => { + annotator.isMobile = true; + annotator.init(); + expect(stubs.setupMobileDialog).to.be.called; + }); + }); + + describe('setupMobileDialog()', () => { + it('should generate mobile annotations dialog and append to container', () => { + annotator.container = { + appendChild: sandbox.mock() + }; + annotator.setupMobileDialog(); + expect(annotator.container.appendChild).to.be.called; + }); }); describe('showAnnotations()', () => { @@ -246,7 +266,7 @@ describe('lib/annotations/Annotator', () => { const annotatedEl = document.querySelector('.annotated-element'); expect(destroyStub).to.be.called; expect(annotator.notification.show).to.be.called; - expect(annotator.emit).to.be.calledWith('pointmodeenter'); + expect(annotator.emit).to.be.calledWith('annotationmodeenter'); expect(annotatedEl).to.have.class(constants.CLASS_ANNOTATION_POINT_MODE); expect(annotator.unbindDOMListeners).to.be.called; expect(annotator.bindPointModeListeners).to.be.called; @@ -261,7 +281,7 @@ describe('lib/annotations/Annotator', () => { const annotatedEl = document.querySelector('.annotated-element'); expect(destroyStub).to.be.called; expect(annotator.notification.hide).to.be.called; - expect(annotator.emit).to.be.calledWith('pointmodeexit'); + expect(annotator.emit).to.be.calledWith('annotationmodeexit'); expect(annotatedEl).to.not.have.class(constants.CLASS_ANNOTATION_POINT_MODE); expect(annotator.unbindPointModeListeners).to.be.called; expect(annotator.bindDOMListeners).to.be.called; diff --git a/src/lib/annotations/annotationConstants.js b/src/lib/annotations/annotationConstants.js index c9518d8bf..68dde7fb5 100644 --- a/src/lib/annotations/annotationConstants.js +++ b/src/lib/annotations/annotationConstants.js @@ -1,5 +1,6 @@ export const CLASS_ANNOTATION_BUTTON_CANCEL = 'cancel-annotation-btn'; export const CLASS_ANNOTATION_BUTTON_POST = 'post-annotation-btn'; +export const CLASS_MOBILE_ANNOTATION_DIALOG = 'bp-mobile-annotation-dialog'; export const CLASS_ANNOTATION_DIALOG = 'bp-annotation-dialog'; export const CLASS_ANNOTATION_DIALOG_HIGHLIGHT = 'bp-highlight-dialog'; export const CLASS_ANNOTATION_TEXT_HIGHLIGHTED = 'bp-is-text-highlighted'; @@ -11,6 +12,7 @@ export const CLASS_BUTTON_CONTAINER = 'button-container'; export const CLASS_BUTTON_DELETE_COMMENT = 'delete-comment-btn'; export const CLASS_CANNOT_ANNOTATE = 'cannot-annotate'; export const CLASS_COMMENTS_CONTAINER = 'annotation-comments'; +export const CLASS_ANNOTATION_CONTAINER = 'annotation-container'; export const CLASS_REPLY_CONTAINER = 'reply-container'; export const CLASS_REPLY_TEXTAREA = 'reply-textarea'; diff --git a/src/lib/annotations/doc/DocAnnotator.js b/src/lib/annotations/doc/DocAnnotator.js index 9226f4307..2f026adf9 100644 --- a/src/lib/annotations/doc/DocAnnotator.js +++ b/src/lib/annotations/doc/DocAnnotator.js @@ -8,7 +8,6 @@ import rangySaveRestore from 'rangy/lib/rangy-selectionsaverestore'; import throttle from 'lodash.throttle'; import autobind from 'autobind-decorator'; import Annotator from '../Annotator'; -import Browser from '../../Browser'; import DocHighlightThread from './DocHighlightThread'; import DocPointThread from './DocPointThread'; import * as annotatorUtil from '../annotatorUtil'; @@ -163,6 +162,7 @@ const HOVER_TIMEOUT_MS = 75; annotations, annotationService: this.annotationService, fileVersionId: this.fileVersionId, + isMobile: this.isMobile, locale: this.locale, location, type @@ -437,7 +437,7 @@ const HOVER_TIMEOUT_MS = 75; // event we would listen to, selectionchange, fires continuously and // is unreliable. If the mouse moved or we double clicked text, // we trigger the create handler instead of the click handler - if (!Browser.isMobile() && (this.didMouseMove || event.type === 'dblclick')) { + if (!this.isMobile && (this.didMouseMove || event.type === 'dblclick')) { this.highlightCreateHandler(event); } else { this.highlightClickHandler(event); diff --git a/src/lib/annotations/doc/DocHighlightDialog.js b/src/lib/annotations/doc/DocHighlightDialog.js index c79303158..bdb78fefc 100644 --- a/src/lib/annotations/doc/DocHighlightDialog.js +++ b/src/lib/annotations/doc/DocHighlightDialog.js @@ -29,7 +29,7 @@ const PAGE_PADDING_TOP = 15; // If annotation is blank then display who highlighted the text // Will be displayed as '{name} highlighted' if (annotation.text === '' && annotation.user.id !== '0') { - const highlightLabelEl = this.element.querySelector('.bp-annotation-highlight-label'); + const highlightLabelEl = this.highlightDialogEl.querySelector('.bp-annotation-highlight-label'); highlightLabelEl.textContent = replacePlaceholders(__('annotation_who_highlighted'), [ annotation.user.name ]); @@ -105,31 +105,28 @@ const PAGE_PADDING_TOP = 15; * @return {void} */ toggleHighlightDialogs() { - const highlightDialogEl = this.element.querySelector('.bp-annotation-highlight-dialog'); - const commentsDialogEl = this.element.querySelector('.annotation-container'); - const commentsDialogIsHidden = commentsDialogEl.classList.contains(CLASS_HIDDEN); + const commentsDialogIsHidden = this.commentsDialogEl.classList.contains(CLASS_HIDDEN); // Displays comments dialog and hides highlight annotations button if (commentsDialogIsHidden) { this.element.classList.remove(constants.CLASS_ANNOTATION_DIALOG_HIGHLIGHT); - annotatorUtil.hideElement(highlightDialogEl); + annotatorUtil.hideElement(this.highlightDialogEl); this.element.classList.add(constants.CLASS_ANNOTATION_DIALOG); - annotatorUtil.showElement(commentsDialogEl); + annotatorUtil.showElement(this.commentsDialogEl); this.hasComments = true; // Activate comments textarea - const textAreaEl = this.element.querySelector(constants.SELECTOR_ANNOTATION_TEXTAREA); + const textAreaEl = this.dialogEl.querySelector(constants.SELECTOR_ANNOTATION_TEXTAREA); textAreaEl.classList.add(CLASS_ACTIVE); - - // Displays the highlight and comment buttons dialog and hides the - // comments dialog } else { + // Displays the highlight and comment buttons dialog and + // hides the comments dialog this.element.classList.remove(constants.CLASS_ANNOTATION_DIALOG); - annotatorUtil.hideElement(commentsDialogEl); + annotatorUtil.hideElement(this.commentsDialogEl); this.element.classList.add(constants.CLASS_ANNOTATION_DIALOG_HIGHLIGHT); - annotatorUtil.showElement(highlightDialogEl); + annotatorUtil.showElement(this.highlightDialogEl); this.hasComments = false; } @@ -146,18 +143,16 @@ const PAGE_PADDING_TOP = 15; * @return {void} */ toggleHighlightCommentsReply(hasAnnotations) { - const commentsDialogEl = this.element.querySelector('.annotation-container'); - const replyTextEl = commentsDialogEl.querySelector('[data-section="create"]'); - const commentTextEl = commentsDialogEl.querySelector('[data-section="show"]'); + const replyTextEl = this.commentsDialogEl.querySelector('[data-section="create"]'); + const commentTextEl = this.commentsDialogEl.querySelector('[data-section="show"]'); // Ensures that "Add a comment here" text area is shown if (hasAnnotations) { annotatorUtil.hideElement(replyTextEl); annotatorUtil.showElement(commentTextEl); this.deactivateReply(); - - // Ensures that "Reply" text area is shown } else { + // Ensures that "Reply" text area is shown annotatorUtil.hideElement(commentTextEl); annotatorUtil.showElement(replyTextEl); this.activateReply(); @@ -179,78 +174,55 @@ const PAGE_PADDING_TOP = 15; * @protected */ setup(annotations) { - // Only create an entirely new dialog, if one doesn't already exist + // Only create an dialog element, if one doesn't already exist if (!this.element) { this.element = document.createElement('div'); } + // Determine if highlight buttons or comments dialog will display if (annotations.length > 0) { - // Determine if highlight buttons or comments dialog will display this.hasComments = annotations[0].text !== '' || annotations.length > 1; + } - // Assign thread number - this.element.dataset.threadNumber = annotations[0].thread; + // Generate HTML of highlight dialog + this.highlightDialogEl = this.generateHighlightDialogEl(); + this.highlightDialogEl.classList.add('bp-annotation-highlight-dialog'); + + // Generate HTML of comments dialog + this.commentsDialogEl = this.generateDialogEl(annotations.length); + this.commentsDialogEl.classList.add(constants.CLASS_ANNOTATION_CONTAINER); + + this.dialogEl = document.createElement('div'); + this.dialogEl.appendChild(this.highlightDialogEl); + this.dialogEl.appendChild(this.commentsDialogEl); + if (annotations.length > 1) { + this.highlightDialogEl.classList.add(CLASS_HIDDEN); + } else { + this.commentsDialogEl.classList.add(CLASS_HIDDEN); } - const dialogTypeClass = this.hasComments - ? constants.CLASS_ANNOTATION_DIALOG - : constants.CLASS_ANNOTATION_DIALOG_HIGHLIGHT; - this.element.classList.add(dialogTypeClass); + if (!this.isMobile) { + this.element.setAttribute('data-type', 'annotation-dialog'); + this.element.classList.add(constants.CLASS_ANNOTATION_DIALOG); + this.element.innerHTML = '
'; + this.element.appendChild(this.dialogEl); + + // Adding thread number to dialog + if (annotations.length > 0) { + this.element.dataset.threadNumber = annotations[0].thread; + } + } // Indicate that text is highlighted in the highlight buttons dialog if (annotations.length > 0) { - this.element.classList.add(constants.CLASS_ANNOTATION_TEXT_HIGHLIGHTED); + this.dialogEl.classList.add(constants.CLASS_ANNOTATION_TEXT_HIGHLIGHTED); } - this.element.innerHTML = ` -
-
- - - -
-
-
- -
- - -
-
-
-
-
- -
- - -
-
-
- `.trim(); - - // Checks if highlight is a plain highlight annotation and if user name - // has been populated. If userID is 0, user name will be 'Some User' + // Checks if highlight is a plain highlight annotation and if + // user name has been populated. If userID is 0, user name will + // be 'Some User' if (annotatorUtil.isPlainHighlight(annotations) && annotations[0].user.id !== '0') { - const highlightLabelEl = this.element.querySelector('.bp-annotation-highlight-label'); + const highlightLabelEl = this.highlightDialogEl.querySelector('.bp-annotation-highlight-label'); highlightLabelEl.textContent = replacePlaceholders(__('annotation_who_highlighted'), [ annotations[0].user.name ]); @@ -259,7 +231,7 @@ const PAGE_PADDING_TOP = 15; // Hide delete button on plain highlights if user doesn't have // permissions if (annotations[0].permissions && !annotations[0].permissions.can_delete) { - const addHighlightBtn = this.element.querySelector('.bp-add-highlight-btn'); + const addHighlightBtn = this.highlightDialogEl.querySelector('.bp-add-highlight-btn'); annotatorUtil.hideElement(addHighlightBtn); } } @@ -269,7 +241,9 @@ const PAGE_PADDING_TOP = 15; this.addAnnotationElement(annotation); }); - this.bindDOMListeners(); + if (!this.isMobile) { + this.bindDOMListeners(); + } } /** @@ -360,7 +334,7 @@ const PAGE_PADDING_TOP = 15; * @return {void} */ toggleHighlightIcon(fillStyle) { - const addHighlightBtn = this.element.querySelector('.bp-add-highlight-btn'); + const addHighlightBtn = this.dialogEl.querySelector('.bp-add-highlight-btn'); if (fillStyle === constants.HIGHLIGHT_ACTIVE_FILL_STYLE) { addHighlightBtn.classList.add('highlight-active'); } else { @@ -380,12 +354,12 @@ const PAGE_PADDING_TOP = 15; * @return {void} */ toggleHighlight() { - const isTextHighlighted = this.element.classList.contains(constants.CLASS_ANNOTATION_TEXT_HIGHLIGHTED); + const isTextHighlighted = this.dialogEl.classList.contains(constants.CLASS_ANNOTATION_TEXT_HIGHLIGHTED); // Creates a blank highlight annotation if (!isTextHighlighted) { this.hasComments = false; - this.element.classList.add(constants.CLASS_ANNOTATION_TEXT_HIGHLIGHTED); + this.dialogEl.classList.add(constants.CLASS_ANNOTATION_TEXT_HIGHLIGHTED); this.emit('annotationcreate'); // Deletes blank highlight annotation if user has permission @@ -400,7 +374,7 @@ const PAGE_PADDING_TOP = 15; * @return {void} */ focusAnnotationsTextArea() { - const textAreaEl = this.element.querySelector(constants.SELECTOR_ANNOTATION_TEXTAREA); + const textAreaEl = this.dialogEl.querySelector(constants.SELECTOR_ANNOTATION_TEXTAREA); if (annotatorUtil.isElementInViewport(textAreaEl)) { textAreaEl.focus(); } @@ -465,12 +439,36 @@ const PAGE_PADDING_TOP = 15; addAnnotationElement(annotation) { // If annotation text is blank, don't add to the comments dialog if (annotation.text === '') { - const annotationEl = this.element.querySelector('.bp-annotation-highlight-dialog'); - annotationEl.dataset.annotationId = annotation.annotationID; + this.highlightDialogEl.dataset.annotationId = annotation.annotationID; } else { super.addAnnotationElement(annotation); } } + + /** + * Generates the highlight annotation dialog DOM element + * + * @private + * @return {HTMLElement} Highlight annotation dialog DOM element + */ + generateHighlightDialogEl() { + const highlightDialogEl = document.createElement('div'); + highlightDialogEl.innerHTML = ` + + + + + `.trim(); + return highlightDialogEl; + } } export default DocHighlightDialog; diff --git a/src/lib/annotations/doc/__tests__/DocAnnotator-test.js b/src/lib/annotations/doc/__tests__/DocAnnotator-test.js index 078639a32..de020fe68 100644 --- a/src/lib/annotations/doc/__tests__/DocAnnotator-test.js +++ b/src/lib/annotations/doc/__tests__/DocAnnotator-test.js @@ -30,6 +30,7 @@ describe('lib/annotations/doc/DocAnnotator', () => { container: document, annotationService: {}, fileVersionId: 1, + isMobile: false, options: {} }); annotator.annotatedElement = annotator.getAnnotatedEl(document); diff --git a/src/lib/annotations/doc/__tests__/DocHighlightDialog-test.js b/src/lib/annotations/doc/__tests__/DocHighlightDialog-test.js index 6bb980b70..ba24250aa 100644 --- a/src/lib/annotations/doc/__tests__/DocHighlightDialog-test.js +++ b/src/lib/annotations/doc/__tests__/DocHighlightDialog-test.js @@ -168,8 +168,8 @@ describe('lib/annotations/doc/DocHighlightDialog', () => { describe('toggleHighlightCommentsReply()', () => { it('should display "Reply" text area in dialog when multiple comments exist', () => { - const replyTextEl = dialog.element.querySelector('[data-section="create"]'); - const commentTextEl = dialog.element.querySelector('[data-section="show"]'); + const replyTextEl = dialog.commentsDialogEl.querySelector('[data-section="create"]'); + const commentTextEl = dialog.commentsDialogEl.querySelector('[data-section="show"]'); sandbox.stub(dialog, 'position'); @@ -180,8 +180,8 @@ describe('lib/annotations/doc/DocHighlightDialog', () => { }); it('should display "Add a comment here" text area in dialog when no comments exist', () => { - const replyTextEl = dialog.element.querySelector('[data-section="create"]'); - const commentTextEl = dialog.element.querySelector('[data-section="show"]'); + const replyTextEl = dialog.commentsDialogEl.querySelector('[data-section="create"]'); + const commentTextEl = dialog.commentsDialogEl.querySelector('[data-section="show"]'); sandbox.stub(dialog, 'position'); @@ -192,6 +192,100 @@ describe('lib/annotations/doc/DocHighlightDialog', () => { }); }); + describe('setup()', () => { + beforeEach(() => { + stubs.annotation = new Annotation({ + text: 'blargh', + user: { id: 1, name: 'Bob' }, + permissions: { + can_delete: true + }, + thread: 1 + }); + stubs.show = sandbox.stub(annotatorUtil, 'showElement'); + stubs.hide = sandbox.stub(annotatorUtil, 'hideElement'); + }); + + it('should create a dialog element if it does not already exist', () => { + dialog.element = null; + dialog.setup([]); + expect(dialog.element).is.not.null; + }); + + it('should set hasComments according to the number of annotations in the thread', () => { + dialog.hasComments = null; + dialog.setup([stubs.annotation]); + expect(dialog.hasComments).to.be.true; + + dialog.hasComments = null; + stubs.annotation.text = ''; + dialog.setup([stubs.annotation]); + expect(dialog.hasComments).to.be.false; + }); + + it('should hide the highlight dialog if thread has more than 1 annotation', () => { + dialog.setup([stubs.annotation, stubs.annotation]); + expect(dialog.highlightDialogEl).to.have.class(CLASS_HIDDEN); + }); + + it('should hide the comments dialog if thread only 1 annotation', () => { + dialog.setup([stubs.annotation]); + expect(dialog.commentsDialogEl).to.have.class(CLASS_HIDDEN); + }); + + it('should setup the dialog element and add thread number to the dialog', () => { + dialog.setup([stubs.annotation]); + expect(dialog.element.dataset.threadNumber).to.equal('1'); + }); + + it('should not set the thread number when using a mobile browser', () => { + dialog.isMobile = true; + dialog.setup([stubs.annotation]); + expect(dialog.element.dataset.threadNumber).to.be.undefined; + }); + + it('should add the text highlighted class if thread has multiple annotations', () => { + dialog.setup([stubs.annotation]); + expect(dialog.dialogEl).to.have.class(constants.CLASS_ANNOTATION_TEXT_HIGHLIGHTED); + }); + + it('should setup and show plain highlight dialog', () => { + sandbox.stub(annotatorUtil, 'isPlainHighlight').returns(true); + dialog.setup([stubs.annotation]); + expect(stubs.show).to.be.called; + }); + + it('should hide delete button on plain highlights if user does not have permissions', () => { + sandbox.stub(annotatorUtil, 'isPlainHighlight').returns(true); + stubs.annotation.permissions.can_delete = false; + + dialog.setup([stubs.annotation]); + const highlightLabelEl = dialog.highlightDialogEl.querySelector('.bp-annotation-highlight-label'); + const addHighlightBtn = dialog.highlightDialogEl.querySelector('.bp-add-highlight-btn'); + expect(stubs.show).to.be.calledWith(highlightLabelEl); + expect(stubs.hide).to.be.calledWith(addHighlightBtn); + }); + + it('should add annotation elements', () => { + stubs.add = sandbox.stub(dialog, 'addAnnotationElement'); + dialog.setup([stubs.annotation, stubs.annotation]); + expect(stubs.add).to.be.calledTwice; + }); + + it('should bind DOM listeners', () => { + stubs.bind = sandbox.stub(dialog, 'bindDOMListeners'); + dialog.setup([stubs.annotation]); + expect(stubs.bind).to.be.called; + }); + + it('should not bind DOM listeners if using a mobile browser', () => { + stubs.bind = sandbox.stub(dialog, 'bindDOMListeners'); + dialog.isMobile = true; + dialog.setup([stubs.annotation]); + expect(stubs.bind).to.not.be.called; + }); + }); + describe('toggleHighlightIcon()', () => { it('should display active highlight icon when highlight is active', () => { const addHighlightBtn = dialog.element.querySelector('.bp-add-highlight-btn'); @@ -208,17 +302,17 @@ describe('lib/annotations/doc/DocHighlightDialog', () => { describe('toggleHighlight()', () => { it('should delete a blank annotation if text is highlighted', () => { - dialog.element.classList.add(constants.CLASS_ANNOTATION_TEXT_HIGHLIGHTED); + dialog.dialogEl.classList.add(constants.CLASS_ANNOTATION_TEXT_HIGHLIGHTED); dialog.toggleHighlight(); expect(dialog.hasComments).to.be.true; expect(stubs.emit).to.be.calledWith('annotationdelete'); }); it('should create a blank annotation if text is not highlighted', () => { - dialog.element.classList.remove(constants.CLASS_ANNOTATION_TEXT_HIGHLIGHTED); + dialog.dialogEl.classList.remove(constants.CLASS_ANNOTATION_TEXT_HIGHLIGHTED); dialog.toggleHighlight(); - expect(dialog.element).to.have.class(constants.CLASS_ANNOTATION_TEXT_HIGHLIGHTED); + expect(dialog.dialogEl).to.have.class(constants.CLASS_ANNOTATION_TEXT_HIGHLIGHTED); expect(dialog.hasComments).to.be.false; expect(stubs.emit).to.be.calledWith('annotationcreate'); }); @@ -230,7 +324,7 @@ describe('lib/annotations/doc/DocHighlightDialog', () => { focus: () => {} }; stubs.textMock = sandbox.mock(stubs.textarea); - sandbox.stub(dialog.element, 'querySelector').returns(stubs.textarea); + sandbox.stub(dialog.dialogEl, 'querySelector').returns(stubs.textarea); }); it('should focus the add comment area if it exists', () => { diff --git a/src/lib/annotations/image/ImageAnnotator.js b/src/lib/annotations/image/ImageAnnotator.js index 2e4ee7c5e..8904fdd43 100644 --- a/src/lib/annotations/image/ImageAnnotator.js +++ b/src/lib/annotations/image/ImageAnnotator.js @@ -93,6 +93,7 @@ const ANNOTATED_ELEMENT_SELECTOR = '.bp-image, .bp-images-wrapper'; annotations, annotationService: this.annotationService, fileVersionId: this.fileVersionId, + isMobile: this.isMobile, locale: this.locale, location, type diff --git a/src/lib/annotations/image/__tests__/ImageAnnotator-test.js b/src/lib/annotations/image/__tests__/ImageAnnotator-test.js index 8a8de62e8..168411366 100644 --- a/src/lib/annotations/image/__tests__/ImageAnnotator-test.js +++ b/src/lib/annotations/image/__tests__/ImageAnnotator-test.js @@ -20,6 +20,7 @@ describe('lib/annotations/image/ImageAnnotator', () => { container: document, annotationService: {}, fileVersionId: 1, + isMobile: false, options: {} }); annotator.annotatedElement = annotator.getAnnotatedEl(document); diff --git a/src/lib/viewers/BaseViewer.js b/src/lib/viewers/BaseViewer.js index 824fadde3..6f82e7a58 100644 --- a/src/lib/viewers/BaseViewer.js +++ b/src/lib/viewers/BaseViewer.js @@ -42,6 +42,7 @@ const RESIZE_WAIT_TIME_IN_MILLIS = 300; super(); this.options = options; this.repStatuses = []; + this.isMobile = Browser.isMobile(); } /** @@ -66,7 +67,7 @@ const RESIZE_WAIT_TIME_IN_MILLIS = 300; this.loadTimeout = LOAD_TIMEOUT_MS; // For mobile browsers add mobile class just in case viewers need it - if (Browser.isMobile()) { + if (this.isMobile) { this.containerEl.classList.add(CLASS_BOX_PREVIEW_MOBILE); } @@ -553,7 +554,7 @@ const RESIZE_WAIT_TIME_IN_MILLIS = 300; const { file } = this.options; // Users can currently only view annotations on mobile - this.canAnnotate = checkPermission(file, PERMISSION_ANNOTATE) && !Browser.isMobile(); + this.canAnnotate = checkPermission(file, PERMISSION_ANNOTATE) && !this.isMobile; if (this.canAnnotate) { this.showAnnotateButton(this.getPointModeClickHandler()); } @@ -581,6 +582,7 @@ const RESIZE_WAIT_TIME_IN_MILLIS = 300; token }, fileVersionId, + isMobile: this.isMobile, locale: location.locale }); this.annotator.init(); diff --git a/src/lib/viewers/__tests__/BaseViewer-test.js b/src/lib/viewers/__tests__/BaseViewer-test.js index df5f7b5c8..1eb781882 100644 --- a/src/lib/viewers/__tests__/BaseViewer-test.js +++ b/src/lib/viewers/__tests__/BaseViewer-test.js @@ -22,6 +22,7 @@ describe('lib/viewers/BaseViewer', () => { fixture.load('viewers/__tests__/BaseViewer-test.html'); containerEl = document.querySelector('.bp-container'); + stubs.browser = sandbox.stub(Browser, 'isMobile').returns(false); base = new BaseViewer({ container: containerEl, file: { @@ -60,7 +61,7 @@ describe('lib/viewers/BaseViewer', () => { }); it('should add a mobile class to the container if on mobile', () => { - sandbox.stub(Browser, 'isMobile').returns(true); + base.isMobile = true; sandbox.stub(base, 'loadAssets').returns(Promise.resolve()); base.setup(); @@ -622,7 +623,6 @@ describe('lib/viewers/BaseViewer', () => { return stubs.annotatorConf; } } - sandbox.stub(Browser, 'isMobile').returns(false); stubs.checkPermission.returns(true); window.BoxAnnotations = BoxAnnotations; diff --git a/src/lib/viewers/doc/DocBaseViewer.js b/src/lib/viewers/doc/DocBaseViewer.js index 7a3df0952..122d878cb 100644 --- a/src/lib/viewers/doc/DocBaseViewer.js +++ b/src/lib/viewers/doc/DocBaseViewer.js @@ -32,7 +32,7 @@ const MIN_SCALE = 0.1; const SHOW_PAGE_NUM_INPUT_CLASS = 'show-page-number-input'; const IS_SAFARI_CLASS = 'is-safari'; const SCROLL_EVENT_THROTTLE_INTERVAL = 200; -const SCROLL_END_TIMEOUT = Browser.isMobile() ? 500 : 250; +const SCROLL_END_TIMEOUT = this.isMobile ? 500 : 250; const RANGE_REQUEST_CHUNK_SIZE_US = 1048576; // 1MB const RANGE_REQUEST_CHUNK_SIZE_NON_US = 524288; // 512KB @@ -59,8 +59,6 @@ const MOBILE_MAX_CANVAS_SIZE = 2949120; // ~3MP 1920x1536 this.docEl.classList.add(IS_SAFARI_CLASS); } - this.isMobile = Browser.isMobile(); - // We disable native pinch-to-zoom and double tap zoom on mobile to force users to use // our viewer's zoom controls if (this.isMobile) { @@ -799,6 +797,10 @@ const MOBILE_MAX_CANVAS_SIZE = 2949120; // ~3MP 1920x1536 * @return {void} */ loadUI() { + if (this.isMobile) { + return; + } + this.controls = new Controls(this.containerEl); this.bindControlListeners(); this.initPageNumEl(); diff --git a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js index f8f55f4dd..5fd595e3b 100644 --- a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js +++ b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js @@ -1210,6 +1210,12 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { expect(initPageNumElStub).to.be.called; expect(docBase.controls instanceof Controls).to.be.true; }); + + it('should disable controls if on a mobile browser', () => { + docBase.isMobile = true; + docBase.loadUI(); + expect(docBase.controls).to.be.undefined; + }); }); describe('showPageNumInput()', () => { diff --git a/src/lib/viewers/image/ImageBaseViewer.js b/src/lib/viewers/image/ImageBaseViewer.js index a5c7b9635..2bacc9431 100644 --- a/src/lib/viewers/image/ImageBaseViewer.js +++ b/src/lib/viewers/image/ImageBaseViewer.js @@ -145,12 +145,18 @@ const CSS_CLASS_PANNABLE = 'pannable'; updateCursor() { if (this.isPannable) { this.isZoomable = false; - this.imageEl.classList.add(CSS_CLASS_PANNABLE); - this.imageEl.classList.remove(CSS_CLASS_ZOOMABLE); + + if (!this.isMobile) { + this.imageEl.classList.add(CSS_CLASS_PANNABLE); + this.imageEl.classList.remove(CSS_CLASS_ZOOMABLE); + } } else { this.isZoomable = true; - this.imageEl.classList.remove(CSS_CLASS_PANNABLE); - this.imageEl.classList.add(CSS_CLASS_ZOOMABLE); + + if (!this.isMobile) { + this.imageEl.classList.remove(CSS_CLASS_PANNABLE); + this.imageEl.classList.add(CSS_CLASS_ZOOMABLE); + } } } @@ -161,6 +167,11 @@ const CSS_CLASS_PANNABLE = 'pannable'; * @return {void} */ loadUI() { + // Temporarily disabling controls on mobile + if (this.isMobile) { + return; + } + this.controls = new Controls(this.containerEl); this.controls.add(__('zoom_out'), this.zoomOut, 'bp-image-zoom-out-icon', ICON_ZOOM_OUT); this.controls.add(__('zoom_in'), this.zoomIn, 'bp-image-zoom-in-icon', ICON_ZOOM_IN); @@ -177,7 +188,7 @@ const CSS_CLASS_PANNABLE = 'pannable'; this.imageEl.addEventListener('mouseup', this.handleMouseUp); this.imageEl.addEventListener('dragstart', this.cancelDragEvent); - if (Browser.isMobile()) { + if (this.isMobile) { if (Browser.isIOS()) { this.imageEl.addEventListener('gesturestart', this.mobileZoomStartHandler); this.imageEl.addEventListener('gestureend', this.mobileZoomEndHandler); @@ -339,7 +350,10 @@ const CSS_CLASS_PANNABLE = 'pannable'; */ enableViewerControls() { super.enableViewerControls(); - this.updateCursor(); + + if (!this.isMobile) { + this.updateCursor(); + } } /** diff --git a/src/lib/viewers/image/ImageViewer.js b/src/lib/viewers/image/ImageViewer.js index 5bb97e5f5..5776c305c 100644 --- a/src/lib/viewers/image/ImageViewer.js +++ b/src/lib/viewers/image/ImageViewer.js @@ -241,6 +241,12 @@ const IMAGE_ZOOM_SCALE = 1.2; */ loadUI() { super.loadUI(); + + // Temporarily disabling controls on mobile + if (this.isMobile) { + return; + } + this.controls.add(__('rotate_left'), this.rotateLeft, 'bp-image-rotate-left-icon', ICON_ROTATE_LEFT); this.controls.add( __('enter_fullscreen'), @@ -343,7 +349,7 @@ const IMAGE_ZOOM_SCALE = 1.2; this.imageEl.addEventListener('load', this.finishLoading); this.imageEl.addEventListener('error', this.errorHandler); - if (Browser.isMobile()) { + if (this.isMobile) { this.imageEl.addEventListener('orientationchange', this.handleOrientationChange); } } @@ -362,7 +368,7 @@ const IMAGE_ZOOM_SCALE = 1.2; this.imageEl.removeEventListener('error', this.errorHandler); } - if (Browser.isMobile()) { + if (this.isMobile) { this.imageEl.removeEventListener('orientationchange', this.handleOrientationChange); } diff --git a/src/lib/viewers/image/__tests__/ImageBaseViewer-test.js b/src/lib/viewers/image/__tests__/ImageBaseViewer-test.js index 1852c8408..9c711951e 100644 --- a/src/lib/viewers/image/__tests__/ImageBaseViewer-test.js +++ b/src/lib/viewers/image/__tests__/ImageBaseViewer-test.js @@ -127,6 +127,21 @@ describe('lib/viewers/image/ImageBaseViewer', () => { expect(imageBase.imageEl).to.have.class(CSS_CLASS_ZOOMABLE); expect(imageBase.imageEl).to.not.have.class(CSS_CLASS_PANNABLE); }); + + it('should update classes if using a mobile browser', () => { + imageBase.isMobile = true; + imageBase.isZoomable = true; + imageBase.isPannable = true; + + imageBase.updateCursor(); + expect(imageBase.isZoomable).to.have.been.false; + + imageBase.isZoomable = false; + imageBase.isPannable = false; + + imageBase.updateCursor(); + expect(imageBase.isZoomable).to.have.been.true; + }); }); describe('startPanning()', () => { @@ -207,6 +222,12 @@ describe('lib/viewers/image/ImageBaseViewer', () => { expect(imageBase.controls).to.not.be.undefined; expect(imageBase.controls.buttonRefs.length).to.equal(2); }); + + it('should disable controls if on a mobile browser', () => { + imageBase.isMobile = true; + imageBase.loadUI(); + expect(imageBase.controls).to.be.undefined; + }); }); describe('handleMouseDown()', () => { @@ -382,11 +403,10 @@ describe('lib/viewers/image/ImageBaseViewer', () => { sandbox.stub(document, 'addEventListener'); stubs.listeners = imageBase.imageEl.addEventListener; - stubs.isMobile = sandbox.stub(Browser, 'isMobile').returns(true); + imageBase.isMobile = true; }); it('should bind all default image listeners', () => { - stubs.isMobile.returns(false); imageBase.bindDOMListeners(); expect(stubs.listeners).to.have.been.calledWith('mousedown', imageBase.handleMouseDown); expect(stubs.listeners).to.have.been.calledWith('mouseup', imageBase.handleMouseUp); @@ -419,12 +439,11 @@ describe('lib/viewers/image/ImageBaseViewer', () => { imageBase.imageEl.removeEventListener = sandbox.stub(); stubs.listeners = imageBase.imageEl.removeEventListener; stubs.documentListener = sandbox.stub(document, 'removeEventListener'); - stubs.isMobile = sandbox.stub(Browser, 'isMobile').returns(true); + imageBase.isMobile = true; }); it('should unbind all default image listeners if imageEl does not exist', () => { imageBase.imageEl = null; - stubs.isMobile.returns(false); imageBase.unbindDOMListeners(); expect(stubs.listeners).to.not.be.calledWith('mousedown', imageBase.handleMouseDown); @@ -440,7 +459,6 @@ describe('lib/viewers/image/ImageBaseViewer', () => { }); it('should unbind all document listeners', () => { - stubs.isMobile.returns(false); imageBase.unbindDOMListeners(); expect(stubs.documentListener).to.be.calledWith('mousemove', imageBase.pan); expect(stubs.documentListener).to.be.calledWith('mouseup', imageBase.stopPanning); diff --git a/src/lib/viewers/image/__tests__/ImageViewer-test.js b/src/lib/viewers/image/__tests__/ImageViewer-test.js index aa0dc260b..553d86a9b 100644 --- a/src/lib/viewers/image/__tests__/ImageViewer-test.js +++ b/src/lib/viewers/image/__tests__/ImageViewer-test.js @@ -373,6 +373,12 @@ describe('lib/viewers/image/ImageViewer', () => { expect(image.controls.buttonRefs.length).to.equal(5); expect(image.boxAnnotationsLoaded).to.be.false; }); + + it('should disable controls if on a mobile browser', () => { + image.isMobile = true; + image.loadUI(); + expect(image.controls).to.be.undefined; + }); }); describe('print()', () => { @@ -457,9 +463,9 @@ describe('lib/viewers/image/ImageViewer', () => { describe('bindDOMListeners()', () => { beforeEach(() => { + image.isMobile = true; image.imageEl.addEventListener = sandbox.stub(); stubs.listeners = image.imageEl.addEventListener; - stubs.isMobile = sandbox.stub(Browser, 'isMobile').returns(true); }); it('should bind all mobile listeners', () => { @@ -473,12 +479,11 @@ describe('lib/viewers/image/ImageViewer', () => { beforeEach(() => { stubs.removeEventListener = sandbox.stub(document, 'removeEventListener'); image.imageEl.removeEventListener = sandbox.stub(); + image.isMobile = true; stubs.listeners = image.imageEl.removeEventListener; - stubs.isMobile = sandbox.stub(Browser, 'isMobile').returns(true); }); it('should unbind all default image listeners', () => { - stubs.isMobile.returns(false); image.unbindDOMListeners(); expect(stubs.listeners).to.have.been.calledWith('load', image.finishLoading); expect(stubs.listeners).to.have.been.calledWith('error', image.errorHandler);