From 2be6ba3720a54a2aad0b9fd74b31953d9f28e92e Mon Sep 17 00:00:00 2001 From: Sumedha Pramod Date: Sun, 5 Nov 2017 19:36:36 -0800 Subject: [PATCH] Chore: Refactoring annotation mode logic into controllers (#15) * Chore: Refactoring annotation mode logic into controllers * Chore: Adding controller events as constants * Chore: Fixing merge conflicts --- src/AnnotationThread.js | 1 + src/Annotator.js | 512 ++--------- src/Annotator.scss | 2 +- src/BoxAnnotations.js | 58 +- src/__tests__/Annotator-test.js | 819 +++--------------- src/__tests__/BoxAnnotations-test.js | 173 ++-- src/__tests__/annotatorUtil-test.js | 22 +- src/annotationConstants.js | 11 + src/annotatorUtil.js | 31 + src/controllers/AnnotationModeController.js | 297 +++++-- src/controllers/DrawingModeController.js | 188 ++-- src/controllers/HighlightModeController.js | 52 ++ src/controllers/PointModeController.js | 76 ++ .../AnnotationModeController-test.js | 376 ++++++-- .../__tests__/DrawingModeController-test.js | 325 ++++--- .../__tests__/HighlightModeController-test.js | 70 ++ .../__tests__/PointModeController-test.js | 117 +++ src/doc/DocAnnotator.js | 172 ++-- src/doc/DocHighlightThread.js | 2 + src/doc/__tests__/DocAnnotator-test.js | 299 +++---- src/doc/__tests__/DocHighlightThread-test.js | 2 + src/drawing/DrawingThread.js | 17 +- src/drawing/__tests__/DrawingThread-test.js | 4 +- src/image/ImageAnnotator.js | 2 - src/image/__tests__/ImageAnnotator-test.js | 12 +- 25 files changed, 1819 insertions(+), 1821 deletions(-) create mode 100644 src/controllers/HighlightModeController.js create mode 100644 src/controllers/PointModeController.js create mode 100644 src/controllers/__tests__/HighlightModeController-test.js create mode 100644 src/controllers/__tests__/PointModeController-test.js diff --git a/src/AnnotationThread.js b/src/AnnotationThread.js index 4e0e8eefe..c2edb7377 100644 --- a/src/AnnotationThread.js +++ b/src/AnnotationThread.js @@ -72,6 +72,7 @@ class AnnotationThread extends EventEmitter { if (this.dialog && !this.isMobile) { this.unbindCustomListenersOnDialog(); this.dialog.destroy(); + this.dialog = null; } if (this.element) { diff --git a/src/Annotator.js b/src/Annotator.js index c250c5f6d..71ffbf348 100644 --- a/src/Annotator.js +++ b/src/Annotator.js @@ -5,21 +5,16 @@ import * as annotatorUtil from './annotatorUtil'; import { ICON_CLOSE } from './icons/icons'; import './Annotator.scss'; import { - CLASS_ACTIVE, CLASS_HIDDEN, - SELECTOR_BOX_PREVIEW_BASE_HEADER, DATA_TYPE_ANNOTATION_DIALOG, CLASS_MOBILE_ANNOTATION_DIALOG, CLASS_ANNOTATION_DIALOG, - CLASS_ANNOTATION_MODE, - CLASS_ANNNOTATION_DRAWING_BACKGROUND, CLASS_MOBILE_DIALOG_HEADER, CLASS_DIALOG_CLOSE, ID_MOBILE_ANNOTATION_DIALOG, - SELECTOR_ANNOTATION_DRAWING_HEADER, TYPES, - THREAD_EVENT, - ANNOTATOR_EVENT + ANNOTATOR_EVENT, + CONTROLLER_EVENT } from './annotationConstants'; @autobind @@ -54,7 +49,6 @@ class Annotator extends EventEmitter { this.validationErrorEmitted = false; this.isMobile = options.isMobile || false; this.hasTouch = options.hasTouch || false; - this.annotationModeHandlers = []; this.localized = options.localizedStrings; const { apiHost, file, token } = this.options; @@ -83,27 +77,9 @@ class Annotator extends EventEmitter { * @return {void} */ destroy() { - this.unbindModeListeners(); - - if (this.threads) { - Object.keys(this.threads).forEach((page) => { - const pageThreads = this.getThreadsOnPage(page); - - Object.keys(pageThreads).forEach((threadID) => { - const thread = pageThreads[threadID]; - this.unbindCustomListenersOnThread(thread); - }); - }); - } - // Destroy all annotate buttons - Object.keys(this.modeButtons).forEach((type) => { - const handler = this.getAnnotationModeClickHandler(type); - const buttonEl = this.container.querySelector(this.modeButtons[type].selector); - - if (buttonEl) { - buttonEl.removeEventListener('click', handler); - } + Object.keys(this.modeControllers).forEach((mode) => { + this.modeControllers[mode].destroy(); }); this.unbindDOMListeners(); @@ -132,13 +108,6 @@ class Annotator extends EventEmitter { this.setupMobileDialog(); } - // Show the annotate button for all enabled types for the - // current viewer - this.modeButtons = this.options.modeButtons; - Object.keys(this.modeButtons).forEach((type) => { - this.showModeAnnotateButton(type); - }); - this.setScale(initialScale); this.setupAnnotations(); this.loadAnnotations(); @@ -166,42 +135,6 @@ class Annotator extends EventEmitter { return true; } - /** - * Shows the annotate button for the specified mode - * - * @param {string} currentMode - Annotation mode - * @return {void} - */ - showModeAnnotateButton(currentMode) { - const mode = this.modeButtons[currentMode]; - if (!mode || !this.permissions.canAnnotate || !this.isModeAnnotatable(currentMode)) { - return; - } - - const annotateButtonEl = this.container.querySelector(mode.selector); - if (annotateButtonEl) { - annotateButtonEl.title = mode.title; - annotateButtonEl.classList.remove(CLASS_HIDDEN); - - const handler = this.getAnnotationModeClickHandler(currentMode); - annotateButtonEl.addEventListener('click', handler); - - if (this.modeControllers[currentMode]) { - this.modeControllers[currentMode].registerAnnotator(this); - } - } - } - - /** - * Gets the annotation button element. - * - * @param {string} annotatorSelector - Class selector for a custom annotation button. - * @return {HTMLElement|null} Annotate button element or null if the selector did not find an element. - */ - getAnnotateButton(annotatorSelector) { - return this.container.querySelector(annotatorSelector); - } - /** * Shows saved annotations. * @@ -240,7 +173,7 @@ class Annotator extends EventEmitter { return; } - const pageThreads = this.getThreadsOnPage(pageNum); + const pageThreads = this.threads[pageNum] || {}; Object.keys(pageThreads).forEach((threadID) => { const thread = pageThreads[threadID]; thread.hide(); @@ -257,97 +190,6 @@ class Annotator extends EventEmitter { this.annotatedElement.setAttribute('data-scale', scale); } - /** - * Toggles annotation modes on and off. When an annotation mode is - * on, annotation threads will be created at that location. - * - * @param {string} mode - Current annotation mode - * @param {HTMLEvent} event - DOM event - * @return {void} - */ - toggleAnnotationHandler(mode, event = {}) { - if (!this.isModeAnnotatable(mode)) { - return; - } - - this.destroyPendingThreads(); - - // No specific mode available for annotation type - if (!(mode in this.modeButtons)) { - return; - } - - const buttonSelector = this.modeButtons[mode].selector; - const buttonEl = event.target || this.getAnnotateButton(buttonSelector); - - // Exit any other annotation mode - this.exitAnnotationModesExcept(mode); - - // If in annotation mode, turn it off - if (this.isInAnnotationMode(mode)) { - this.disableAnnotationMode(mode, buttonEl); - - // Remove annotation mode - this.currentAnnotationMode = null; - } else { - this.enableAnnotationMode(mode, buttonEl); - - // Update annotation mode - this.currentAnnotationMode = mode; - } - } - - /** - * Disables the specified annotation mode - * - * @param {string} mode - Current annotation mode - * @param {HTMLElement} buttonEl - Annotation button element - * @return {void} - */ - disableAnnotationMode(mode, buttonEl) { - if (!this.isModeAnnotatable(mode)) { - return; - } else if (this.isInAnnotationMode(mode)) { - this.currentAnnotationMode = null; - this.emit(ANNOTATOR_EVENT.modeExit, { mode, headerSelector: SELECTOR_BOX_PREVIEW_BASE_HEADER }); - } - - this.annotatedElement.classList.remove(CLASS_ANNOTATION_MODE); - if (buttonEl) { - buttonEl.classList.remove(CLASS_ACTIVE); - - if (mode === TYPES.draw) { - this.annotatedElement.classList.remove(CLASS_ANNNOTATION_DRAWING_BACKGROUND); - } - } - - this.unbindModeListeners(mode); // Disable mode - this.bindDOMListeners(); // Re-enable other annotations - } - - /** - * Enables the specified annotation mode - * - * @param {string} mode - Current annotation mode - * @param {HTMLElement} buttonEl - Annotation button element - * @return {void} - */ - enableAnnotationMode(mode, buttonEl) { - this.emit(ANNOTATOR_EVENT.modeEnter, { mode, headerSelector: SELECTOR_ANNOTATION_DRAWING_HEADER }); - - this.annotatedElement.classList.add(CLASS_ANNOTATION_MODE); - if (buttonEl) { - buttonEl.classList.add(CLASS_ACTIVE); - - if (mode === TYPES.draw) { - this.annotatedElement.classList.add(CLASS_ANNNOTATION_DRAWING_BACKGROUND); - } - } - - this.unbindDOMListeners(); // Disable other annotations - this.bindModeListeners(mode); // Enable mode - } - //-------------------------------------------------------------------------- // Abstract //-------------------------------------------------------------------------- @@ -403,6 +245,40 @@ class Annotator extends EventEmitter { this.bindDOMListeners(); this.bindCustomListenersOnService(this.annotationService); this.addListener(ANNOTATOR_EVENT.scale, this.scaleAnnotations); + this.setupControllers(); + } + + /** + * Mode controllers setup. + * + * @protected + * @return {void} + */ + setupControllers() { + const { CONTROLLERS } = this.options.annotator || {}; + this.modeControllers = CONTROLLERS || {}; + this.modeButtons = this.options.modeButtons || {}; + + const options = { + header: this.options.header, + isTouchCompatible: this.isMobile && this.hasTouch + }; + Object.keys(this.modeControllers).forEach((type) => { + const controller = this.modeControllers[type]; + controller.init({ + container: this.container, + annotatedElement: this.annotatedElement, + mode: type, + modeButton: this.modeButtons[type], + + permissions: this.permissions, + annotator: this, + options + }); + + this.handleControllerEvents = this.handleControllerEvents.bind(this); + controller.addListener('annotationcontrollerevent', this.handleControllerEvents); + }); } /** @@ -478,11 +354,8 @@ class Annotator extends EventEmitter { // Bind events on valid annotation thread const thread = this.createAnnotationThread(annotations, firstAnnotation.location, firstAnnotation.type); - this.bindCustomListenersOnThread(thread); - const controller = this.modeControllers[firstAnnotation.type]; if (controller) { - controller.bindCustomListenersOnThread(thread); controller.registerThread(thread); } }); @@ -573,160 +446,17 @@ class Annotator extends EventEmitter { } /** - * Binds custom event listeners for a thread. + * Returns the current annotation mode * * @protected - * @param {AnnotationThread} thread - Thread to bind events to - * @return {void} + * @return {string|null} Current annotation mode */ - bindCustomListenersOnThread(thread) { - if (!thread) { - return; - } - - thread.addListener('threadevent', this.handleAnnotationThreadEvents); - } - - /** - * Unbinds custom event listeners for the thread. - * - * @protected - * @param {AnnotationThread} thread - Thread to bind events to - * @return {void} - */ - unbindCustomListenersOnThread(thread) { - thread.removeListener('threadevent', this.handleAnnotationThreadEvents); - } - - /** - * Binds event listeners for annotation modes. - * - * @protected - * @param {string} mode - Current annotation mode - * @return {void} - */ - bindModeListeners(mode) { - const handlers = []; - - if (mode === TYPES.point) { - handlers.push( - { - type: 'mousedown', - func: this.pointClickHandler, - eventObj: this.annotatedElement - }, - { - type: 'touchstart', - func: this.pointClickHandler, - eventObj: this.annotatedElement - } - ); - } else if (mode === TYPES.draw && this.modeControllers[mode]) { - this.modeControllers[mode].bindModeListeners(); - } - - handlers.forEach((handler) => { - handler.eventObj.addEventListener(handler.type, handler.func, false); - this.annotationModeHandlers.push(handler); + getCurrentAnnotationMode() { + const modes = Object.keys(this.modeControllers).filter((mode) => { + const controller = this.modeControllers[mode]; + return controller.isEnabled(); }); - } - - /** - * Event handler for adding a point annotation. Creates a point annotation - * thread at the clicked location. - * - * @protected - * @param {Event} event - DOM event - * @return {void} - */ - pointClickHandler(event) { - event.stopPropagation(); - event.preventDefault(); - - // Determine if a point annotation dialog is already open and close the - // current open dialog - const hasPendingThreads = this.destroyPendingThreads(); - if (hasPendingThreads) { - return; - } - - // Exits point annotation mode on first click - const buttonSelector = this.modeButtons[TYPES.point].selector; - const buttonEl = this.getAnnotateButton(buttonSelector); - this.disableAnnotationMode(TYPES.point, buttonEl); - - // Get annotation location from click event, ignore click if location is invalid - const location = this.getLocationFromEvent(event, TYPES.point); - if (!location) { - return; - } - - // Create new thread with no annotations, show indicator, and show dialog - const thread = this.createAnnotationThread([], location, TYPES.point); - - if (thread) { - thread.show(); - - // Bind events on thread - this.bindCustomListenersOnThread(thread); - } - - this.emit(THREAD_EVENT.pending, thread.getThreadEventData()); - } - - /** - * Unbinds event listeners for annotation modes. - * - * @protected - * @param {string} mode - Annotation mode to be unbound - * @return {void} - */ - unbindModeListeners(mode) { - while (this.annotationModeHandlers.length > 0) { - const handler = this.annotationModeHandlers.pop(); - handler.eventObj.removeEventListener(handler.type, handler.func); - } - - if (this.modeControllers[mode]) { - this.modeControllers[mode].unbindModeListeners(); - } - } - - /** - * Adds thread to in-memory map. - * - * @protected - * @param {AnnotationThread} thread - Thread to add - * @return {void} - */ - addThreadToMap(thread) { - // Add thread to in-memory map - const page = thread.location.page || 1; // Defaults to page 1 if thread has no page' - const pageThreads = this.getThreadsOnPage(page); - pageThreads[thread.threadID] = thread; - } - - /** - * Removes thread to in-memory map. - * - * @protected - * @param {AnnotationThread} thread - Thread to bind events to - * @return {void} - */ - removeThreadFromMap(thread) { - const page = thread.location.page || 1; - delete this.threads[page][thread.threadID]; - } - - /** - * Returns whether or not annotator is in the specified annotation mode. - * - * @protected - * @param {string} mode - Current annotation mode - * @return {boolean} Whether or not in the specified annotation mode - */ - isInAnnotationMode(mode) { - return this.currentAnnotationMode === mode; + return modes[0] || null; } //-------------------------------------------------------------------------- @@ -757,7 +487,7 @@ class Annotator extends EventEmitter { return; } - const pageThreads = this.getThreadsOnPage(pageNum); + const pageThreads = this.threads[pageNum] || {}; Object.keys(pageThreads).forEach((threadID) => { const thread = pageThreads[threadID]; if (!this.isModeAnnotatable(thread.type)) { @@ -793,14 +523,14 @@ class Annotator extends EventEmitter { // Only show/hide point annotation button if user has the // appropriate permissions - if (!this.permissions.canAnnotate) { + const controller = this.modeControllers[TYPES.point]; + if (!this.permissions.canAnnotate || !controller) { return; } // Hide create annotations button if image is rotated const pointButtonSelector = this.modeButtons[TYPES.point].selector; - const pointAnnotateButton = this.getAnnotateButton(pointButtonSelector); - + const pointAnnotateButton = controller.getButton(pointButtonSelector); if (rotationAngle !== 0) { annotatorUtil.hideElement(pointAnnotateButton); } else { @@ -824,23 +554,6 @@ class Annotator extends EventEmitter { }; } - /** - * Returns click handler for toggling annotation mode. - * - * @private - * @param {string} mode - Target annotation mode - * @return {Function|null} Click handler - */ - getAnnotationModeClickHandler(mode) { - if (!mode || !this.isModeAnnotatable(mode)) { - return null; - } - - return () => { - this.toggleAnnotationHandler(mode); - }; - } - /** * Orient annotations to the correct scale and orientation of the annotated document. * @@ -859,53 +572,15 @@ class Annotator extends EventEmitter { * @param {string} mode - Current annotation mode * @return {void} */ - exitAnnotationModesExcept(mode) { - Object.keys(this.modeButtons).forEach((type) => { - if (mode === type) { - return; - } - - const buttonSelector = this.modeButtons[type].selector; - if (!this.modeButtons[type].button) { - this.modeButtons[type].button = this.getAnnotateButton(buttonSelector); - } - - this.disableAnnotationMode(type, this.modeButtons[type].button); - }); - } - - /** - * Gets threads on page - * - * @private - * @param {number} page - Current page number - * @return {Map|[]} Threads on page - */ - getThreadsOnPage(page) { - if (!(page in this.threads)) { - this.threads[page] = {}; + toggleAnnotationMode(mode) { + const currentMode = this.getCurrentAnnotationMode(); + if (currentMode) { + this.modeControllers[currentMode].exit(); } - return this.threads[page]; - } - - /** - * Gets thread specified by threadID - * - * @private - * @param {number} threadID - Thread ID - * @return {AnnotationThread} Annotation thread specified by threadID - */ - getThreadByID(threadID) { - let thread = null; - Object.keys(this.threads).forEach((page) => { - const pageThreads = this.getThreadsOnPage(page); - if (threadID in pageThreads) { - thread = pageThreads[threadID]; - } - }); - - return thread; + if (currentMode !== mode) { + this.modeControllers[mode].enter(); + } } /** @@ -928,30 +603,6 @@ class Annotator extends EventEmitter { }); } - /** - * Destroys pending threads. - * - * @private - * @return {boolean} Whether or not any pending threads existed on the - * current file - */ - destroyPendingThreads() { - let hasPendingThreads = false; - - Object.keys(this.threads).forEach((page) => { - const pageThreads = this.getThreadsOnPage(page); - - Object.keys(pageThreads).forEach((threadID) => { - const thread = pageThreads[threadID]; - if (annotatorUtil.isPending(thread.state)) { - hasPendingThreads = true; - thread.destroy(); - } - }); - }); - return hasPendingThreads; - } - /** * Displays annotation validation error notification once on load. Does * nothing if notification was already displayed once. @@ -1012,42 +663,37 @@ class Annotator extends EventEmitter { } /** - * Handles annotation thread events and emits them to the viewer + * Handle events emitted by the annotation service * * @private - * @param {Object} [data] - Annotation thread event data - * @param {string} [data.event] - Annotation thread event - * @param {string} [data.data] - Annotation thread event data + * @param {Object} [data] - Annotation service event data + * @param {string} [data.event] - Annotation service event + * @param {string} [data.data] - * @return {void} */ - handleAnnotationThreadEvents(data) { - if (!data.data || !data.data.threadID) { - return; - } - - const thread = this.getThreadByID(data.data.threadID); - if (!thread) { - return; - } - + handleControllerEvents(data) { + let opt = { page: 1, pageThreads: {} }; + const headerSelector = data.data ? data.data.headerSelector : ''; switch (data.event) { - case THREAD_EVENT.threadCleanup: - // Thread should be cleaned up, unbind listeners - we - // don't do this in annotationdelete listener since thread - // may still need to respond to error messages - this.unbindCustomListenersOnThread(thread); + case CONTROLLER_EVENT.toggleMode: + this.toggleAnnotationMode(data.mode); break; - case THREAD_EVENT.threadDelete: - // Thread was deleted, remove from thread map - this.removeThreadFromMap(thread); - this.emit(data.event, data.data); + case CONTROLLER_EVENT.enter: + this.emit(data.event, { mode: data.mode, headerSelector }); + this.unbindDOMListeners(); + break; + case CONTROLLER_EVENT.exit: + this.emit(data.event, { mode: data.mode, headerSelector }); + this.bindDOMListeners(); break; - case THREAD_EVENT.deleteError: - this.emit(ANNOTATOR_EVENT.error, this.localized.deleteError); + case CONTROLLER_EVENT.register: + opt = annotatorUtil.addThreadToMap(data.data, this.threads); + this.threads[opt.page] = opt.pageThreads; this.emit(data.event, data.data); break; - case THREAD_EVENT.createError: - this.emit(ANNOTATOR_EVENT.error, this.localized.createError); + case CONTROLLER_EVENT.unregister: + opt = annotatorUtil.removeThreadFromMap(data.data, this.threads); + this.threads[opt.page] = opt.pageThreads; this.emit(data.event, data.data); break; default: diff --git a/src/Annotator.scss b/src/Annotator.scss index d2965d5fe..68d977771 100644 --- a/src/Annotator.scss +++ b/src/Annotator.scss @@ -332,7 +332,7 @@ $tablet: 'max-width: 768px'; width: 24px; &:hover { - z-index: 10000; // Ensure activated point annotation icon is above dialog + z-index: 9999; // Ensure activated point annotation icon is above dialog } svg { diff --git a/src/BoxAnnotations.js b/src/BoxAnnotations.js index 933a42ce9..ae1bfcf8e 100644 --- a/src/BoxAnnotations.js +++ b/src/BoxAnnotations.js @@ -1,6 +1,8 @@ import DocAnnotator from './doc/DocAnnotator'; import ImageAnnotator from './image/ImageAnnotator'; import DrawingModeController from './controllers/DrawingModeController'; +import PointModeController from './controllers/PointModeController'; +import HighlightModeController from './controllers/HighlightModeController'; import { TYPES } from './annotationConstants'; import { canLoadAnnotations } from './annotatorUtil'; @@ -29,6 +31,15 @@ const ANNOTATORS = [ ]; const ANNOTATOR_TYPE_CONTROLLERS = { + [TYPES.point]: { + CONSTRUCTOR: PointModeController + }, + [TYPES.highlight]: { + CONSTRUCTOR: HighlightModeController + }, + [TYPES.highlight_comment]: { + CONSTRUCTOR: HighlightModeController + }, [TYPES.draw]: { CONSTRUCTOR: DrawingModeController } @@ -85,7 +96,8 @@ class BoxAnnotations { /* eslint-disable no-param-reassign */ annotatorConfig.CONTROLLERS = {}; - annotatorConfig.TYPE.forEach((type) => { + const annotatorTypes = this.getAnnotatorTypes(annotatorConfig); + annotatorTypes.forEach((type) => { if (type in ANNOTATOR_TYPE_CONTROLLERS) { annotatorConfig.CONTROLLERS[type] = new ANNOTATOR_TYPE_CONTROLLERS[type].CONSTRUCTOR(); } @@ -93,6 +105,32 @@ class BoxAnnotations { /* eslint-enable no-param-reassign */ } + /** + * Determines the supported annotation types based on the viewer configurations + * if provided, otherwise using the viewer defaults + * + * @private + * @param {Object} annotatorConfig - The config where annotation type controller instances should be attached + * @return {void} + */ + getAnnotatorTypes(annotatorConfig) { + if (!this.viewerConfig) { + return [...annotatorConfig.DEFAULT_TYPES]; + } + + const enabledTypes = this.viewerConfig.enabledTypes || [...annotatorConfig.DEFAULT_TYPES]; + + // Keeping disabledTypes for backwards compatibility + const disabledTypes = this.viewerConfig.disabledTypes || []; + + return enabledTypes.filter((type) => { + return ( + !disabledTypes.some((disabled) => disabled === type) && + annotatorConfig.TYPE.some((allowed) => allowed === type) + ); + }); + } + /** * Chooses an annotator based on viewer. * @@ -104,27 +142,15 @@ class BoxAnnotations { determineAnnotator(options, viewerConfig = {}, disabledAnnotators = []) { let modifiedAnnotator = null; + this.viewerConfig = viewerConfig; const hasAnnotationPermissions = canLoadAnnotations(options.file.permissions); const annotator = this.getAnnotatorsForViewer(options.viewer.NAME, disabledAnnotators); - if (!hasAnnotationPermissions || !annotator || viewerConfig.enabled === false) { + if (!hasAnnotationPermissions || !annotator || this.viewerConfig.enabled === false) { return modifiedAnnotator; } modifiedAnnotator = Object.assign({}, annotator); - - const enabledTypes = viewerConfig.enabledTypes || [...modifiedAnnotator.DEFAULT_TYPES]; - - // Keeping disabledTypes for backwards compatibility - const disabledTypes = viewerConfig.disabledTypes || []; - - const annotatorTypes = enabledTypes.filter((type) => { - return ( - !disabledTypes.some((disabled) => disabled === type) && - modifiedAnnotator.TYPE.some((allowed) => allowed === type) - ); - }); - - modifiedAnnotator.TYPE = annotatorTypes; + modifiedAnnotator.TYPE = this.getAnnotatorTypes(modifiedAnnotator); return modifiedAnnotator; } diff --git a/src/__tests__/Annotator-test.js b/src/__tests__/Annotator-test.js index a0b5bc361..a44be370e 100644 --- a/src/__tests__/Annotator-test.js +++ b/src/__tests__/Annotator-test.js @@ -16,7 +16,8 @@ import { SELECTOR_ANNOTATION_DRAWING_HEADER, SELECTOR_BOX_PREVIEW_BASE_HEADER, ANNOTATOR_EVENT, - THREAD_EVENT + THREAD_EVENT, + CONTROLLER_EVENT } from '../annotationConstants'; let annotator; @@ -31,10 +32,23 @@ describe('Annotator', () => { beforeEach(() => { fixture.load('__tests__/Annotator-test.html'); + stubs.controller = { + init: () => {}, + addListener: () => {}, + registerThread: () => {}, + isEnabled: () => {}, + getButton: () => {}, + enter: () => {}, + exit: () => {} + }; + stubs.controllerMock = sandbox.mock(stubs.controller); + const options = { annotator: { - NAME: 'name' - } + NAME: 'name', + CONTROLLERS: { 'something': stubs.controller } + }, + modeButtons: { 'something': {} } }; annotator = new Annotator({ canAnnotate: true, @@ -45,7 +59,6 @@ describe('Annotator', () => { }, isMobile: false, options, - modeButtons: {}, location: {}, localizedStrings: { anonymousUserName: 'anonymous', @@ -56,7 +69,6 @@ describe('Annotator', () => { } }); annotator.threads = {}; - annotator.modeControllers = {}; stubs.thread = { threadID: '123abc', @@ -67,7 +79,8 @@ describe('Annotator', () => { removeListener: () => {}, scrollIntoView: () => {}, getThreadEventData: () => {}, - type: 'type' + type: 'something', + location: { page: 1 } }; stubs.threadMock = sandbox.mock(stubs.thread); @@ -78,7 +91,8 @@ describe('Annotator', () => { addListener: () => {}, unbindCustomListenersOnThread: () => {}, removeAllListeners: () => {}, - type: 'type' + type: 'something', + location: { page: 2 } }; stubs.threadMock2 = sandbox.mock(stubs.thread2); @@ -89,7 +103,8 @@ describe('Annotator', () => { addListener: () => {}, unbindCustomListenersOnThread: () => {}, removeAllListeners: () => {}, - type: 'type' + type: 'something', + location: { page: 2 } }; stubs.threadMock3 = sandbox.mock(stubs.thread3); }); @@ -117,17 +132,9 @@ describe('Annotator', () => { stubs.setup = sandbox.stub(annotator, 'setupAnnotations'); stubs.show = sandbox.stub(annotator, 'loadAnnotations'); stubs.setupMobileDialog = sandbox.stub(annotator, 'setupMobileDialog'); - stubs.showButton = sandbox.stub(annotator, 'showModeAnnotateButton'); + stubs.getPermissions = sandbox.stub(annotator, 'getAnnotationPermissions'); annotator.permissions = { canAnnotate: true }; - annotator.modeButtons = { - point: { selector: 'point_btn' }, - draw: { selector: 'draw_btn' } - }; - }); - - afterEach(() => { - annotator.modeButtons = {}; }); it('should set scale and setup annotations', () => { @@ -188,16 +195,34 @@ describe('Annotator', () => { sandbox.stub(annotator, 'bindDOMListeners'); sandbox.stub(annotator, 'bindCustomListenersOnService'); sandbox.stub(annotator, 'addListener'); + sandbox.stub(annotator, 'setupControllers'); annotator.setupAnnotations(); expect(annotator.threads).to.not.be.undefined; expect(annotator.bindDOMListeners).to.be.called; expect(annotator.bindCustomListenersOnService).to.be.called; + expect(annotator.setupControllers).to.be.called; expect(annotator.addListener).to.be.calledWith(ANNOTATOR_EVENT.scale, sinon.match.func); }); }); + describe('setupControllers()', () => { + it('should instantiate controllers for enabled types', () => { + annotator.options = { + annotator: { + NAME: 'name', + CONTROLLERS: { 'something': stubs.controller } + }, + modeButtons: { 'something': {} } + }; + + stubs.controllerMock.expects('init'); + stubs.controllerMock.expects('addListener').withArgs('annotationcontrollerevent', sinon.match.func); + annotator.setupControllers(); + }); + }); + describe('once annotator is initialized', () => { beforeEach(() => { const annotatedEl = document.querySelector('.annotated-element'); @@ -205,15 +230,16 @@ describe('Annotator', () => { sandbox.stub(annotator, 'getAnnotatedEl').returns(annotatedEl); sandbox.stub(annotator, 'setupAnnotations'); sandbox.stub(annotator, 'loadAnnotations'); - - stubs.thread.location = { page: 1 }; - stubs.thread2.location = { page: 2 }; - stubs.thread3.location = { page: 2 }; - annotator.addThreadToMap(stubs.thread); - annotator.addThreadToMap(stubs.thread2); - annotator.addThreadToMap(stubs.thread3); - annotator.init(); + annotator.setupControllers(); + + annotator.threads = { + 1: { '123abc': stubs.thread }, + 2: { + '456def': stubs.thread2, + '789ghi': stubs.thread3 + } + } }); afterEach(() => { @@ -222,17 +248,12 @@ describe('Annotator', () => { describe('destroy()', () => { it('should unbind custom listeners on thread and unbind DOM listeners', () => { - stubs.thread.location = { page: 1 }; - annotator.addThreadToMap(stubs.thread); - - const unbindCustomStub = sandbox.stub(annotator, 'unbindCustomListenersOnThread'); const unbindDOMStub = sandbox.stub(annotator, 'unbindDOMListeners'); const unbindCustomListenersOnService = sandbox.stub(annotator, 'unbindCustomListenersOnService'); const unbindListener = sandbox.stub(annotator, 'removeListener'); annotator.destroy(); - expect(unbindCustomStub).to.be.calledWith(stubs.thread); expect(unbindDOMStub).to.be.called; expect(unbindCustomListenersOnService).to.be.called; expect(unbindListener).to.be.calledWith(ANNOTATOR_EVENT.scale, sinon.match.func); @@ -276,14 +297,14 @@ describe('Annotator', () => { it('should not call show() if the thread type is disabled', () => { const badType = 'not_accepted'; stubs.thread3.type = badType; - stubs.thread2.type = 'type'; + stubs.thread2.type = 'something'; stubs.threadMock3.expects('show').never(); stubs.threadMock2.expects('show').once(); const isModeAnn = sandbox.stub(annotator, 'isModeAnnotatable'); isModeAnn.withArgs(badType).returns(false); - isModeAnn.withArgs('type').returns(true); + isModeAnn.withArgs('something').returns(true); annotator.renderAnnotationsOnPage('2'); }); @@ -302,11 +323,13 @@ describe('Annotator', () => { stubs.hide = sandbox.stub(annotatorUtil, 'hideElement'); stubs.show = sandbox.stub(annotatorUtil, 'showElement'); stubs.render = sandbox.stub(annotator, 'renderAnnotations'); + stubs.renderPage = sandbox.stub(annotator, 'renderAnnotationsOnPage'); annotator.modeButtons = { point: { selector: 'point_btn' }, draw: { selector: 'draw_btn' } }; + annotator.modeControllers['point'] = stubs.controller; }); afterEach(() => { @@ -359,152 +382,6 @@ describe('Annotator', () => { }); }); - describe('exitAnnotationModesExcept()', () => { - it('should call disableAnnotationMode on all modes except the specified one', () => { - annotator.modeButtons = { - 'type1': { - selector: 'bogus', - button: 'button1' - }, - 'type2': { - selector: 'test', - button: 'button2' - } - }; - - sandbox.stub(annotator, 'disableAnnotationMode'); - annotator.exitAnnotationModesExcept('type2'); - expect(annotator.disableAnnotationMode).to.be.calledWith('type1', 'button1'); - expect(annotator.disableAnnotationMode).to.not.be.calledWith('type2', 'button2'); - }); - }); - - describe('toggleAnnotationHandler()', () => { - beforeEach(() => { - stubs.destroyStub = sandbox.stub(annotator, 'destroyPendingThreads'); - stubs.annotationMode = sandbox.stub(annotator, 'isInAnnotationMode'); - stubs.exitModes = sandbox.stub(annotator, 'exitAnnotationModesExcept'); - stubs.disable = sandbox.stub(annotator, 'disableAnnotationMode'); - stubs.enable = sandbox.stub(annotator, 'enableAnnotationMode'); - sandbox.stub(annotator, 'getAnnotateButton'); - stubs.isAnnotatable = sandbox.stub(annotator, 'isModeAnnotatable').returns(true); - - annotator.modeButtons = { - point: { selector: 'point_btn' }, - draw: { selector: 'draw_btn' } - }; - - annotator.createHighlightDialog = { - isVisible: false, - hide: sandbox.stub() - } - }); - - afterEach(() => { - annotator.modeButtons = {}; - }); - - it('should do nothing if specified annotation type is not annotatable', () => { - stubs.isAnnotatable.returns(false); - annotator.toggleAnnotationHandler('bleh'); - expect(stubs.destroyStub).to.not.be.called; - }); - - it('should do nothing if specified annotation type does not have a mode button', () => { - annotator.toggleAnnotationHandler(TYPES.highlight); - expect(stubs.destroyStub).to.be.called; - expect(stubs.exitModes).to.not.be.called; - }); - - it('should turn annotation mode on if it is off', () => { - stubs.annotationMode.returns(false); - - annotator.toggleAnnotationHandler(TYPES.point); - - expect(stubs.destroyStub).to.be.called; - expect(stubs.exitModes).to.be.called; - expect(stubs.enable).to.be.called; - }); - - it('should turn annotation mode off if it is on', () => { - stubs.annotationMode.returns(true); - - annotator.toggleAnnotationHandler(TYPES.point); - - expect(stubs.destroyStub).to.be.called; - expect(stubs.exitModes).to.be.called; - expect(stubs.disable).to.be.called; - }); - }); - - describe('disableAnnotationMode()', () => { - beforeEach(() => { - annotator.currentAnnotationMode = TYPES.point; - stubs.isModeAnnotatable = sandbox.stub(annotator, 'isModeAnnotatable').returns(true); - stubs.isInMode = sandbox.stub(annotator, 'isInAnnotationMode').returns(false); - stubs.emit = sandbox.stub(annotator, 'emit'); - stubs.unbindMode = sandbox.stub(annotator, 'unbindModeListeners'); - stubs.bindDOM = sandbox.stub(annotator, 'bindDOMListeners'); - }); - - it('should do nothing when the mode is not annotatable', () => { - stubs.isModeAnnotatable.returns(false); - annotator.annotatedElement = null; - - expect(annotator.disableAnnotationMode, TYPES.draw).to.not.throw(); - }); - - it('should exit annotation mode if currently in the specified mode', () => { - stubs.isInMode.returns(true); - annotator.disableAnnotationMode(TYPES.point); - expect(stubs.emit).to.be.calledWith(ANNOTATOR_EVENT.modeExit, sinon.match.object); - expect(stubs.unbindMode).to.be.calledWith(TYPES.point); - expect(stubs.bindDOM).to.be.called; - expect(annotator.annotatedElement).to.not.have.class(CLASS_ANNOTATION_MODE); - expect(annotator.currentAnnotationMode).to.be.null; - }); - - it('should deactivate point annotation mode button', () => { - const btn = document.querySelector('.bp-btn-annotate'); - annotator.disableAnnotationMode(TYPES.point, btn); - expect(btn).to.not.have.class(CLASS_ACTIVE); - }); - - it('should deactivate draw annotation mode button', () => { - const btn = document.querySelector('.bp-btn-annotate'); - annotator.disableAnnotationMode(TYPES.draw, btn); - expect(btn).to.not.have.class(CLASS_ACTIVE); - }); - }); - - describe('enableAnnotationMode()', () => { - beforeEach(() => { - stubs.emit = sandbox.stub(annotator, 'emit'); - stubs.unbindDOM = sandbox.stub(annotator, 'unbindDOMListeners'); - stubs.bindMode = sandbox.stub(annotator, 'bindModeListeners'); - }); - - it('should enter annotation mode', () => { - annotator.enableAnnotationMode(TYPES.point); - expect(stubs.emit).to.be.calledWith(ANNOTATOR_EVENT.modeEnter, sinon.match.object); - expect(stubs.unbindDOM).to.be.called; - expect(stubs.bindMode).to.be.calledWith(TYPES.point); - expect(annotator.annotatedElement).to.have.class(CLASS_ANNOTATION_MODE); - }); - - it('should deactivate point annotation mode button', () => { - const btn = document.querySelector('.bp-btn-annotate'); - annotator.enableAnnotationMode(TYPES.point, btn); - expect(btn).to.have.class(CLASS_ACTIVE); - }); - - it('should deactivate draw annotation mode button', () => { - const btn = document.querySelector('.bp-btn-annotate'); - annotator.enableAnnotationMode(TYPES.draw, btn); - expect(btn).to.have.class(CLASS_ACTIVE); - }); - }); - describe('fetchAnnotations()', () => { beforeEach(() => { annotator.annotationService = { @@ -582,11 +459,9 @@ describe('Annotator', () => { describe('generateThreadMap()', () => { beforeEach(() => { - stubs.threadMap = { - someID: [{}, {}], - someID2: [{}] - }; - sandbox.stub(annotatorUtil, 'getFirstAnnotation').returns({}); + stubs.threadMap = { '123abc': stubs.thread }; + const annotation = { location: {}, type: 'something' }; + sandbox.stub(annotatorUtil, 'getFirstAnnotation').returns(annotation); sandbox.stub(annotator, 'isModeAnnotatable').returns(true); }); @@ -599,14 +474,17 @@ describe('Annotator', () => { it('should reset and create a new thread map by from annotations fetched from server', () => { annotator.options.annotator = { NAME: 'name' }; - stubs.createThread = sandbox.stub(annotator, 'createAnnotationThread'); - stubs.createThread.onFirstCall(); - stubs.createThread.onSecondCall().returns(stubs.thread); - sandbox.stub(annotator, 'bindCustomListenersOnThread'); + sandbox.stub(annotator, 'createAnnotationThread').returns(stubs.thread); + annotator.generateThreadMap(stubs.threadMap); + expect(annotator.createAnnotationThread).to.be.called; + }); + it('should register thread if controller exists', () => { + annotator.options.annotator = { NAME: 'name' }; + annotator.modeControllers['something'] = stubs.controller; + sandbox.stub(annotator, 'createAnnotationThread').returns(stubs.thread); + stubs.controllerMock.expects('registerThread'); annotator.generateThreadMap(stubs.threadMap); - expect(annotator.createAnnotationThread).to.be.calledTwice; - expect(annotator.bindCustomListenersOnThread).to.be.calledTwice; }); }); @@ -668,6 +546,55 @@ describe('Annotator', () => { }); }); + describe('handleControllerEvents()', () => { + const mode = 'something'; + let data = {}; + + beforeEach(() => { + sandbox.stub(annotator, 'emit'); + data = { mode, data: { headerSelector: 'selector' } }; + }); + + it('should toggle annotation mode on togglemode', () => { + sandbox.stub(annotator, 'toggleAnnotationMode'); + data.event = CONTROLLER_EVENT.toggleMode; + annotator.handleControllerEvents(data); + expect(annotator.toggleAnnotationMode).to.be.calledWith(mode); + }); + + it('should unbind dom listeners and emit message on mode enter', () => { + sandbox.stub(annotator, 'unbindDOMListeners'); + data.event = CONTROLLER_EVENT.enter; + annotator.handleControllerEvents(data); + expect(annotator.unbindDOMListeners).to.be.called; + expect(annotator.emit).to.be.calledWith(data.event, sinon.match.object); + }); + + it('should bind dom listeners and emit message on mode exit', () => { + sandbox.stub(annotator, 'bindDOMListeners'); + data.event = CONTROLLER_EVENT.exit; + annotator.handleControllerEvents(data); + expect(annotator.bindDOMListeners).to.be.called; + expect(annotator.emit).to.be.calledWith(data.event, sinon.match.object); + }); + + it('should bind dom listeners and emit message on mode exit', () => { + sandbox.stub(annotatorUtil, 'addThreadToMap').returns(annotator.threads[1]); + data.event = CONTROLLER_EVENT.register; + annotator.handleControllerEvents(data); + expect(annotatorUtil.addThreadToMap).to.be.called; + expect(annotator.emit).to.be.calledWith(data.event, data.data); + }); + + it('should bind dom listeners and emit message on mode exit', () => { + sandbox.stub(annotatorUtil, 'removeThreadFromMap').returns(annotator.threads[1]); + data.event = CONTROLLER_EVENT.unregister; + annotator.handleControllerEvents(data); + expect(annotatorUtil.removeThreadFromMap).to.be.called; + expect(annotator.emit).to.be.calledWith(data.event, data.data); + }); + }); + describe('unbindCustomListenersOnService()', () => { it('should do nothing if the service does not exist', () => { annotator.annotationService = { @@ -693,232 +620,26 @@ describe('Annotator', () => { }); }); - describe('bindCustomListenersOnThread()', () => { - it('should bind custom listeners on the thread', () => { - stubs.threadMock.expects('addListener').withArgs('threadevent', sinon.match.func); - annotator.bindCustomListenersOnThread(stubs.thread); - }); - - it('should do nothing when given thread is empty', () => { - stubs.threadMock.expects('addListener').never(); - annotator.bindCustomListenersOnThread(null); - }) - }); - - describe('unbindCustomListenersOnThread()', () => { - it('should unbind custom listeners from the thread', () => { - stubs.threadMock.expects('removeListener').withArgs('threadevent'); - annotator.unbindCustomListenersOnThread(stubs.thread); - }); - }); - - describe('bindModeListeners()', () => { - let drawingThread; - - beforeEach(() => { - annotator.annotatedElement = { - addEventListener: sandbox.stub(), - removeEventListener: sandbox.stub() - }; - - stubs.controllers = { - [TYPES.draw]: { - bindModeListeners: sandbox.stub() - } - }; - - annotator.modeControllers = stubs.controllers; - drawingThread = { - handleStart: () => {}, - handleStop: () => {}, - handleMove: () => {}, - addListener: sandbox.stub() - }; - }); - - it('should get event handlers for point annotation mode', () => { - annotator.bindModeListeners(TYPES.point); - expect(annotator.annotatedElement.addEventListener).to.be.calledWith( - 'mousedown', - annotator.pointClickHandler - ); - expect(annotator.annotatedElement.addEventListener).to.be.calledWith( - 'touchstart', - annotator.pointClickHandler - ); - expect(annotator.annotationModeHandlers.length).equals(2); - }); - - it('should bind draw mode click handlers if post button exists', () => { - annotator.bindModeListeners(TYPES.draw); - - expect(annotator.annotatedElement.addEventListener).to.not.be.called; - expect(stubs.controllers[TYPES.draw].bindModeListeners).to.be.called; - }); - }); - - describe('unbindModeListeners()', () => { - it('should unbind mode handlers', () => { - sandbox.stub(annotator.annotatedElement, 'removeEventListener'); - annotator.annotationModeHandlers = [ - { - type: 'event1', - func: () => {}, - eventObj: annotator.annotatedElement - }, - { - type: 'event2', - func: () => {}, - eventObj: annotator.annotatedElement - } - ]; - - annotator.unbindModeListeners(); - expect(annotator.annotatedElement.removeEventListener).to.be.calledWith( - 'event1', - sinon.match.func - ); - expect(annotator.annotatedElement.removeEventListener).to.be.calledWith( - 'event2', - sinon.match.func - ); - }); - - it('should delegate to the controller', () => { - annotator.modeControllers = { - [TYPES.draw]: { - name: 'drawingModeController', - unbindModeListeners: sandbox.stub() - } - }; - - annotator.unbindModeListeners(TYPES.draw); - expect(annotator.modeControllers[TYPES.draw].unbindModeListeners).to.be.called; - }); - }); - - describe('pointClickHandler()', () => { - const event = { - stopPropagation: () => {}, - preventDefault: () => {} - }; - - beforeEach(() => { - stubs.destroy = sandbox.stub(annotator, 'destroyPendingThreads'); - stubs.create = sandbox.stub(annotator, 'createAnnotationThread'); - stubs.getLocation = sandbox.stub(annotator, 'getLocationFromEvent'); - sandbox.stub(annotator, 'bindCustomListenersOnThread'); - sandbox.stub(annotator, 'disableAnnotationMode'); - stubs.emit = sandbox.stub(annotator, 'emit'); - annotator.modeButtons = { - point: { - title: 'Point Annotation Mode', - selector: '.bp-btn-annotate' - } - }; - }); - - afterEach(() => { - annotator.modeButtons = {}; - annotator.container = document; - }); - - it('should not do anything if there are pending threads', () => { - stubs.destroy.returns(true); - stubs.create.returns(stubs.thread); - - stubs.threadMock.expects('show').never(); - annotator.pointClickHandler(event); - - expect(annotator.getLocationFromEvent).to.not.be.called; - expect(annotator.bindCustomListenersOnThread).to.not.be.called; - expect(annotator.disableAnnotationMode).to.not.be.called; - }); - - it('should not do anything if thread is invalid', () => { - stubs.destroy.returns(false); - - stubs.threadMock.expects('show').never(); - annotator.pointClickHandler(event); - - expect(annotator.getLocationFromEvent).to.be.called; - expect(annotator.disableAnnotationMode).to.be.called; - expect(annotator.bindCustomListenersOnThread).to.not.be.called; + describe('getCurrentAnnotationMode()', () => { + it('should return null if no mode is enabled', () => { + annotator.modeControllers['something'] = stubs.controller; + stubs.controllerMock.expects('isEnabled').returns(false); + expect(annotator.getCurrentAnnotationMode()).to.be.null; }); - it('should not create a thread if a location object cannot be inferred from the event', () => { - stubs.destroy.returns(false); - stubs.getLocation.returns(null); - stubs.create.returns(stubs.thread); - - stubs.threadMock.expects('show').never(); - annotator.pointClickHandler(event); - - expect(annotator.getLocationFromEvent).to.be.called; - expect(annotator.bindCustomListenersOnThread).to.not.be.called; - expect(annotator.disableAnnotationMode).to.be.called; + it('should return the current annotation mode', () => { + annotator.modeControllers['something'] = stubs.controller; + stubs.controllerMock.expects('isEnabled').returns(true); + expect(annotator.getCurrentAnnotationMode()).equals('something'); }); - it('should create, show, and bind listeners to a thread', () => { - stubs.destroy.returns(false); - stubs.getLocation.returns({}); - stubs.create.returns(stubs.thread); - stubs.threadMock.expects('getThreadEventData').returns('data'); - - stubs.threadMock.expects('show'); - annotator.pointClickHandler(event); - - expect(annotator.getLocationFromEvent).to.be.called; - expect(annotator.bindCustomListenersOnThread).to.be.called; - expect(annotator.disableAnnotationMode).to.be.called; - expect(annotator.emit).to.be.calledWith(THREAD_EVENT.pending, 'data'); - }); - }); - - describe('addThreadToMap()', () => { - it('should add valid threads to the thread map', () => { - stubs.thread.location = { page: 1 }; - stubs.thread2.location = { page: 1 }; - - const threadMap = { '456def': stubs.thread2 }; - annotator.threads = { 1: threadMap }; - annotator.addThreadToMap(stubs.thread); - - const pageThreads = annotator.getThreadsOnPage(1); - expect(pageThreads).to.have.any.keys(stubs.thread.threadID); - }); - }); - - describe('removeThreadFromMap()', () => { - it('should remove a valid thread from the thread map', () => { - stubs.thread.location = { page: 1 }; - stubs.thread2.location = { page: 1 }; - annotator.addThreadToMap(stubs.thread); - annotator.addThreadToMap(stubs.thread2); - - annotator.removeThreadFromMap(stubs.thread); - const pageThreads = annotator.getThreadsOnPage(1); - expect(pageThreads).to.not.have.any.keys(stubs.thread.threadID); - expect(pageThreads).to.have.any.keys(stubs.thread2.threadID); - }); - }); - - describe('isInAnnotationMode()', () => { - it('should return whether the annotator is in specified annotation mode or not', () => { - annotator.currentAnnotationMode = TYPES.draw; - expect(annotator.isInAnnotationMode(TYPES.draw)).to.be.true; - - annotator.currentAnnotationMode = TYPES.point; - expect(annotator.isInAnnotationMode(TYPES.draw)).to.be.false; + it('should null if no controllers exist', () => { + annotator.modeControllers = {}; + expect(annotator.getCurrentAnnotationMode()).to.be.null; }); }); describe('scrollToAnnotation()', () => { - beforeEach(() => { - stubs.thread.location = { page: 1 }; - annotator.addThreadToMap(stubs.thread); - }); - it('should do nothing if no threadID is provided', () => { stubs.threadMock.expects('scrollIntoView').never(); annotator.scrollToAnnotation(); @@ -930,6 +651,7 @@ describe('Annotator', () => { }); it('should scroll to annotation if threadID exists on page', () => { + annotator.threads = { 1: { '123abc': stubs.thread } }; stubs.threadMock.expects('scrollIntoView'); annotator.scrollToAnnotation(stubs.thread.threadID); }); @@ -951,105 +673,21 @@ describe('Annotator', () => { }); }); - describe('getThreadsOnPage()', () => { - it('should add page to threadMap if it does not already exist', () => { - annotator.threads = { - 1: 'not empty' - }; - const threads = annotator.getThreadsOnPage(2); - expect(threads).to.not.be.undefined; - annotator.threads = {}; - }); - - it('should return an existing page in the threadMap', () => { - annotator.threads = { - 1: 'not empty' - }; - const threads = annotator.getThreadsOnPage(1); - expect(threads).equals('not empty'); - annotator.threads = {}; - }); - }); - - describe('getThreadByID()', () => { - it('should find and return annotation thread specified by threadID', () => { - annotator.threads = { 1: {} }; - sandbox.stub(annotator, 'getThreadsOnPage').returns({ - '123abc': stubs.thread - }); - const thread = annotator.getThreadByID(stubs.thread.threadID); - expect(thread).to.deep.equals(stubs.thread); - }); - - it('should return null if specified annotation thread is invalid', () => { - annotator.threads = { 1: {} }; - sandbox.stub(annotator, 'getThreadsOnPage').returns({ - '123abc': stubs.thread - }); - const thread = annotator.getThreadByID('random'); - expect(thread).to.deep.equals(null); - }); - }); - - describe('destroyPendingThreads()', () => { + describe('toggleAnnotationMode()', () => { beforeEach(() => { - stubs.thread = { - threadID: '123abc', - location: { page: 2 }, - type: 'type', - state: STATES.pending, - destroy: () => {}, - unbindCustomListenersOnThread: () => {}, - removeAllListeners: () => {} - }; - stubs.threadMock = sandbox.mock(stubs.thread); - stubs.isPending = sandbox.stub(annotatorUtil, 'isPending').returns(false); - stubs.isPending.withArgs(STATES.pending).returns(true); - - annotator.addThreadToMap(stubs.thread); - annotator.init(); - }); - - it('should destroy and return true if there are any pending threads', () => { - stubs.threadMock.expects('destroy'); - const destroyed = annotator.destroyPendingThreads(); - expect(destroyed).to.equal(true); - }); - - it('should not destroy and return false if there are no threads', () => { - annotator.threads = {}; - stubs.threadMock.expects('destroy').never(); - stubs.isPending.returns(false); - const destroyed = annotator.destroyPendingThreads(); - expect(destroyed).to.equal(false); + annotator.modeControllers['something'] = stubs.controller; }); - it('should not destroy and return false if the threads are not pending', () => { - stubs.thread.state = 'NOT_PENDING'; - stubs.threadMock.expects('destroy').never(); - const destroyed = annotator.destroyPendingThreads(); - expect(destroyed).to.equal(false); + it('should exit the current mode', () => { + stubs.controllerMock.expects('isEnabled').returns(true); + stubs.controllerMock.expects('exit'); + annotator.toggleAnnotationMode('something'); }); - it('should destroy only pending threads, and return true', () => { - stubs.thread.state = 'NOT_PENDING'; - const pendingThread = { - threadID: '456def', - location: { page: 1 }, - type: 'type', - state: STATES.pending, - destroy: () => {}, - unbindCustomListenersOnThread: () => {}, - removeAllListeners: () => {} - }; - stubs.pendingMock = sandbox.mock(pendingThread); - annotator.addThreadToMap(pendingThread); - - stubs.threadMock.expects('destroy').never(); - stubs.pendingMock.expects('destroy'); - const destroyed = annotator.destroyPendingThreads(); - - expect(destroyed).to.equal(true); + it('should enter the specified mode', () => { + sandbox.stub(annotator, 'getCurrentAnnotationMode'); + stubs.controllerMock.expects('enter'); + annotator.toggleAnnotationMode('something'); }); }); @@ -1071,87 +709,6 @@ describe('Annotator', () => { }); }); - describe('handleAnnotationThreadEvents()', () => { - beforeEach(() => { - stubs.getThread = sandbox.stub(annotator, 'getThreadByID'); - stubs.emit = sandbox.stub(annotator, 'emit'); - stubs.unbind = sandbox.stub(annotator, 'unbindCustomListenersOnThread'); - stubs.remove = sandbox.stub(annotator, 'removeThreadFromMap'); - }); - - it('should do nothing if invalid params are specified', () => { - annotator.handleAnnotationThreadEvents('no data'); - annotator.handleAnnotationThreadEvents({ data: 'no threadID'}); - expect(stubs.getThread).to.not.be.called; - - annotator.handleAnnotationThreadEvents({ data: { threadID: 1 }}); - expect(stubs.emit).to.not.be.called; - expect(stubs.unbind).to.not.be.called; - expect(stubs.remove).to.not.be.called; - }); - - it('should unbind custom thread listeners on threadCleanup', () => { - stubs.getThread.returns(stubs.thread); - annotator.handleAnnotationThreadEvents({ - event: THREAD_EVENT.threadCleanup, - data: { threadID: 1 } - }); - expect(stubs.unbind).to.be.called; - expect(stubs.emit).to.not.be.called; - expect(stubs.remove).to.not.be.called; - }); - - it('should remove thread from map on threadDelete', () => { - stubs.getThread.returns(stubs.thread); - const data = { - event: THREAD_EVENT.threadDelete, - data: { threadID: 1 } - }; - annotator.handleAnnotationThreadEvents(data); - expect(stubs.emit).to.be.calledWith(data.event, data.data); - expect(stubs.remove).to.be.called; - expect(stubs.unbind).to.not.be.called; - }); - - it('should emit delete error notification event', () => { - stubs.getThread.returns(stubs.thread); - const data = { - event: THREAD_EVENT.deleteError, - data: { threadID: 1 } - }; - annotator.handleAnnotationThreadEvents(data); - expect(stubs.emit).to.be.calledWith(data.event, data.data); - expect(stubs.emit).to.be.calledWith(ANNOTATOR_EVENT.error, sinon.match.string); - expect(stubs.unbind).to.not.be.called; - expect(stubs.remove).to.not.be.called; - }); - - it('should emit save error notification event', () => { - stubs.getThread.returns(stubs.thread); - const data = { - event: THREAD_EVENT.createError, - data: { threadID: 1 } - }; - annotator.handleAnnotationThreadEvents(data); - expect(stubs.emit).to.be.calledWith(data.event, data.data); - expect(stubs.emit).to.be.calledWith(ANNOTATOR_EVENT.error, sinon.match.string); - expect(stubs.unbind).to.not.be.called; - expect(stubs.remove).to.not.be.called; - }); - - it('should emit thread event', () => { - stubs.getThread.returns(stubs.thread); - const data = { - event: THREAD_EVENT.pending, - data: { threadID: 1 } - }; - annotator.handleAnnotationThreadEvents(data); - expect(stubs.emit).to.be.calledWith(data.event, data.data); - expect(stubs.unbind).to.not.be.called; - expect(stubs.remove).to.not.be.called; - }); - }); - describe('emit()', () => { const emitFunc = EventEmitter.prototype.emit; @@ -1219,103 +776,5 @@ describe('Annotator', () => { expect(annotator.isModeAnnotatable('drawing')).to.equal(false); }); }); - - describe('showModeAnnotateButton()', () => { - beforeEach(() => { - annotator.modeButtons = { - point: { - title: 'Point Annotation Mode', - selector: '.bp-btn-annotate' - } - }; - annotator.permissions.canAnnotate = true; - }); - - afterEach(() => { - annotator.modeButtons = {}; - annotator.container = document; - }); - - it('should do nothing if user cannot annotate', () => { - annotator.permissions.canAnnotate = false; - sandbox.stub(annotator, 'isModeAnnotatable').returns(true); - sandbox.stub(annotator, 'getAnnotationModeClickHandler'); - annotator.showModeAnnotateButton(TYPES.point); - expect(annotator.getAnnotationModeClickHandler).to.not.be.called; - }); - - it('should do nothing if the mode does not require a button', () => { - sandbox.stub(annotator, 'getAnnotationModeClickHandler'); - sandbox.stub(annotator, 'isModeAnnotatable').returns(true); - annotator.showModeAnnotateButton(TYPES.highlight); - expect(annotator.getAnnotationModeClickHandler).to.not.be.called; - }); - - it('should do nothing if the annotation type is not supported ', () => { - sandbox.stub(annotator, 'getAnnotationModeClickHandler'); - sandbox.stub(annotator, 'isModeAnnotatable').returns(false); - annotator.showModeAnnotateButton('bleh'); - expect(annotator.getAnnotationModeClickHandler).to.not.be.called; - }); - - it('should do nothing if the button is not in the container', () => { - annotator.modeButtons = { - point: { - title: 'Point Annotation Mode', - selector: 'wrong-selector' - } - }; - sandbox.stub(annotator, 'isModeAnnotatable').returns(true); - sandbox.stub(annotator, 'getAnnotationModeClickHandler'); - annotator.showModeAnnotateButton(TYPES.point); - expect(annotator.getAnnotationModeClickHandler).to.not.be.called; - }); - - it('should set up and show an annotate button', () => { - const buttonEl = annotator.container.querySelector('.bp-btn-annotate'); - buttonEl.classList.add('point-selector'); - buttonEl.classList.add(CLASS_HIDDEN); - - sandbox.stub(annotator, 'isModeAnnotatable').returns(true); - sandbox.stub(annotator, 'getAnnotationModeClickHandler'); - sandbox.mock(buttonEl).expects('addEventListener').withArgs('click'); - - annotator.showModeAnnotateButton(TYPES.point); - expect(buttonEl.title).to.equal('Point Annotation Mode'); - expect(annotator.getAnnotationModeClickHandler).to.be.called; - }); - }); - - describe('getAnnotateButton()', () => { - it('should return the annotate button', () => { - const selector = 'bp-btn-annotate'; - const buttonEl = annotator.getAnnotateButton(`.${selector}`); - expect(buttonEl).to.have.class(selector); - }); - }); - - describe('getAnnotationModeClickHandler()', () => { - beforeEach(() => { - stubs.isModeAnnotatable = sandbox.stub(annotator, 'isModeAnnotatable').returns(false); - }); - - it('should return null if you cannot annotate', () => { - const handler = annotator.getAnnotationModeClickHandler(TYPES.point); - expect(stubs.isModeAnnotatable).to.be.called; - expect(handler).to.equal(null); - }); - - it('should return the toggle point mode handler', () => { - stubs.isModeAnnotatable.returns(true); - stubs.toggle = sandbox.stub(annotator, 'toggleAnnotationHandler'); - - const handler = annotator.getAnnotationModeClickHandler(TYPES.point); - expect(stubs.isModeAnnotatable).to.be.called; - expect(handler).to.be.a('function'); - - handler(event); - expect(stubs.toggle).to.be.calledWith(TYPES.point); - }); - }); }); }); diff --git a/src/__tests__/BoxAnnotations-test.js b/src/__tests__/BoxAnnotations-test.js index 771c3ca51..5d0ea2c04 100644 --- a/src/__tests__/BoxAnnotations-test.js +++ b/src/__tests__/BoxAnnotations-test.js @@ -3,6 +3,8 @@ import BoxAnnotations from '../BoxAnnotations'; import { TYPES } from '../annotationConstants'; import * as annotatorUtil from '../annotatorUtil'; import DrawingModeController from '../controllers/DrawingModeController'; +import PointModeController from '../controllers/PointModeController'; +import HighlightModeController from '../controllers/HighlightModeController'; let loader; let stubs; @@ -60,6 +62,70 @@ describe('BoxAnnotations', () => { }); }); + describe('instantiateControllers()', () => { + it('Should do nothing when a controller exists', () => { + const config = { + CONTROLLERS: { + [TYPES.draw]: { + CONSTRUCTOR: sandbox.stub() + } + } + }; + + expect(() => loader.instantiateControllers(config)).to.not.throw(); + }); + + it('Should do nothing when given an undefined object', () => { + const config = undefined; + expect(() => loader.instantiateControllers(config)).to.not.throw(); + }); + + it('Should do nothing when config has no types', () => { + const config = { + TYPE: undefined + }; + expect(() => loader.instantiateControllers(config)).to.not.throw(); + }); + + it('Should instantiate controllers and assign them to the CONTROLLERS attribute', () => { + const config = { + TYPE: [TYPES.draw, 'typeWithoutController'] + }; + loader.viewerConfig = { enabledTypes: [TYPES.draw] }; + + loader.instantiateControllers(config); + expect(config.CONTROLLERS).to.not.equal(undefined); + expect(config.CONTROLLERS[TYPES.draw] instanceof DrawingModeController).to.be.truthy; + const assignedControllers = Object.keys(config.CONTROLLERS); + expect(assignedControllers.length).to.equal(1); + }); + }); + + describe('getAnnotatorTypes()', () => { + beforeEach(() => { + stubs.config = { + NAME: 'Document', + VIEWER: ['Document'], + TYPE: ['point', 'highlight', 'highlight-comment', 'draw'], + DEFAULT_TYPES: ['point', 'highlight'] + }; + }); + + it('should filter disabled annotation types from the annotator.TYPE', () => { + loader.viewerConfig = { disabledTypes: ['point'] }; + expect(loader.getAnnotatorTypes(stubs.config)).to.deep.equal(['highlight']); + }); + + it('should filter and only keep allowed types of annotations', () => { + loader.viewerConfig = { enabledTypes: ['point', 'timestamp'] }; + expect(loader.getAnnotatorTypes(stubs.config)).to.deep.equal(['point']); + }); + + it('should respect default annotators if none provided', () => { + expect(loader.getAnnotatorTypes(stubs.config)).to.deep.equal(['point', 'highlight']); + }); + }); + describe('determineAnnotator()', () => { beforeEach(() => { stubs.instantiateControllers = sandbox.stub(loader, 'instantiateControllers'); @@ -80,6 +146,7 @@ describe('BoxAnnotations', () => { NAME: 'Document' } } + sandbox.stub(loader, 'getAnnotatorTypes').returns(['point']); }); it('should not return an annotator if the user has incorrect permissions/scopes', () => { @@ -127,111 +194,5 @@ describe('BoxAnnotations', () => { const annotator = loader.determineAnnotator(stubs.options, config); expect(annotator).to.be.null; }); - - it('should filter disabled annotation types from the annotator.TYPE', () => { - const config = { - enabled: true, - disabledTypes: ['point'] - }; - - const docAnnotator = { - NAME: 'Document', - VIEWER: ['Document'], - TYPE: ['point', 'highlight'], - DEFAULT_TYPES: ['point', 'highlight'] - }; - sandbox.stub(loader, 'getAnnotatorsForViewer').returns(docAnnotator); - const annotator = loader.determineAnnotator(stubs.options, config); - - expect(annotator.TYPE.includes('point')).to.be.false; - expect(annotator.TYPE.includes('highlight')).to.be.true; - expect(annotator).to.deep.equal({ - NAME: 'Document', - VIEWER: ['Document'], - TYPE: ['highlight'], - DEFAULT_TYPES: ['point', 'highlight'] - }); - expect(loader.getAnnotatorsForViewer).to.be.called; - }); - - it('should filter and only keep allowed types of annotations', () => { - const config = { - enabled: true, - enabledTypes: ['point', 'timestamp'] - }; - - const docAnnotator = { - NAME: 'Document', - VIEWER: ['Document'], - TYPE: ['point', 'highlight', 'highlight-comment', 'draw'], - DEFAULT_TYPES: ['point', 'highlight'] - }; - - sandbox.stub(loader, 'getAnnotatorsForViewer').returns(docAnnotator); - const annotator = loader.determineAnnotator(stubs.options, config); - expect(annotator.TYPE.includes('point')).to.be.true; - expect(annotator.TYPE.includes('highlight')).to.be.false; - expect(annotator).to.deep.equal({ - NAME: 'Document', - VIEWER: ['Document'], - TYPE: ['point'], - DEFAULT_TYPES: ['point', 'highlight'] - }); - }); - - it('should respect default annotators if none provided', () => { - const config = { - enabled: true - }; - - const docAnnotator = { - NAME: 'Document', - VIEWER: ['Document'], - TYPE: ['point', 'highlight', 'highlight-comment', 'draw'], - DEFAULT_TYPES: ['point', 'draw'] - }; - sandbox.stub(loader, 'getAnnotatorsForViewer').returns(docAnnotator); - const annotator = loader.determineAnnotator(stubs.options, config); - - expect(annotator.TYPE).to.deep.equal(['point', 'draw']); - }); - }); - - describe('instantiateControllers()', () => { - it('Should do nothing when a controller exists', () => { - const config = { - CONTROLLERS: { - [TYPES.draw]: { - CONSTRUCTOR: sandbox.stub() - } - } - }; - - expect(() => loader.instantiateControllers(config)).to.not.throw(); - }); - - it('Should do nothing when given an undefined object', () => { - const config = undefined; - expect(() => loader.instantiateControllers(config)).to.not.throw(); - }); - - it('Should do nothing when config has no types', () => { - const config = { - TYPE: undefined - }; - expect(() => loader.instantiateControllers(config)).to.not.throw(); - }); - - it('Should instantiate controllers and assign them to the CONTROLLERS attribute', () => { - const config = { - TYPE: [TYPES.draw, 'typeWithoutController'] - }; - - loader.instantiateControllers(config); - expect(config.CONTROLLERS).to.not.equal(undefined); - expect(config.CONTROLLERS[TYPES.draw] instanceof DrawingModeController).to.be.truthy; - const assignedControllers = Object.keys(config.CONTROLLERS); - expect(assignedControllers.length).to.equal(1); - }); }); }); diff --git a/src/__tests__/annotatorUtil-test.js b/src/__tests__/annotatorUtil-test.js index 7d8baf2d3..a9e094e67 100644 --- a/src/__tests__/annotatorUtil-test.js +++ b/src/__tests__/annotatorUtil-test.js @@ -33,7 +33,9 @@ import { prevDefAndStopProp, canLoadAnnotations, insertTemplate, - generateBtn + generateBtn, + addThreadToMap, + removeThreadFromMap } from '../annotatorUtil'; import { STATES, @@ -742,4 +744,22 @@ describe('annotatorUtil', () => { expect(canLoadAnnotations(stubs.permissions)).to.be.true; }); }); + + describe('addThreadToMap()', () => { + it('should add thread to in-memory map', () => { + const thread = { threadID: '123abc', location: { page: 2 } }; + const result = addThreadToMap(thread, {}); + expect(result.page).equals(2); + expect(result.pageThreads).to.deep.equal({ '123abc': thread }); + }); + }); + + describe('removeThreadFromMap()', () => { + it('should remove thread from in-memory map', () => { + const thread = { threadID: '123abc', location: { page: 2 } }; + const result = removeThreadFromMap(thread, { '123abc': thread }); + expect(result.page).equals(2); + expect(result.pageThreads).to.deep.equal({}); + }); + }); }); diff --git a/src/annotationConstants.js b/src/annotationConstants.js index a1007596d..c2c2ff8dd 100644 --- a/src/annotationConstants.js +++ b/src/annotationConstants.js @@ -138,6 +138,17 @@ export const THREAD_EVENT = { createError: 'annotationcreateerror' }; +export const CONTROLLER_EVENT = { + toggleMode: 'togglemode', + enter: 'annotationmodeenter', + exit: 'annotationmodeexit', + register: 'registerthread', + unregister: 'unregisterthread', + showHighlights: 'showhighlights', + bindDOMListeners: 'binddomlisteners', + unbindDOMListeners: 'unbinddomlisteners' +}; + export const PAGE_PADDING_TOP = 15; export const PAGE_PADDING_BOTTOM = 15; diff --git a/src/annotatorUtil.js b/src/annotatorUtil.js index f7e0ba213..2bfe429d6 100644 --- a/src/annotatorUtil.js +++ b/src/annotatorUtil.js @@ -758,3 +758,34 @@ export function canLoadAnnotations(permissions) { return !!canAnnotate || !!canViewAllAnnotations || !!canViewOwnAnnotations; } + +/** + * Adds thread to in-memory map. + * + * @protected + * @param {AnnotationThread} thread - Thread to add + * @param {Object} threadMap - Thread map + * @return {void} + */ +export function addThreadToMap(thread, threadMap) { + // Add thread to in-memory map + const page = thread.location.page || 1; // Defaults to page 1 if thread has no page' + const pageThreads = threadMap[page] || {}; + pageThreads[thread.threadID] = thread; + return { page, pageThreads }; +} + +/** + * Removes thread to in-memory map. + * + * @protected + * @param {AnnotationThread} thread - Thread to bind events to + * @param {Object} threadMap - Thread map + * @return {void} + */ +export function removeThreadFromMap(thread, threadMap) { + const page = thread.location.page || 1; // Defaults to page 1 if thread has no page' + const pageThreads = threadMap[page] || {}; + delete pageThreads[thread.threadID]; + return { page, pageThreads }; +} diff --git a/src/controllers/AnnotationModeController.js b/src/controllers/AnnotationModeController.js index 8c2bdf09d..09bca6d8a 100644 --- a/src/controllers/AnnotationModeController.js +++ b/src/controllers/AnnotationModeController.js @@ -1,24 +1,158 @@ import EventEmitter from 'events'; -import { insertTemplate } from '../annotatorUtil'; +import { insertTemplate, isPending, addThreadToMap, removeThreadFromMap } from '../annotatorUtil'; +import { + CLASS_HIDDEN, + CLASS_ACTIVE, + CLASS_ANNOTATION_MODE, + ANNOTATOR_EVENT, + THREAD_EVENT, + CONTROLLER_EVENT +} from '../annotationConstants'; class AnnotationModeController extends EventEmitter { - /** @property {Object} - Object of annotation threads indexed by threadID */ + /** @property {Object} - Object containing annotation threads */ threads = {}; /** @property {Array} - The array of annotation handlers */ handlers = []; + /** @property {HTMLElement} - Container of the annotatedElement */ + container; + + /** @property {HTMLElement} - Annotated HTML DOM element */ + annotatedElement; + + /** @property {string} - Mode for annotation controller */ + mode; + /** - * Register the annotator and any information associated with the annotator + * Initializes mode controller. + * + * @param {Object} data - Options for constructing a controller + * @return {void} + */ + init(data) { + this.container = data.container; + this.annotatedElement = data.annotatedElement; + this.mode = data.mode; + this.annotator = data.annotator; + this.permissions = data.permissions; + this.isTouchCompatible = data.options ? data.options.isTouchCompatible : false; + + if (data.modeButton) { + this.modeButton = data.modeButton; + this.showButton(); + } + + this.handleThreadEvents = this.handleThreadEvents.bind(this); + } + + /** + * [destructor] + * + * @return {void} + */ + destroy() { + Object.keys(this.threads).forEach((page) => { + const pageThreads = this.threads[page] || {}; + + Object.keys(pageThreads).forEach((threadID) => { + const thread = pageThreads[threadID]; + this.unregisterThread(thread); + }); + }); + + if (this.buttonEl) { + this.buttonEl.removeEventListener('click', this.toggleMode); + } + } + + /** + * Gets the annotation button element. + * + * @param {string} annotatorSelector - Class selector for a custom annotation button. + * @return {HTMLElement|null} Annotate button element or null if the selector did not find an element. + */ + getButton(annotatorSelector) { + return this.container.querySelector(annotatorSelector); + } + + /** + * Shows the annotate button for the specified mode * - * @public - * @param {Annotator} annotator - The annotator to be associated with the controller * @return {void} */ - registerAnnotator(annotator) { - // TODO (@minhnguyen): remove the need to register an annotator. Ideally, the annotator should know about the - // controller and the controller does not know about the annotator. - this.annotator = annotator; + showButton() { + if (!this.permissions.canAnnotate) { + return; + } + + this.buttonEl = this.getButton(this.modeButton.selector); + if (this.buttonEl) { + this.buttonEl.title = this.modeButton.title; + this.buttonEl.classList.remove(CLASS_HIDDEN); + + this.toggleMode = this.toggleMode.bind(this); + this.buttonEl.addEventListener('click', this.toggleMode); + } + } + + /** + * Toggles annotation modes on and off. When an annotation mode is + * on, annotation threads will be created at that location. + * + * @return {void} + */ + toggleMode() { + this.destroyPendingThreads(); + + // No specific mode available for annotation type + if (!this.modeButton) { + return; + } + + // Exit any other annotation mode + this.emit(CONTROLLER_EVENT.toggleMode); + } + + /** + * Disables the specified annotation mode + * + * @return {void} + */ + exit() { + this.destroyPendingThreads(); + this.annotatedElement.classList.remove(CLASS_ANNOTATION_MODE); + if (this.buttonEl) { + this.buttonEl.classList.remove(CLASS_ACTIVE); + } + + this.unbindListeners(); // Disable mode + this.emit(CONTROLLER_EVENT.exit); + } + + /** + * Enables the specified annotation mode + * + * @return {void} + */ + enter() { + this.annotatedElement.classList.add(CLASS_ANNOTATION_MODE); + if (this.buttonEl) { + this.buttonEl.classList.add(CLASS_ACTIVE); + } + + this.emit(CONTROLLER_EVENT.enter); // Disable other annotations + this.bindListeners(); // Enable mode + } + + /** + * Returns whether or not the current annotation mode is enabled + * + * @return {boolean} Whether or not the annotation mode is enabled + */ + isEnabled() { + return this.buttonEl ? this.buttonEl.classList.contains(CLASS_ACTIVE) : false; } /** @@ -27,7 +161,7 @@ class AnnotationModeController extends EventEmitter { * @public * @return {void} */ - bindModeListeners() { + bindListeners() { const currentHandlerIndex = this.handlers.length; this.setupHandlers(); @@ -45,7 +179,7 @@ class AnnotationModeController extends EventEmitter { * @public * @return {void} */ - unbindModeListeners() { + unbindListeners() { while (this.handlers.length > 0) { const handler = this.handlers.pop(); const types = handler.type instanceof Array ? handler.type : [handler.type]; @@ -64,12 +198,10 @@ class AnnotationModeController extends EventEmitter { * @return {void} */ registerThread(thread) { - const page = thread.location.page || 1; // Defaults to page 1 if thread has no page' - if (!(page in this.threads)) { - this.threads[page] = {}; - } - const pageThreads = this.threads[page]; - pageThreads[thread.threadID] = thread; + const { page, pageThreads } = addThreadToMap(thread, this.threads); + this.threads[page] = pageThreads; + this.emit(CONTROLLER_EVENT.register, thread); + thread.addListener('threadevent', (data) => this.handleThreadEvents(thread, data)); } /** @@ -80,50 +212,38 @@ class AnnotationModeController extends EventEmitter { * @return {void} */ unregisterThread(thread) { - const page = thread.location.page || 1; - delete this.threads[page][thread.threadID]; + const { page, pageThreads } = removeThreadFromMap(thread, this.threads); + this.threads[page] = pageThreads; + this.emit(CONTROLLER_EVENT.unregister, thread); + thread.removeListener('threadevent', this.handleThreadEvents); } /** - * Clean up any selected annotations + * Gets thread specified by threadID * - * @return {void} + * @private + * @param {number} threadID - Thread ID + * @return {AnnotationThread} Annotation thread specified by threadID */ - removeSelection() {} - - /** - * Binds custom event listeners for a thread. - * - * @protected - * @param {AnnotationThread} thread - Thread to bind events to - * @return {void} - */ - bindCustomListenersOnThread(thread) { - if (!thread) { - return; - } - - // TODO (@minhnguyen): Move annotator.bindCustomListenersOnThread logic to AnnotationModeController - this.annotator.bindCustomListenersOnThread(thread); - thread.addListener('threadevent', (data) => { - this.handleAnnotationEvent(thread, data); + getThreadByID(threadID) { + let thread = null; + Object.keys(this.threads).some((page) => { + const pageThreads = this.threads[page] || {}; + if (threadID in pageThreads) { + thread = pageThreads[threadID]; + } + return thread !== null; }); + + return thread; } /** - * Unbinds custom event listeners for the thread. + * Clean up any selected annotations * - * @protected - * @param {AnnotationThread} thread - Thread to unbind events from * @return {void} */ - unbindCustomListenersOnThread(thread) { - if (!thread) { - return; - } - - thread.removeAllListeners('threadevent'); - } + removeSelection() {} /** * Set up and return the necessary handlers for the annotation mode @@ -133,18 +253,43 @@ class AnnotationModeController extends EventEmitter { * the type of events to listen for, and the callback */ setupHandlers() {} + /* eslint-enable no-unused-vars */ /** - * Handle an annotation event. + * Handles annotation thread events and emits them to the viewer * - * @protected + * @private * @param {AnnotationThread} thread - The thread that emitted the event - * @param {Object} data - Extra data related to the annotation event + * @param {Object} [data] - Annotation thread event data + * @param {string} [data.event] - Annotation thread event + * @param {string} [data.data] - Annotation thread event data * @return {void} */ - /* eslint-disable no-unused-vars */ - handleAnnotationEvent(thread, data = {}) {} - /* eslint-enable no-unused-vars */ + handleThreadEvents(thread, data) { + switch (data.event) { + case THREAD_EVENT.threadCleanup: + // Thread should be cleaned up, unbind listeners - we + // don't do this in annotationdelete listener since thread + // may still need to respond to error messages + this.unregisterThread(thread); + break; + case THREAD_EVENT.threadDelete: + // Thread was deleted, remove from thread map + this.unregisterThread(thread); + this.emit(data.event, data.data); + break; + case THREAD_EVENT.deleteError: + this.emit(ANNOTATOR_EVENT.error, this.localized.deleteError); + this.emit(data.event, data.data); + break; + case THREAD_EVENT.createError: + this.emit(ANNOTATOR_EVENT.error, this.localized.createError); + this.emit(data.event, data.data); + break; + default: + this.emit(data.event, data.data); + } + } /** * Creates a handler description object and adds its to the internal handler container. @@ -180,6 +325,48 @@ class AnnotationModeController extends EventEmitter { const baseHeaderEl = container.firstElementChild; insertTemplate(container, header, baseHeaderEl); } + + /** + * Destroys pending threads. + * + * @private + * @return {boolean} Whether or not any pending threads existed on the + * current file + */ + destroyPendingThreads() { + let hasPendingThreads = false; + + Object.keys(this.threads).forEach((page) => { + const pageThreads = this.threads[page] || {}; + + Object.keys(pageThreads).forEach((threadID) => { + const thread = pageThreads[threadID]; + if (isPending(thread.state)) { + hasPendingThreads = true; + thread.destroy(); + } + }); + }); + return hasPendingThreads; + } + + /** + * Emits a generic annotator event + * + * @private + * @emits annotatorevent + * @param {string} event - Event name + * @param {Object} data - Event data + * @return {void} + */ + emit(event, data) { + super.emit(event, data); + super.emit('annotationcontrollerevent', { + event, + data, + mode: this.mode + }); + } } export default AnnotationModeController; diff --git a/src/controllers/DrawingModeController.js b/src/controllers/DrawingModeController.js index bce07666f..e6a089221 100644 --- a/src/controllers/DrawingModeController.js +++ b/src/controllers/DrawingModeController.js @@ -4,14 +4,19 @@ import annotationsShell from './../annotationsShell.html'; import DocDrawingThread from '../doc/DocDrawingThread'; import * as annotatorUtil from '../annotatorUtil'; import { - CLASS_ANNOTATION_DRAW, TYPES, STATES, SELECTOR_ANNOTATION_BUTTON_DRAW_CANCEL, SELECTOR_ANNOTATION_BUTTON_DRAW_POST, SELECTOR_ANNOTATION_BUTTON_DRAW_UNDO, SELECTOR_ANNOTATION_BUTTON_DRAW_REDO, - DRAW_BORDER_OFFSET + SELECTOR_BOX_PREVIEW_BASE_HEADER, + SELECTOR_ANNOTATION_DRAWING_HEADER, + CLASS_ANNNOTATION_DRAWING_BACKGROUND, + DRAW_BORDER_OFFSET, + CLASS_ACTIVE, + CLASS_ANNOTATION_MODE, + CONTROLLER_EVENT } from '../annotationConstants'; class DrawingModeController extends AnnotationModeController { @@ -31,27 +36,64 @@ class DrawingModeController extends AnnotationModeController { redoButtonEl; /** - * Register the annotator and any information associated with the annotator + * Initializes mode controller. * * @inheritdoc - * @public - * @param {Annotator} annotator - The annotator to be associated with the controller + * @param {Object} data - Options for constructing a controller * @return {void} */ - registerAnnotator(annotator) { - super.registerAnnotator(annotator); - - if (annotator.options.header !== 'none') { - // We need to create our own header UI - this.setupHeader(annotator.container, annotationsShell); + init(data) { + super.init(data); + + // If the header coming from the preview options is not none (e.g. + // light, dark, or no value given), then we want to use our draw + // header. Otherwise we expect header UI to be handled by Preview’s + // consumer + if (data.options.header !== 'none') { + this.setupHeader(this.container, annotationsShell); } - this.cancelButtonEl = annotator.getAnnotateButton(SELECTOR_ANNOTATION_BUTTON_DRAW_CANCEL); - this.postButtonEl = annotator.getAnnotateButton(SELECTOR_ANNOTATION_BUTTON_DRAW_POST); - this.undoButtonEl = annotator.getAnnotateButton(SELECTOR_ANNOTATION_BUTTON_DRAW_UNDO); - this.redoButtonEl = annotator.getAnnotateButton(SELECTOR_ANNOTATION_BUTTON_DRAW_REDO); + this.cancelButtonEl = this.getButton(SELECTOR_ANNOTATION_BUTTON_DRAW_CANCEL); + this.postButtonEl = this.getButton(SELECTOR_ANNOTATION_BUTTON_DRAW_POST); + this.undoButtonEl = this.getButton(SELECTOR_ANNOTATION_BUTTON_DRAW_UNDO); + this.redoButtonEl = this.getButton(SELECTOR_ANNOTATION_BUTTON_DRAW_REDO); + + this.handleSelection = this.handleSelection.bind(this); + } + + /** + * Disables the specified annotation mode + * + * @inheritdoc + * @return {void} + */ + exit() { + this.emit(CONTROLLER_EVENT.exit, { headerSelector: SELECTOR_BOX_PREVIEW_BASE_HEADER }); + + this.annotatedElement.classList.remove(CLASS_ANNOTATION_MODE); + this.annotatedElement.classList.remove(CLASS_ANNNOTATION_DRAWING_BACKGROUND); + + this.buttonEl.classList.remove(CLASS_ACTIVE); + + this.unbindListeners(); // Disable mode + this.emit(CONTROLLER_EVENT.bindDOMListeners); + } + + /** + * Enables the specified annotation mode + * + * @inheritdoc + * @return {void} + */ + enter() { + this.annotatedElement.classList.add(CLASS_ANNOTATION_MODE); + this.annotatedElement.classList.add(CLASS_ANNNOTATION_DRAWING_BACKGROUND); + + this.buttonEl.classList.add(CLASS_ACTIVE); - this.annotator.annotatedElement.classList.add(CLASS_ANNOTATION_DRAW); + this.emit(CONTROLLER_EVENT.enter, { headerSelector: SELECTOR_ANNOTATION_DRAWING_HEADER }); + this.emit(CONTROLLER_EVENT.unbindDOMListeners); // Disable other annotations + this.bindListeners(); // Enable mode } /** @@ -74,6 +116,10 @@ class DrawingModeController extends AnnotationModeController { /* eslint-enable new-cap */ } this.threads[page].insert(thread); + this.emit(CONTROLLER_EVENT.register, thread); + thread.addListener('threadevent', (data) => { + this.handleThreadEvents(thread, data); + }); } /** @@ -89,56 +135,67 @@ class DrawingModeController extends AnnotationModeController { return; } - const page = thread.location.page || 1; + const page = thread.location.page || 1; // Defaults to page 1 if thread has no page' this.threads[page].remove(thread); + this.emit(CONTROLLER_EVENT.unregister, thread); + thread.removeListener('threadevent', this.handleThreadEvents); } /** - * Binds custom event listeners for a thread. + * Bind the DOM listeners for this mode * * @inheritdoc - * @protected - * @param {AnnotationThread} thread - Thread to bind events to + * @public * @return {void} */ - bindCustomListenersOnThread(thread) { - if (!thread) { - return; + bindDOMListeners() { + if (this.isTouchCompatible) { + this.annotatedElement.addEventListener('touchstart', this.handleSelection); + } else { + this.annotatedElement.addEventListener('click', this.handleSelection); } - - super.bindCustomListenersOnThread(thread); - - // On save, add the thread to the Rbush, on delete, remove it from the Rbush - thread.addListener('annotationsaved', () => this.registerThread(thread)); - thread.addListener('annotationdelete', () => this.unregisterThread(thread)); } /** - * Unbind drawing mode listeners. Resets the undo and redo buttons to be disabled if they exist + * Unbind the DOM listeners for this mode * * @inheritdoc - * @protected + * @public * @return {void} */ - unbindModeListeners() { - super.unbindModeListeners(); + unbindDOMListeners() { + if (this.isTouchCompatible) { + this.annotatedElement.removeEventListener('touchstart', this.handleSelection); + } else { + this.annotatedElement.removeEventListener('click', this.handleSelection); + } + } - annotatorUtil.disableElement(this.undoButtonEl); - annotatorUtil.disableElement(this.redoButtonEl); + /** + * Bind the mode listeners and store each handler for future unbinding + * + * @inheritdoc + * @public + * @return {void} + */ + bindListeners() { + super.bindListeners(); + this.unbindDOMListeners(); } /** - * Deselect a saved and selected thread + * Unbind drawing mode listeners. Resets the undo and redo buttons to be disabled if they exist * + * @inheritdoc + * @protected * @return {void} */ - removeSelection() { - if (!this.selectedThread) { - return; - } + unbindListeners() { + super.unbindListeners(); + this.bindDOMListeners(); - this.selectedThread.clearBoundary(); - this.selectedThread = undefined; + annotatorUtil.disableElement(this.undoButtonEl); + annotatorUtil.disableElement(this.redoButtonEl); } /** @@ -158,35 +215,37 @@ class DrawingModeController extends AnnotationModeController { // Setup const threadParams = this.annotator.getThreadParams([], {}, TYPES.draw); this.currentThread = new DocDrawingThread(threadParams); - this.bindCustomListenersOnThread(this.currentThread); + this.currentThread.addListener('threadevent', (data) => { + this.handleThreadEvents(this.currentThread, data); + }); // Get handlers this.pushElementHandler( - this.annotator.annotatedElement, + this.annotatedElement, ['mousemove', 'touchmove'], annotatorUtil.eventToLocationHandler(locationFunction, this.currentThread.handleMove) ); this.pushElementHandler( - this.annotator.annotatedElement, + this.annotatedElement, ['mousedown', 'touchstart'], annotatorUtil.eventToLocationHandler(locationFunction, this.currentThread.handleStart) ); this.pushElementHandler( - this.annotator.annotatedElement, + this.annotatedElement, ['mouseup', 'touchcancel', 'touchend'], annotatorUtil.eventToLocationHandler(locationFunction, this.currentThread.handleStop) ); this.pushElementHandler(this.cancelButtonEl, 'click', () => { this.currentThread.cancelUnsavedAnnotation(); - this.annotator.toggleAnnotationHandler(TYPES.draw); + this.toggleMode(); }); this.pushElementHandler(this.postButtonEl, 'click', () => { this.currentThread.saveAnnotation(TYPES.draw); - this.annotator.toggleAnnotationHandler(TYPES.draw); + this.toggleMode(); }); this.pushElementHandler(this.undoButtonEl, 'click', this.currentThread.undo); @@ -202,20 +261,20 @@ class DrawingModeController extends AnnotationModeController { * @param {Object} data - Extra data related to the annotation event * @return {void} */ - handleAnnotationEvent(thread, data = {}) { + handleThreadEvents(thread, data = {}) { const { eventData } = data; switch (data.event) { case 'locationassigned': // Register the thread to the threadmap when a starting location is assigned. Should only occur once. - this.annotator.addThreadToMap(thread); + this.registerThread(thread); break; case 'softcommit': // Save the original thread, create a new thread and // start drawing at the location indicating the page change this.currentThread = undefined; thread.saveAnnotation(TYPES.draw); - this.unbindModeListeners(); - this.bindModeListeners(); + this.unbindListeners(); + this.bindListeners(); // Given a location (page change) start drawing at the provided location if (eventData && eventData.location) { @@ -224,12 +283,16 @@ class DrawingModeController extends AnnotationModeController { break; case 'dialogdelete': + if (!thread.dialog) { + return; + } + if (thread.state === STATES.pending) { // Soft delete, in-progress thread doesn't require a redraw or a delete on the server // Clear in-progress thread and restart drawing thread.destroy(); - this.unbindModeListeners(); - this.bindModeListeners(); + this.unbindListeners(); + this.bindListeners(); } else { thread.deleteThread(); this.unregisterThread(thread); @@ -245,6 +308,8 @@ class DrawingModeController extends AnnotationModeController { break; default: } + + super.handleThreadEvents(thread, data); } /** @@ -290,6 +355,21 @@ class DrawingModeController extends AnnotationModeController { this.select(selected); } + /** + * Deselect a saved and selected thread + * + * @private + * @return {void} + */ + removeSelection() { + if (!this.selectedThread) { + return; + } + + this.selectedThread.clearBoundary(); + this.selectedThread = undefined; + } + /** * Select the indicated drawing thread. Deletes a drawing thread upon the second consecutive selection * diff --git a/src/controllers/HighlightModeController.js b/src/controllers/HighlightModeController.js new file mode 100644 index 000000000..736a7b2a4 --- /dev/null +++ b/src/controllers/HighlightModeController.js @@ -0,0 +1,52 @@ +import AnnotationModeController from './AnnotationModeController'; +import { THREAD_EVENT, CONTROLLER_EVENT } from '../annotationConstants'; + +class HighlightModeController extends AnnotationModeController { + /** + * Handles annotation thread events and emits them to the viewer + * + * @inheritdoc + * @private + * @param {AnnotationThread} thread - The thread that emitted the event + * @param {Object} [data] - Annotation thread event data + * @param {string} [data.event] - Annotation thread event + * @param {string} [data.data] - Annotation thread event data + * @return {void} + */ + handleThreadEvents(thread, data) { + switch (data.event) { + case THREAD_EVENT.threadCleanup: + this.emit(CONTROLLER_EVENT.showHighlights, thread.location.page); + break; + default: + } + + super.handleThreadEvents(thread, data); + } + + /** + * Disables the specified annotation mode + * + * @inheritdoc + * @return {void} + */ + exit() { + this.destroyPendingThreads(); + window.getSelection().removeAllRanges(); + this.unbindListeners(); // Disable mode + this.emit(CONTROLLER_EVENT.bindDOMListeners); + } + + /** + * Enables the specified annotation mode + * + * @inheritdoc + * @return {void} + */ + enter() { + this.emit(CONTROLLER_EVENT.unbindDOMListeners); // Disable other annotations + this.bindListeners(); // Enable mode + } +} + +export default HighlightModeController; diff --git a/src/controllers/PointModeController.js b/src/controllers/PointModeController.js new file mode 100644 index 000000000..aa69e2df1 --- /dev/null +++ b/src/controllers/PointModeController.js @@ -0,0 +1,76 @@ +import AnnotationModeController from './AnnotationModeController'; +import { TYPES, THREAD_EVENT, CONTROLLER_EVENT } from '../annotationConstants'; + +class PointModeController extends AnnotationModeController { + /** @property {HTMLElement} - The button to cancel the pending thread */ + cancelButtonEl; + + /** @property {HTMLElement} - The button to commit the pending thread */ + postButtonEl; + + /** + * Set up and return the necessary handlers for the annotation mode + * + * @inheritdoc + * @protected + * @return {Array} An array where each element is an object containing + * the object that will emit the event, the type of events to listen + * for, and the callback + */ + setupHandlers() { + this.pointClickHandler = this.pointClickHandler.bind(this); + // Get handlers + this.pushElementHandler(this.annotatedElement, ['mousedown', 'touchstart'], this.pointClickHandler); + + this.pushElementHandler(this.cancelButtonEl, 'click', () => { + this.currentThread.cancelUnsavedAnnotation(); + this.emit(CONTROLLER_EVENT.toggleMode); + }); + + this.pushElementHandler(this.postButtonEl, 'click', () => { + this.currentThread.saveAnnotation(TYPES.point); + this.emit(CONTROLLER_EVENT.toggleMode); + }); + } + + /** + * Event handler for adding a point annotation. Creates a point annotation + * thread at the clicked location. + * + * @protected + * @param {Event} event - DOM event + * @return {void} + */ + pointClickHandler(event) { + event.stopPropagation(); + event.preventDefault(); + + // Determine if a point annotation dialog is already open and close the + // current open dialog + const hasPendingThreads = this.destroyPendingThreads(); + if (hasPendingThreads) { + return; + } + + // Exits point annotation mode on first click + this.emit(CONTROLLER_EVENT.toggleMode); + + // Get annotation location from click event, ignore click if location is invalid + const location = this.annotator.getLocationFromEvent(event, TYPES.point); + if (!location) { + return; + } + + // Create new thread with no annotations, show indicator, and show dialog + const thread = this.annotator.createAnnotationThread([], location, TYPES.point); + + if (thread) { + thread.show(); + this.registerThread(thread); + } + + this.emit(THREAD_EVENT.pending, thread.getThreadEventData()); + } +} + +export default PointModeController; diff --git a/src/controllers/__tests__/AnnotationModeController-test.js b/src/controllers/__tests__/AnnotationModeController-test.js index d676f71ce..8d5d35f44 100644 --- a/src/controllers/__tests__/AnnotationModeController-test.js +++ b/src/controllers/__tests__/AnnotationModeController-test.js @@ -1,34 +1,208 @@ +import EventEmitter from 'events'; import AnnotationModeController from '../AnnotationModeController'; import DocDrawingThread from '../../doc/DocDrawingThread'; import * as util from '../../annotatorUtil'; +import { + CLASS_HIDDEN, + CLASS_ACTIVE, + CLASS_ANNOTATION_MODE, + ANNOTATOR_EVENT, + THREAD_EVENT, + STATES, + CONTROLLER_EVENT +} from '../../annotationConstants'; let controller; -let stubs; +let stubs = {}; const sandbox = sinon.sandbox.create(); describe('controllers/AnnotationModeController', () => { beforeEach(() => { controller = new AnnotationModeController(); - stubs = {}; + stubs.thread = { + threadID: '123abc', + location: { page: 1 }, + type: 'type', + state: STATES.pending, + addListener: () => {}, + removeListener: () => {}, + saveAnnotation: () => {}, + handleStart: () => {}, + destroy: () => {}, + deleteThread: () => {}, + show: () => {} + }; + stubs.threadMock = sandbox.mock(stubs.thread); }); afterEach(() => { sandbox.verifyAndRestore(); - stubs = null; + stubs = {}; controller = null; }); - describe('registerAnnotator()', () => { - it('should internally keep track of the registered annotator', () => { - const annotator = 'I am an annotator'; - expect(controller.annotator).to.be.undefined; + describe('init()', () => { + it('should init controller', () => { + sandbox.stub(controller, 'showButton'); + controller.init({ modeButton: {} }); + expect(controller.showButton).to.be.called; + }); + + it('should not show modeButton if none provided', () => { + sandbox.stub(controller, 'showButton'); + controller.init({}); + expect(controller.showButton).to.not.be.called; + }); + }); + + describe('destroy()', () => { + it('should destroy all the threads in controller', () => { + controller.threads = { 1: { + '123abc': stubs.thread + }}; + + controller.destroy(); + expect(controller.buttonEl).to.be.undefined; + }); + + it('should remove listener from button', () => { + controller.buttonEl = { + removeEventListener: sandbox.stub() + }; + controller.destroy(); + expect(controller.buttonEl.removeEventListener).to.be.called; + }); + }); + + describe('getButton', () => { + it('should return the annotation mode button', () => { + const buttonEl = document.createElement('button'); + buttonEl.classList.add('class'); + controller.container = document.createElement('div'); + controller.container.appendChild(buttonEl); + + expect(controller.getButton('.class')).to.not.be.null; + }); + }) + + describe('showButton()', () => { + beforeEach(() => { + controller.modeButton = { + type: { + title: 'Annotation Mode', + selector: '.selector' + } + }; + const buttonEl = document.createElement('button'); + buttonEl.title = controller.modeButton.title; + buttonEl.classList.add(CLASS_HIDDEN); + buttonEl.classList.add('selector'); + + controller.permissions = { canAnnotate: true }; + stubs.getButton = sandbox.stub(controller, 'getButton').returns(buttonEl); + }); + + it('should do nothing if user cannot annotate', () => { + const buttonEl = controller.getButton(controller.modeButton.selector); + controller.permissions.canAnnotate = false; + controller.showButton(); + expect(buttonEl).to.have.class(CLASS_HIDDEN); + }); + + it('should do nothing if the button is not in the container', () => { + const buttonEl = controller.getButton(controller.modeButton.selector); + stubs.getButton.returns(null); + controller.showButton(); + expect(buttonEl).to.have.class(CLASS_HIDDEN); + }); + + it('should set up and show an annotate button', () => { + const buttonEl = controller.getButton(controller.modeButton.selector); + sandbox.stub(buttonEl, 'addEventListener'); + + controller.showButton(); + expect(buttonEl).to.not.have.class(CLASS_HIDDEN); + expect(buttonEl.addEventListener).to.be.calledWith('click', controller.toggleMode); + }); + }); + + describe('toggleMode()', () => { + beforeEach(() => { + sandbox.stub(controller, 'destroyPendingThreads'); + sandbox.stub(controller, 'emit'); + }); + + it('should destroy all threads', () => { + controller.modeButton = undefined; + controller.toggleMode(); + expect(controller.emit).to.not.be.calledWith(CONTROLLER_EVENT.toggleMode); + }); + + it('should only toggle the current annotation mode if it has a button', () => { + controller.modeButton = {}; + controller.toggleMode(); + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.toggleMode); + }); + }); + + describe('exit()', () => { + beforeEach(() => { + sandbox.stub(controller, 'destroyPendingThreads'); + sandbox.stub(controller, 'unbindListeners'); + sandbox.stub(controller, 'emit'); + + controller.annotatedElement = document.createElement('div'); + controller.annotatedElement.classList.add(CLASS_ANNOTATION_MODE); + }); + + it('should exit annotation mode', () => { + controller.exit(); + expect(controller.destroyPendingThreads).to.be.called; + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.exit); + expect(controller.unbindListeners).to.be.called; + }); + + it('should deactive mode button if available', () => { + controller.buttonEl = document.createElement('button'); + controller.buttonEl.classList.add(CLASS_ACTIVE); + controller.exit(); + expect(controller.buttonEl).to.not.have.class(CLASS_ACTIVE); + }); + }); + + describe('enter()', () => { + beforeEach(() => { + sandbox.stub(controller, 'bindListeners'); + sandbox.stub(controller, 'emit'); + + controller.annotatedElement = document.createElement('div'); + controller.annotatedElement.classList.add(CLASS_ANNOTATION_MODE); + }); + + it('should enter annotation mode', () => { + controller.enter(); + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.enter); + expect(controller.bindListeners).to.be.called; + }); - controller.registerAnnotator(annotator); - expect(controller.annotator).to.equal(annotator); + it('should activate mode button if available', () => { + controller.buttonEl = document.createElement('button'); + controller.enter(); + expect(controller.buttonEl).to.have.class(CLASS_ACTIVE); }); }); - describe('bindModeListeners()', () => { + describe('isEnabled()', () => { + it('should return whether or not the current annotation mode is enabled', () => { + controller.buttonEl = document.createElement('button'); + expect(controller.isEnabled()).to.be.falsy; + + controller.buttonEl.classList.add(CLASS_ACTIVE); + expect(controller.isEnabled()).to.be.truthy; + }); + }) + + describe('bindListeners()', () => { it('should bind mode listeners', () => { const handlerObj = { type: 'event', @@ -42,13 +216,13 @@ describe('controllers/AnnotationModeController', () => { }); expect(controller.handlers.length).to.equal(0); - controller.bindModeListeners(); + controller.bindListeners(); expect(handlerObj.eventObj.addEventListener).to.be.calledWith(handlerObj.type, handlerObj.func); expect(controller.handlers.length).to.equal(1); }); }); - describe('unbindModeListeners()', () => { + describe('unbindListeners()', () => { it('should unbind mode listeners', () => { const handlerObj = { type: 'event', @@ -61,7 +235,7 @@ describe('controllers/AnnotationModeController', () => { controller.handlers = [handlerObj]; expect(controller.handlers.length).to.equal(1); - controller.unbindModeListeners(); + controller.unbindListeners(); expect(handlerObj.eventObj.removeEventListener).to.be.calledWith(handlerObj.type, handlerObj.func); expect(controller.handlers.length).to.equal(0); }); @@ -69,95 +243,94 @@ describe('controllers/AnnotationModeController', () => { describe('registerThread()', () => { it('should internally keep track of the registered thread', () => { + sandbox.stub(controller, 'emit'); controller.threads = { 1: {} }; const pageThreads = controller.threads[1]; const thread = { threadID: '123abc', - location: { page: 1 } + location: { page: 1 }, + addListener: sandbox.stub() }; expect(thread.threadID in pageThreads).to.be.falsy; controller.registerThread(thread); expect(pageThreads[thread.threadID]).equals(thread); + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.register, thread); + expect(thread.addListener).to.be.calledWith('threadevent', sinon.match.func); }); }); describe('unregisterThread()', () => { it('should internally keep track of the registered thread', () => { + sandbox.stub(controller, 'emit'); controller.threads = { 1: {} }; const pageThreads = controller.threads[1]; const thread = { threadID: '123abc', - location: { page: 1 } + location: { page: 1 }, + addListener: sandbox.stub(), + removeListener: sandbox.stub() }; controller.registerThread(thread); expect(thread.threadID in pageThreads).to.be.truthy; controller.unregisterThread(thread); expect(thread.threadID in pageThreads).to.be.falsy; + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.unregister, thread); + expect(thread.removeListener).to.be.calledWith('threadevent', sinon.match.func); }); }); - describe('bindCustomListenersOnThread()', () => { - it('should do nothing when the input is empty', () => { - controller.annotator = { - bindCustomListenersOnThread: sandbox.stub() - }; - - controller.bindCustomListenersOnThread(undefined); - expect(controller.annotator.bindCustomListenersOnThread).to.not.be.called; + describe('getThreadByID()', () => { + it('should find and return annotation thread specified by threadID', () => { + controller.threads = { 1: { '123abc': stubs.thread } }; + const thread = controller.getThreadByID(stubs.thread.threadID); + expect(thread).to.deep.equals(stubs.thread); }); - it('should bind custom listeners on thread', () => { - const thread = { - addListener: sandbox.stub() - }; - controller.annotator = { - bindCustomListenersOnThread: sandbox.stub() - }; - - controller.bindCustomListenersOnThread(thread); - expect(controller.annotator.bindCustomListenersOnThread).to.be.called; - expect(thread.addListener).to.be.called; + it('should return null if specified annotation thread is invalid', () => { + controller.threads = { 1: { '123abc': stubs.thread } }; + const thread = controller.getThreadByID('random'); + expect(thread).to.deep.equals(null); }); + }); - // Catches edge case where sometimes the first click upon entering - // Draw annotation mode, the annotator is not registered properly - // with the controller - it('should maintain annotator context when a "threadevent" is fired', () => { - Object.defineProperty(DocDrawingThread.prototype, 'setup', { value: sandbox.stub() }); - Object.defineProperty(DocDrawingThread.prototype, 'getThreadEventData', { value: sandbox.stub() }); - const thread = new DocDrawingThread({ threadID: 123 }); - - controller.handleAnnotationEvent = () => { - expect(controller.annotator).to.not.be.undefined; - }; - controller.annotator = { - bindCustomListenersOnThread: sandbox.stub() - }; + describe('handleThreadEvents()', () => { + beforeEach(() => { + sandbox.stub(controller, 'unregisterThread'); + sandbox.stub(controller, 'emit'); + controller.localized = { + deleteError: 'delete error', + createError: 'create error' + } + }); - controller.bindCustomListenersOnThread(thread); - thread.emit('threadevent', {}); + it('should unregister thread on threadCleanup', () => { + controller.handleThreadEvents(stubs.thread, { event: THREAD_EVENT.threadCleanup, data: {} }); + expect(controller.unregisterThread).to.be.called; }); - }); - describe('unbindCustomListenersOnThread()', () => { - it('should do nothing when the input is empty', () => { - const thread = { - removeAllListeners: sandbox.stub() - }; + it('should unregister thread on threadDelete', () => { + controller.handleThreadEvents(stubs.thread, { event: THREAD_EVENT.threadDelete, data: {} }); + expect(controller.unregisterThread).to.be.called; + expect(controller.emit).to.be.calledWith(THREAD_EVENT.threadDelete, sinon.match.object); + }); - controller.unbindCustomListenersOnThread(undefined); - expect(thread.removeAllListeners).to.not.be.called; + it('should unregister thread on deleteError', () => { + controller.handleThreadEvents(stubs.thread, { event: THREAD_EVENT.deleteError, data: {} }); + expect(controller.emit).to.be.calledWith(ANNOTATOR_EVENT.error, controller.localized.deleteError); + expect(controller.emit).to.be.calledWith(THREAD_EVENT.deleteError, sinon.match.object); }); - it('should bind custom listeners on thread', () => { - const thread = { - removeAllListeners: sandbox.stub() - }; + it('should unregister thread on createError', () => { + controller.handleThreadEvents(stubs.thread, { event: THREAD_EVENT.createError, data: {} }); + expect(controller.emit).to.be.calledWith(ANNOTATOR_EVENT.error, controller.localized.createError); + expect(controller.emit).to.be.calledWith(THREAD_EVENT.createError, sinon.match.object); + }); - controller.unbindCustomListenersOnThread(thread); - expect(thread.removeAllListeners).to.be.calledWith('threadevent'); + it('should emit the event on default', () => { + controller.handleThreadEvents(stubs.thread, { event: 'random', data: {} }); + expect(controller.emit).to.be.calledWith('random', sinon.match.object); }); }); @@ -201,4 +374,79 @@ describe('controllers/AnnotationModeController', () => { expect(stubs.insertTemplate).to.be.calledWith(container, header); }); }); + + describe('destroyPendingThreads()', () => { + beforeEach(() => { + stubs.isPending = sandbox.stub(util, 'isPending').returns(false); + stubs.isPending.withArgs(STATES.pending).returns(true); + + controller.registerThread(stubs.thread); + }); + + it('should destroy and return true if there are any pending threads', () => { + stubs.threadMock.expects('destroy'); + const destroyed = controller.destroyPendingThreads(); + expect(destroyed).to.equal(true); + }); + + it('should not destroy and return false if there are no threads', () => { + controller.threads = {}; + stubs.threadMock.expects('destroy').never(); + stubs.isPending.returns(false); + const destroyed = controller.destroyPendingThreads(); + expect(destroyed).to.equal(false); + }); + + it('should not destroy and return false if the threads are not pending', () => { + stubs.thread.state = 'NOT_PENDING'; + stubs.threadMock.expects('destroy').never(); + const destroyed = controller.destroyPendingThreads(); + expect(destroyed).to.equal(false); + }); + + it('should destroy only pending threads, and return true', () => { + stubs.thread.state = 'NOT_PENDING'; + const pendingThread = { + threadID: '456def', + location: { page: 1 }, + type: 'type', + state: STATES.pending, + destroy: () => {}, + unbindCustomListenersOnThread: () => {}, + addListener: () => {}, + removeAllListeners: () => {} + }; + stubs.pendingMock = sandbox.mock(pendingThread); + controller.registerThread(pendingThread); + + stubs.threadMock.expects('destroy').never(); + stubs.pendingMock.expects('destroy'); + const destroyed = controller.destroyPendingThreads(); + + expect(destroyed).to.equal(true); + }); + }); + + describe('emit()', () => { + const emitFunc = EventEmitter.prototype.emit; + + afterEach(() => { + Object.defineProperty(EventEmitter.prototype, 'emit', { value: emitFunc }); + }); + + it('should pass through the event as well as broadcast it as a controller event', () => { + const mode = 'this mode'; + const event = 'event'; + const data = {}; + controller.mode = mode; + + const emitStub = sandbox.stub(); + Object.defineProperty(EventEmitter.prototype, 'emit', { value: emitStub }); + + controller.emit(event, data); + + expect(emitStub).to.be.calledWith(event, data); + expect(emitStub).to.be.calledWithMatch('annotationcontrollerevent', { event, data, mode }); + }); + }); }); diff --git a/src/controllers/__tests__/DrawingModeController-test.js b/src/controllers/__tests__/DrawingModeController-test.js index 188e00776..34750a781 100644 --- a/src/controllers/__tests__/DrawingModeController-test.js +++ b/src/controllers/__tests__/DrawingModeController-test.js @@ -2,16 +2,25 @@ import rbush from 'rbush'; import AnnotationModeController from '../AnnotationModeController'; import DrawingModeController from '../DrawingModeController'; import * as annotatorUtil from '../../annotatorUtil'; -import { CLASS_ANNOTATION_DRAW} from '../../annotationConstants'; +import { + THREAD_EVENT, + SELECTOR_ANNOTATION_BUTTON_DRAW_CANCEL, + SELECTOR_ANNOTATION_BUTTON_DRAW_POST, + SELECTOR_ANNOTATION_BUTTON_DRAW_UNDO, + SELECTOR_ANNOTATION_BUTTON_DRAW_REDO, + CLASS_ANNNOTATION_DRAWING_BACKGROUND, + CLASS_ACTIVE, + CLASS_ANNOTATION_MODE, + CONTROLLER_EVENT +} from '../../annotationConstants'; let controller; -let stubs; +let stubs = {}; const sandbox = sinon.sandbox.create(); describe('controllers/DrawingModeController', () => { beforeEach(() => { controller = new DrawingModeController(); - stubs = {}; stubs.thread = { minX: 10, minY: 10, @@ -22,110 +31,219 @@ describe('controllers/DrawingModeController', () => { }, info: 'I am a thread', addListener: sandbox.stub(), + removeListener: sandbox.stub(), saveAnnotation: sandbox.stub(), handleStart: sandbox.stub(), destroy: sandbox.stub(), deleteThread: sandbox.stub(), show: sandbox.stub() }; + + sandbox.stub(controller, 'emit'); }); afterEach(() => { sandbox.verifyAndRestore(); - stubs = null; + stubs = {}; controller = null; }); - describe('registerAnnotator()', () => { - const annotator = { - getAnnotateButton: sandbox.stub(), - options: { - header: 'none' - }, - annotatedElement: { - classList: { - add: sandbox.stub() - } - } - }; - it('should use the annotator to get button elements', () => { - annotator.getAnnotateButton.onCall(0).returns('cancelButton'); - annotator.getAnnotateButton.onCall(1).returns('postButton'); - annotator.getAnnotateButton.onCall(2).returns('undoButton'); - annotator.getAnnotateButton.onCall(3).returns('redoButton'); + describe('init()', () => { + beforeEach(() => { + Object.defineProperty(AnnotationModeController.prototype, 'init', { value: sandbox.stub() }); + sandbox.stub(controller, 'getButton'); + sandbox.stub(controller, 'setupHeader'); + }); - expect(controller.postButtonEl).to.be.undefined; - expect(controller.undoButtonEl).to.be.undefined; - expect(controller.redoButtonEl).to.be.undefined; + it('should get all the mode buttons and initialize the controller', () => { + controller.init({ options: { header: 'none' } }); + expect(controller.setupHeader).to.not.be.called; + expect(controller.getButton).to.be.calledWith(SELECTOR_ANNOTATION_BUTTON_DRAW_CANCEL); + expect(controller.getButton).to.be.calledWith(SELECTOR_ANNOTATION_BUTTON_DRAW_POST); + expect(controller.getButton).to.be.calledWith(SELECTOR_ANNOTATION_BUTTON_DRAW_UNDO); + expect(controller.getButton).to.be.calledWith(SELECTOR_ANNOTATION_BUTTON_DRAW_REDO); + }); - controller.registerAnnotator(annotator); - annotator.getAnnotateButton.onCall(0).returns('cancelButton'); - expect(controller.postButtonEl).to.equal('postButton'); - expect(controller.redoButtonEl).to.equal('redoButton'); - expect(controller.undoButtonEl).to.equal('undoButton'); + it('should replace the draw annotations header if using the preview header', () => { + controller.init({ options: { header: 'light' } }); + expect(controller.setupHeader).to.be.called; }); + }); - it('should setup the drawing header if the options allow', () => { - const setupHeaderStub = sandbox.stub(controller, 'setupHeader'); + describe('exit()', () => { + it('should exit draw annotation mode', () => { + sandbox.stub(controller, 'unbindListeners'); - controller.registerAnnotator(annotator); - expect(setupHeaderStub).to.not.be.called; + // Set up draw annotation mode + controller.annotatedElement = document.createElement('div'); + controller.annotatedElement.classList.add(CLASS_ANNOTATION_MODE); + controller.annotatedElement.classList.add(CLASS_ANNNOTATION_DRAWING_BACKGROUND); - annotator.options.header = 'dark'; + controller.buttonEl = document.createElement('button'); + controller.buttonEl.classList.add(CLASS_ACTIVE); - controller.registerAnnotator(annotator); - expect(setupHeaderStub).to.be.called; + controller.exit(); + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.exit, sinon.match.object); + expect(controller.unbindListeners).to.be.called; + expect(controller.emit).to.be.calledWith('binddomlisteners'); }); + }); + + describe('enter()', () => { + it('should exit draw annotation mode', () => { + sandbox.stub(controller, 'bindListeners'); - it('should add the draw class to the annotated element', () => { - annotator.options.header = 'none'; - controller.registerAnnotator(annotator); - expect(annotator.annotatedElement.classList.add).to.be.calledWith(CLASS_ANNOTATION_DRAW); + // Set up draw annotation mode + controller.annotatedElement = document.createElement('div'); + controller.buttonEl = document.createElement('button'); + + controller.enter(); + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.enter, sinon.match.object); + expect(controller.bindListeners).to.be.called; + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.unbindDOMListeners); }); }); describe('registerThread()', () => { - it('should internally keep track of the registered thread', () => { + beforeEach(() => { controller.threads = { 1: new rbush() }; + }); + + it('should do nothing if thread does not exist', () => { + stubs.thread = undefined; + controller.registerThread(stubs.thread); + expect(controller.emit).to.not.be.calledWith(CONTROLLER_EVENT.register, sinon.match.object); + }); + + it('should do nothing if thread location does not exist', () => { + stubs.thread.location = undefined; + controller.registerThread(stubs.thread); + expect(controller.emit).to.not.be.calledWith(CONTROLLER_EVENT.register, sinon.match.object); + }); + + it('should create new rbush for page if it does not already exist', () => { + stubs.thread.location.page = 2; + + controller.registerThread(stubs.thread); + + const thread = controller.threads[2].search(stubs.thread); + expect(thread.includes(stubs.thread)).to.be.truthy; + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.register, sinon.match.object); + }); + + it('should internally keep track of the registered thread', () => { const pageThreads = controller.threads[1]; expect(pageThreads.search(stubs.thread)).to.deep.equal([]); controller.registerThread(stubs.thread); const thread = pageThreads.search(stubs.thread); expect(thread.includes(stubs.thread)).to.be.truthy; + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.register, sinon.match.object); }); }); describe('unregisterThread()', () => { - it('should internally keep track of the registered thread', () => { + beforeEach(() => { controller.threads = { 1: new rbush() }; - const pageThreads = controller.threads[1]; - controller.registerThread(stubs.thread); - expect(pageThreads.search(stubs.thread).includes(stubs.thread)).to.be.truthy; + }); + it('should do nothing if thread does not exist', () => { + stubs.thread = undefined; controller.unregisterThread(stubs.thread); - expect(pageThreads.search(stubs.thread)).to.deep.equal([]); + expect(controller.emit).to.not.be.calledWith(CONTROLLER_EVENT.unregister, sinon.match.object); + }); + + it('should do nothing if thread location does not exist', () => { + stubs.thread.location = undefined; + controller.unregisterThread(stubs.thread); + expect(controller.emit).to.not.be.calledWith(CONTROLLER_EVENT.unregister, sinon.match.object); + }); + + it('should internally keep track of the registered thread', () => { + const pageThreads = controller.threads[1]; + controller.unregisterThread(stubs.thread); + const thread = pageThreads.search(stubs.thread); + expect(thread.includes(stubs.thread)).to.be.falsy; + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.unregister, sinon.match.object); }); }); + describe('bindDOMListeners()', () => { + beforeEach(() => { + controller.annotatedElement = { + addEventListener: sandbox.stub() + }; + stubs.add = controller.annotatedElement.addEventListener; + }); + + it('should unbind the mobileDOM listeners', () => { + controller.isTouchCompatible = true; + controller.bindDOMListeners(); + expect(stubs.add).to.be.calledWith('touchstart', sinon.match.func); + }); + + it('should unbind the DOM listeners', () => { + controller.isTouchCompatible = false; + controller.bindDOMListeners(); + expect(stubs.add).to.be.calledWith('click', sinon.match.func); + }); + }); - describe('bindCustomListenersOnThread()', () => { + describe('unbindDOMListeners()', () => { beforeEach(() => { - Object.defineProperty(AnnotationModeController.prototype, 'bindCustomListenersOnThread', { value: sandbox.stub() }) - stubs.super = AnnotationModeController.prototype.bindCustomListenersOnThread; + controller.annotatedElement = { + removeEventListener: sandbox.stub() + }; + stubs.remove = controller.annotatedElement.removeEventListener; + }); + + it('should unbind the mobileDOM listeners', () => { + controller.isTouchCompatible = true; + controller.unbindDOMListeners(); + expect(stubs.remove).to.be.calledWith('touchstart', sinon.match.func); + }); + + it('should unbind the DOM listeners', () => { + controller.isTouchCompatible = false; + controller.unbindDOMListeners(); + expect(stubs.remove).to.be.calledWith('click', sinon.match.func); }); + }); + + describe('bindListeners()', () => { + it('should disable undo and redo buttons', () => { + sandbox.stub(controller, 'unbindDOMListeners'); + const handlerObj = { + type: 'event', + func: () => {}, + eventObj: { + addEventListener: sandbox.stub() + } + }; + sandbox.stub(controller, 'setupHandlers', () => { + controller.handlers = [handlerObj]; + }); + expect(controller.handlers.length).to.equal(0); - it('should do nothing when the input is empty', () => { - controller.bindCustomListenersOnThread(undefined); - expect(stubs.super).to.not.be.called; + controller.bindListeners(); + expect(controller.unbindDOMListeners).to.be.called; }); + }); + + describe('unbindListeners()', () => { + it('should disable undo and redo buttons', () => { + sandbox.stub(annotatorUtil, 'disableElement'); + sandbox.stub(controller, 'bindDOMListeners'); + + controller.annotatedElement = document.createElement('div'); + controller.undoButtonEl = 'test1'; + controller.redoButtonEl = 'test2'; - it('should bind custom listeners on thread', () => { - controller.bindCustomListenersOnThread(stubs.thread); - expect(stubs.super).to.be.called; - expect(stubs.thread.addListener).to.be.calledWith('annotationsaved'); - expect(stubs.thread.addListener).to.be.calledWith('annotationdelete'); + controller.unbindListeners(); + expect(annotatorUtil.disableElement).to.be.calledWith(controller.undoButtonEl); + expect(annotatorUtil.disableElement).to.be.calledWith(controller.redoButtonEl); + expect(controller.bindDOMListeners); }); }); @@ -133,23 +251,11 @@ describe('controllers/DrawingModeController', () => { beforeEach(() => { controller.annotator = { getThreadParams: sandbox.stub(), - getLocationFromEvent: sandbox.stub(), - annotatedElement: {} + getLocationFromEvent: sandbox.stub() }; + controller.annotatedElement = {}; stubs.getParams = controller.annotator.getThreadParams.returns({}); stubs.getLocation = controller.annotator.getLocationFromEvent; - stubs.bindCustomListenersOnThread = sandbox.stub(controller, 'bindCustomListenersOnThread'); - }); - - it('should successfully contain draw mode handlers if undo and redo buttons do not exist', () => { - controller.postButtonEl = 'not undefined'; - controller.undoButtonEl = undefined; - controller.redoButtonEl = undefined; - - controller.setupHandlers(); - expect(stubs.getParams).to.be.called; - expect(stubs.bindCustomListenersOnThread).to.be.called; - expect(controller.handlers.length).to.equal(4); }); it('should successfully contain draw mode handlers if undo and redo buttons exist', () => { @@ -161,43 +267,31 @@ describe('controllers/DrawingModeController', () => { controller.setupHandlers(); expect(stubs.getParams).to.be.called; - expect(stubs.bindCustomListenersOnThread).to.be.called; expect(controller.handlers.length).to.equal(7); }); }); - describe('unbindModeListeners()', () => { - it('should disable undo and redo buttons', () => { - sandbox.stub(annotatorUtil, 'disableElement'); - - controller.undoButtonEl = 'test1'; - controller.redoButtonEl = 'test2'; - controller.unbindModeListeners(); - expect(annotatorUtil.disableElement).to.be.calledWith(controller.undoButtonEl); - expect(annotatorUtil.disableElement).to.be.calledWith(controller.redoButtonEl); + describe('handleThreadEvents()', () => { + beforeEach(() => { + stubs.thread.dialog = {}; }); - }); - describe('handleAnnotationEvent()', () => { it('should add thread to map on locationassigned', () => { - controller.annotator = { - addThreadToMap: sandbox.stub() - }; - - controller.handleAnnotationEvent(stubs.thread, { + sandbox.stub(controller, 'registerThread'); + controller.handleThreadEvents(stubs.thread, { event: 'locationassigned' }); - expect(controller.annotator.addThreadToMap).to.be.called; + expect(controller.registerThread).to.be.called; }); it('should restart mode listeners from the thread on softcommit', () => { - sandbox.stub(controller, 'unbindModeListeners'); - sandbox.stub(controller, 'bindModeListeners'); - controller.handleAnnotationEvent(stubs.thread, { + sandbox.stub(controller, 'unbindListeners'); + sandbox.stub(controller, 'bindListeners'); + controller.handleThreadEvents(stubs.thread, { event: 'softcommit' }); - expect(controller.unbindModeListeners).to.be.called; - expect(controller.bindModeListeners).to.be.called; + expect(controller.unbindListeners).to.be.called; + expect(controller.bindListeners).to.be.called; expect(stubs.thread.saveAnnotation).to.be.called; expect(stubs.thread.handleStart).to.not.be.called; }); @@ -231,22 +325,22 @@ describe('controllers/DrawingModeController', () => { location: 'not empty' } }; - sandbox.stub(controller, 'unbindModeListeners'); - sandbox.stub(controller, 'bindModeListeners', () => { + sandbox.stub(controller, 'unbindListeners'); + sandbox.stub(controller, 'bindListeners', () => { controller.currentThread = thread2; }); - controller.handleAnnotationEvent(thread1, data); + controller.handleThreadEvents(thread1, data); expect(thread1.saveAnnotation).to.be.called; - expect(controller.unbindModeListeners).to.be.called; - expect(controller.bindModeListeners).to.be.called; + expect(controller.unbindListeners).to.be.called; + expect(controller.bindListeners).to.be.called; expect(thread2.handleStart).to.be.calledWith(data.eventData.location); }); it('should update undo and redo buttons on availableactions', () => { sandbox.stub(controller, 'updateUndoRedoButtonEls'); - controller.handleAnnotationEvent(stubs.thread, { + controller.handleThreadEvents(stubs.thread, { event: 'availableactions', eventData: { undo: 1, @@ -259,14 +353,14 @@ describe('controllers/DrawingModeController', () => { it('should soft delete a pending thread and restart mode listeners', () => { stubs.thread.state = 'pending'; - sandbox.stub(controller, 'unbindModeListeners'); - sandbox.stub(controller, 'bindModeListeners'); - controller.handleAnnotationEvent(stubs.thread, { + sandbox.stub(controller, 'unbindListeners'); + sandbox.stub(controller, 'bindListeners'); + controller.handleThreadEvents(stubs.thread, { event: 'dialogdelete' }); expect(stubs.thread.destroy).to.be.called; - expect(controller.unbindModeListeners).to.be.called; - expect(controller.bindModeListeners).to.be.called; + expect(controller.unbindListeners).to.be.called; + expect(controller.bindListeners).to.be.called; }); it('should delete a non-pending thread', () => { @@ -275,12 +369,24 @@ describe('controllers/DrawingModeController', () => { controller.registerThread(stubs.thread); const unregisterThreadStub = sandbox.stub(controller, 'unregisterThread'); - controller.handleAnnotationEvent(stubs.thread, { + controller.handleThreadEvents(stubs.thread, { event: 'dialogdelete' }); expect(stubs.thread.deleteThread).to.be.called; expect(unregisterThreadStub).to.be.called; }); + + it('should not delete a thread if the dialog no longer exists', () => { + stubs.thread.dialog = null; + controller.threads[1] = new rbush(); + controller.registerThread(stubs.thread); + const unregisterThreadStub = sandbox.stub(controller, 'unregisterThread'); + + controller.handleThreadEvents(stubs.thread, { + event: 'dialogdelete' + }); + expect(unregisterThreadStub).to.not.be.called; + }); }); describe('handleSelection()', () => { @@ -298,6 +404,13 @@ describe('controllers/DrawingModeController', () => { expect(stubs.getLoc).to.not.be.called; }) + it('should do nothing if no location exists', () => { + stubs.clean = sandbox.stub(controller, 'removeSelection'); + stubs.getLoc.returns(null); + controller.handleSelection('event'); + expect(stubs.clean).to.not.be.called; + }); + it('should call select on an thread found in the data store', () => { stubs.select = sandbox.stub(controller, 'select'); stubs.clean = sandbox.stub(controller, 'removeSelection'); diff --git a/src/controllers/__tests__/HighlightModeController-test.js b/src/controllers/__tests__/HighlightModeController-test.js new file mode 100644 index 000000000..dc9462c18 --- /dev/null +++ b/src/controllers/__tests__/HighlightModeController-test.js @@ -0,0 +1,70 @@ +import EventEmitter from 'events'; +import HighlightModeController from '../HighlightModeController'; +import * as util from '../../annotatorUtil'; +import { + CLASS_HIDDEN, + CLASS_ACTIVE, + CLASS_ANNOTATION_MODE, + THREAD_EVENT, + STATES, + CONTROLLER_EVENT +} from '../../annotationConstants'; + +let controller; +let stubs = {}; +const sandbox = sinon.sandbox.create(); + +describe('controllers/HighlightModeController', () => { + beforeEach(() => { + controller = new HighlightModeController(); + sandbox.stub(controller, 'emit'); + stubs.thread = { location: { page: 1 } }; + }); + + afterEach(() => { + sandbox.verifyAndRestore(); + stubs = {}; + controller = null; + }); + + describe('handleThreadEvents()', () => { + it('should unregister thread on threadCleanup', () => { + sandbox.stub(controller, 'unregisterThread'); + controller.handleThreadEvents(stubs.thread, { event: THREAD_EVENT.threadCleanup, data: {} }); + expect(controller.unregisterThread).to.be.called; + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.showHighlights, 1); + }); + }); + + describe('exit()', () => { + it('should exit annotation mode', () => { + sandbox.stub(controller, 'destroyPendingThreads'); + sandbox.stub(controller, 'unbindListeners'); + + const selection = window.getSelection(); + sandbox.stub(selection, 'removeAllRanges'); + + controller.annotatedElement = document.createElement('div'); + controller.annotatedElement.classList.add(CLASS_ANNOTATION_MODE); + + controller.exit(); + expect(controller.destroyPendingThreads).to.be.called; + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.bindDOMListeners); + expect(controller.unbindListeners).to.be.called; + expect(selection.removeAllRanges); + }); + }); + + describe('enter()', () => { + it('should enter annotation mode', () => { + sandbox.stub(controller, 'bindListeners'); + + controller.annotatedElement = document.createElement('div'); + controller.annotatedElement.classList.add(CLASS_ANNOTATION_MODE); + + controller.enter(); + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.unbindDOMListeners); + expect(controller.bindListeners).to.be.called; + }); + }); +}); diff --git a/src/controllers/__tests__/PointModeController-test.js b/src/controllers/__tests__/PointModeController-test.js new file mode 100644 index 000000000..af5f813c0 --- /dev/null +++ b/src/controllers/__tests__/PointModeController-test.js @@ -0,0 +1,117 @@ +import EventEmitter from 'events'; +import PointModeController from '../PointModeController'; +import * as util from '../../annotatorUtil'; +import { + CLASS_HIDDEN, + CLASS_ACTIVE, + CLASS_ANNOTATION_MODE, + ANNOTATOR_EVENT, + THREAD_EVENT, + STATES, + CONTROLLER_EVENT +} from '../../annotationConstants'; + +let controller; +let stubs = {}; +const sandbox = sinon.sandbox.create(); + +describe('controllers/PointModeController', () => { + beforeEach(() => { + controller = new PointModeController(); + stubs.thread = { + show: () => {}, + getThreadEventData: () => {} + }; + stubs.threadMock = sandbox.mock(stubs.thread); + + sandbox.stub(controller, 'emit'); + controller.annotatedElement = {}; + controller.annotator = { + getLocationFromEvent: () => {}, + createAnnotationThread: () => {} + }; + stubs.annotatorMock = sandbox.mock(controller.annotator); + }); + + afterEach(() => { + sandbox.verifyAndRestore(); + stubs = {}; + controller = null; + }); + + describe('setupHandlers()', () => { + it('should successfully contain mode handlers', () => { + controller.postButtonEl = 'not undefined'; + controller.cancelButtonEl = 'definitely not undefined'; + + controller.setupHandlers(); + expect(controller.handlers.length).to.equal(3); + }); + }); + + describe('pointClickHandler()', () => { + const event = { + stopPropagation: () => {}, + preventDefault: () => {} + }; + + beforeEach(() => { + stubs.destroy = sandbox.stub(controller, 'destroyPendingThreads'); + sandbox.stub(controller, 'registerThread'); + controller.modeButton = { + title: 'Point Annotation Mode', + selector: '.bp-btn-annotate' + }; + }); + + afterEach(() => { + controller.modeButton = {}; + controller.container = document; + }); + + it('should not do anything if there are pending threads', () => { + stubs.destroy.returns(true); + stubs.annotatorMock.expects('createAnnotationThread').never(); + stubs.annotatorMock.expects('getLocationFromEvent').never(); + stubs.threadMock.expects('show').never(); + + controller.pointClickHandler(event); + expect(controller.registerThread).to.not.be.called; + expect(controller.emit).to.not.be.calledWith(CONTROLLER_EVENT.toggleMode); + }); + + it('should not do anything if thread is invalid', () => { + stubs.destroy.returns(false); + stubs.annotatorMock.expects('getLocationFromEvent'); + stubs.threadMock.expects('show').never(); + + controller.pointClickHandler(event); + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.toggleMode); + expect(controller.registerThread).to.not.be.called; + }); + + it('should not create a thread if a location object cannot be inferred from the event', () => { + stubs.destroy.returns(false); + stubs.annotatorMock.expects('getLocationFromEvent').returns(null); + stubs.annotatorMock.expects('createAnnotationThread').never(); + stubs.threadMock.expects('show').never(); + + controller.pointClickHandler(event); + expect(controller.registerThread).to.not.be.called; + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.toggleMode); + }); + + it('should create, show, and bind listeners to a thread', () => { + stubs.destroy.returns(false); + stubs.annotatorMock.expects('getLocationFromEvent').returns({}); + stubs.annotatorMock.expects('createAnnotationThread').returns(stubs.thread); + stubs.threadMock.expects('getThreadEventData').returns('data'); + stubs.threadMock.expects('show'); + + controller.pointClickHandler(event); + expect(controller.registerThread).to.be.called; + expect(controller.emit).to.be.calledWith(CONTROLLER_EVENT.toggleMode); + expect(controller.emit).to.be.calledWith(THREAD_EVENT.pending, 'data'); + }); + }); +}); diff --git a/src/doc/DocAnnotator.js b/src/doc/DocAnnotator.js index 3d4072d4e..00b1baad9 100644 --- a/src/doc/DocAnnotator.js +++ b/src/doc/DocAnnotator.js @@ -23,7 +23,8 @@ import { CLASS_ANNOTATION_LAYER_HIGHLIGHT, CLASS_ANNOTATION_LAYER_DRAW, THREAD_EVENT, - ANNOTATOR_EVENT + ANNOTATOR_EVENT, + CONTROLLER_EVENT } from '../annotationConstants'; const MOUSEMOVE_THROTTLE_MS = 50; @@ -85,58 +86,6 @@ class DocAnnotator extends Annotator { /** @property {Function} - Reference to filter function that has been bound TODO(@jholdstock): remove on refactor. */ showFirstDialogFilter; - /** - * Creates and mananges plain highlight and comment highlight and point annotations - * on document files. - * - * [constructor] - * - * @inheritdoc - * @return {DocAnnotator} DocAnnotator instance - */ - constructor(data) { - super(data); - - this.plainHighlightEnabled = this.isModeAnnotatable(TYPES.highlight); - this.commentHighlightEnabled = this.isModeAnnotatable(TYPES.highlight_comment); - this.drawEnabled = this.isModeAnnotatable(TYPES.draw); - - // Don't bind to draw specific handlers if we cannot draw - if (this.drawEnabled) { - this.drawingSelectionHandler = this.drawingSelectionHandler.bind(this); - } - - // Don't bind to highlight specific handlers if we cannot highlight - if (!this.plainHighlightEnabled && !this.commentHighlightEnabled) { - return; - } - - // Explicit scoping - this.highlightCreateHandler = this.highlightCreateHandler.bind(this); - this.showFirstDialogFilter = this.showFirstDialogFilter.bind(this); - - this.createHighlightDialog = new CreateHighlightDialog(this.container, { - isMobile: this.isMobile, - hasTouch: this.hasTouch, - allowComment: this.commentHighlightEnabled, - allowHighlight: this.plainHighlightEnabled, - localized: this.localized - }); - - if (this.commentHighlightEnabled) { - this.highlightCurrentSelection = this.highlightCurrentSelection.bind(this); - this.createHighlightDialog.addListener(CreateEvents.comment, this.highlightCurrentSelection); - - this.createHighlightThread = this.createHighlightThread.bind(this); - this.createHighlightDialog.addListener(CreateEvents.commentPost, this.createHighlightThread); - } - - if (this.plainHighlightEnabled) { - this.createPlainHighlight = this.createPlainHighlight.bind(this); - this.createHighlightDialog.addListener(CreateEvents.plain, this.createPlainHighlight); - } - } - /** * [destructor] * @@ -338,8 +287,6 @@ class DocAnnotator extends Annotator { if (!thread) { this.emit(ANNOTATOR_EVENT.error, this.localized.loadError); - } else if (thread && (type !== TYPES.draw || location.page)) { - this.addThreadToMap(thread); } return thread; @@ -363,10 +310,11 @@ class DocAnnotator extends Annotator { } // TODO (@jholdstock|@spramod) remove this if statement, and make super call, upon refactor. - const pageThreads = this.getThreadsOnPage(pageNum); + const pageThreads = this.threads[pageNum] || {}; Object.keys(pageThreads).forEach((threadID) => { const thread = pageThreads[threadID]; - if (!this.isModeAnnotatable(thread.type)) { + const controller = this.modeControllers[thread.type]; + if (!controller) { return; } @@ -403,29 +351,6 @@ class DocAnnotator extends Annotator { }); } - /** - * Toggles annotation modes on and off. When an annotation mode is - * on, annotation threads will be created at that location. - * - * @param {string} mode - Current annotation mode - * @param {HTMLEvent} event - DOM event - * @return {void} - */ - toggleAnnotationHandler(mode, event = {}) { - if (!this.isModeAnnotatable(mode)) { - return; - } - - this.destroyPendingThreads(); - - if (this.createHighlightDialog && this.createHighlightDialog.isVisible) { - document.getSelection().removeAllRanges(); - this.createHighlightDialog.hide(); - } - - super.toggleAnnotationHandler(mode, event); - } - //-------------------------------------------------------------------------- // Protected //-------------------------------------------------------------------------- @@ -440,10 +365,45 @@ class DocAnnotator extends Annotator { setupAnnotations() { super.setupAnnotations(); + this.plainHighlightEnabled = !!this.modeControllers[TYPES.highlight]; + this.commentHighlightEnabled = !!this.modeControllers[TYPES.highlight_comment]; + this.drawEnabled = !!this.modeControllers[TYPES.draw]; + + // Don't bind to draw specific handlers if we cannot draw + if (this.drawEnabled) { + this.drawingSelectionHandler = this.drawingSelectionHandler.bind(this); + } + + // Don't bind to highlight specific handlers if we cannot highlight if (!this.plainHighlightEnabled && !this.commentHighlightEnabled) { return; } + // Explicit scoping + this.highlightCreateHandler = this.highlightCreateHandler.bind(this); + this.showFirstDialogFilter = this.showFirstDialogFilter.bind(this); + + this.createHighlightDialog = new CreateHighlightDialog(this.container, { + isMobile: this.isMobile, + hasTouch: this.hasTouch, + allowComment: this.commentHighlightEnabled, + allowHighlight: this.plainHighlightEnabled, + localized: this.localized + }); + + if (this.commentHighlightEnabled) { + this.highlightCurrentSelection = this.highlightCurrentSelection.bind(this); + this.createHighlightDialog.addListener(CreateEvents.comment, this.highlightCurrentSelection); + + this.createHighlightThread = this.createHighlightThread.bind(this); + this.createHighlightDialog.addListener(CreateEvents.commentPost, this.createHighlightThread); + } + + if (this.plainHighlightEnabled) { + this.createPlainHighlight = this.createPlainHighlight.bind(this); + this.createHighlightDialog.addListener(CreateEvents.plain, this.createPlainHighlight); + } + // Init rangy and rangy highlight this.highlighter = rangy.createHighlighter(); this.highlighter.addClassApplier( @@ -591,7 +551,10 @@ class DocAnnotator extends Annotator { thread.show(this.plainHighlightEnabled, this.commentHighlightEnabled); thread.dialog.postAnnotation(commentText); - this.bindCustomListenersOnThread(thread); + const controller = this.modeControllers[highlightType]; + if (controller) { + controller.registerThread(thread); + } this.emit(THREAD_EVENT.threadSave, thread.getThreadEventData()); return thread; @@ -633,7 +596,7 @@ class DocAnnotator extends Annotator { // Set all annotations that are in the 'hover' state to 'inactive' Object.keys(this.threads).forEach((page) => { - const pageThreads = this.getThreadsOnPage(page); + const pageThreads = this.threads[page] || {}; const highlightThreads = this.getHighlightThreadsOnPage(pageThreads); highlightThreads.filter(isThreadInHoverState).forEach((thread) => { thread.reset(); @@ -678,7 +641,7 @@ class DocAnnotator extends Annotator { this.mouseY = event.clientY; Object.keys(this.threads).forEach((page) => { - const pageThreads = this.getThreadsOnPage(page); + const pageThreads = this.threads[page] || {}; const highlightThreads = this.getHighlightThreadsOnPage(pageThreads); highlightThreads.forEach((thread) => { thread.onMousedown(); @@ -750,7 +713,7 @@ class DocAnnotator extends Annotator { const delayThreads = []; let hoverActive = false; - const pageThreads = this.getThreadsOnPage(page); + const pageThreads = this.threads[page] || {}; Object.keys(pageThreads).forEach((threadID) => { const thread = pageThreads[threadID]; @@ -920,7 +883,7 @@ class DocAnnotator extends Annotator { let activeThread = null; const page = annotatorUtil.getPageInfo(event.target).page; - const pageThreads = this.getThreadsOnPage(page); + const pageThreads = this.threads[page] || {}; Object.keys(pageThreads).forEach((threadID) => { const thread = pageThreads[threadID]; @@ -981,7 +944,7 @@ class DocAnnotator extends Annotator { */ getHighlightThreadsOnPage(page) { const threads = []; - const pageThreads = this.getThreadsOnPage(page); + const pageThreads = this.threads[page] || {}; Object.keys(pageThreads).forEach((threadID) => { const thread = pageThreads[threadID]; @@ -1039,32 +1002,35 @@ class DocAnnotator extends Annotator { } /** - * Handles annotation thread events and emits them to the viewer + * Handle events emitted by the annotaiton service * * @private - * @param {Object} [data] - Annotation thread event data - * @param {string} [data.event] - Annotation thread event - * @param {string} [data.data] - Annotation thread event data + * @param {Object} [data] - Annotation service event data + * @param {string} [data.event] - Annotation service event + * @param {string} [data.data] - * @return {void} */ - handleAnnotationThreadEvents(data) { - if (!data.data || !data.data.threadID) { - return; - } - - const thread = this.getThreadByID(data.data.threadID); - if (!thread) { - return; - } - - super.handleAnnotationThreadEvents(data); + handleControllerEvents(data) { + const isCreateDialogVisible = this.createHighlightDialog && this.createHighlightDialog.isVisible; switch (data.event) { - case THREAD_EVENT.threadDelete: - this.showHighlightsOnPage(thread.location.page); + case CONTROLLER_EVENT.toggleMode: + if (isCreateDialogVisible) { + document.getSelection().removeAllRanges(); + this.createHighlightDialog.hide(); + } + break; + case CONTROLLER_EVENT.showHighlights: + this.showHighlightsOnPage(data.data); + break; + case CONTROLLER_EVENT.bindDOMListeners: + if (isCreateDialogVisible) { + this.createHighlightDialog.hide(); + } break; default: } + super.handleControllerEvents(data); } /** diff --git a/src/doc/DocHighlightThread.js b/src/doc/DocHighlightThread.js index 4960cf3b5..e6e78eed1 100644 --- a/src/doc/DocHighlightThread.js +++ b/src/doc/DocHighlightThread.js @@ -4,6 +4,7 @@ import DocHighlightDialog from './DocHighlightDialog'; import * as annotatorUtil from '../annotatorUtil'; import * as docAnnotatorUtil from './docAnnotatorUtil'; import { + THREAD_EVENT, STATES, TYPES, SELECTOR_ADD_HIGHLIGHT_BTN, @@ -64,6 +65,7 @@ class DocHighlightThread extends AnnotationThread { if (this.state === STATES.pending) { window.getSelection().removeAllRanges(); } + this.emit(THREAD_EVENT.threadCleanup); } /** diff --git a/src/doc/__tests__/DocAnnotator-test.js b/src/doc/__tests__/DocAnnotator-test.js index 095210307..fd17d3f71 100644 --- a/src/doc/__tests__/DocAnnotator-test.js +++ b/src/doc/__tests__/DocAnnotator-test.js @@ -3,6 +3,7 @@ import rangy from 'rangy'; import Annotator from '../../Annotator'; import Annotation from '../../Annotation'; import AnnotationThread from '../../AnnotationThread'; +import HighlightModeController from '../../controllers/HighlightModeController'; import DocAnnotator from '../DocAnnotator'; import DocHighlightThread from '../DocHighlightThread'; import DocDrawingThread from '../DocDrawingThread'; @@ -16,7 +17,8 @@ import { TYPES, CLASS_ANNOTATION_LAYER_HIGHLIGHT, DATA_TYPE_ANNOTATION_DIALOG, - THREAD_EVENT + THREAD_EVENT, + CONTROLLER_EVENT } from '../../annotationConstants'; let annotator; @@ -33,10 +35,14 @@ describe('doc/DocAnnotator', () => { beforeEach(() => { fixture.load('doc/__tests__/DocAnnotator-test.html'); + stubs.controller = { enter: () => {} }; + stubs.controllerMock = sandbox.mock(stubs.controller); + const options = { annotator: { NAME: 'name', - TYPE: ['highlight', 'highlight-comment'] + TYPE: ['highlight', 'highlight-comment'], + CONTROLLERS: { 'something': stubs.controller } } }; annotator = new DocAnnotator({ @@ -62,6 +68,7 @@ describe('doc/DocAnnotator', () => { annotator.threads = {}; annotator.modeControllers = {}; annotator.permissions = annotator.getAnnotationPermissions(annotator.options.file); + sandbox.stub(annotator, 'emit'); stubs.thread = { threadID: '123abc', @@ -104,51 +111,6 @@ describe('doc/DocAnnotator', () => { stubs = {}; }); - describe('constructor()', () => { - it('should not bind any plain highlight functions if they are disabled', () => { - const options = { - annotator: { - NAME: 'name', - TYPE: ['highlight-comment'] - } - }; - annotator = new DocAnnotator({ - canAnnotate: true, - container: document, - annotationService: {}, - file: { file_version: { id: 1 } }, - isMobile: false, - options, - modeButtons: {}, - location: { locale: 'en-US' }, - localizedStrings: { anonymousUserName: 'anonymous' } - }); - stubs.createDialogMock.expects('addListener').withArgs(CreateEvents.plain, sinon.match.func).never(); - }); - - it('should not bind any comment highlight functions if they are disabled', () => { - const options = { - annotator: { - NAME: 'name', - TYPE: ['highlight'] - } - }; - annotator = new DocAnnotator({ - canAnnotate: true, - container: document, - annotationService: {}, - file: { file_version: { id: 1 } }, - isMobile: false, - options, - modeButtons: {}, - location: { locale: 'en-US' }, - localizedStrings: { anonymousUserName: 'anonymous' } - }); - stubs.createDialogMock.expects('addListener').withArgs(CreateEvents.comment, sinon.match.func).never(); - stubs.createDialogMock.expects('addListener').withArgs(CreateEvents.commentPost, sinon.match.func).never(); - }); - }); - describe('init()', () => { it('should add ID to annotatedElement add createHighlightDialog init listener', () => { stubs.createDialogMock.expects('addListener').withArgs(CreateEvents.init, sinon.match.func); @@ -349,7 +311,6 @@ describe('doc/DocAnnotator', () => { describe('createAnnotationThread()', () => { beforeEach(() => { - stubs.addThread = sandbox.stub(annotator, 'addThreadToMap'); stubs.setupFunc = AnnotationThread.prototype.setup; stubs.validateThread = sandbox.stub(annotatorUtil, 'areThreadParamsValid').returns(true); sandbox.stub(annotator, 'handleValidationError'); @@ -362,28 +323,25 @@ describe('doc/DocAnnotator', () => { Object.defineProperty(AnnotationThread.prototype, 'setup', { value: stubs.setupFunc }); }); - it('should create, add highlight thread to internal map, and return it', () => { + it('should create highlight thread and return it', () => { const thread = annotator.createAnnotationThread([], {}, TYPES.highlight); - expect(stubs.addThread).to.be.called; expect(thread instanceof DocHighlightThread).to.be.true; expect(annotator.handleValidationError).to.not.be.called; }); - it('should create, add highlight comment thread to internal map, and return it', () => { + it('should create highlight comment thread and return it', () => { const thread = annotator.createAnnotationThread([], {}, TYPES.highlight_comment); - expect(stubs.addThread).to.be.called; expect(thread instanceof DocHighlightThread).to.be.true; expect(annotator.handleValidationError).to.not.be.called; }); - it('should create, add point thread to internal map, and return it', () => { + it('should create point thread and return it', () => { const thread = annotator.createAnnotationThread([], {}, TYPES.point); - expect(stubs.addThread).to.be.called; expect(thread instanceof DocPointThread).to.be.true; expect(annotator.handleValidationError).to.not.be.called; }); - it('should create, add highlight thread to internal map with appropriate parameters', () => { + it('should create highlight thread with appropriate parameters', () => { Object.defineProperty(AnnotationThread.prototype, 'setup', { value: sandbox.mock() }); const annotation = new Annotation({ fileVersionId: 2, @@ -395,16 +353,14 @@ describe('doc/DocAnnotator', () => { }); const thread = annotator.createAnnotationThread([annotation], {}, TYPES.highlight); - expect(stubs.addThread).to.be.called; expect(thread.threadID).to.equal(annotation.threadID); expect(thread.threadNumber).to.equal(annotation.threadNumber); expect(thread instanceof DocHighlightThread).to.be.true; expect(annotator.handleValidationError).to.not.be.called; }); - it('should create drawing thread and return it without adding it to the internal thread map', () => { + it('should create drawing thread and return it', () => { const thread = annotator.createAnnotationThread([], {}, TYPES.draw); - expect(stubs.addThread).to.not.be.called; expect(thread instanceof DocDrawingThread).to.be.true; expect(annotator.handleValidationError).to.not.be.called; }); @@ -418,7 +374,6 @@ describe('doc/DocAnnotator', () => { }); it('should emit error and return undefined if thread fails to create', () => { - sandbox.stub(annotator, 'emit'); const thread = annotator.createAnnotationThread([], {}, 'random'); expect(thread).to.be.undefined; expect(annotator.emit).to.be.calledWith(ANNOTATOR_EVENT.error, annotator.localized.loadError); @@ -436,7 +391,7 @@ describe('doc/DocAnnotator', () => { expect(annotator.highlightCurrentSelection).to.be.called; }); - it('should invoke createHighlightThread()', () => { + it('should invoke createHighlightThread', () => { expect(annotator.createHighlightThread).to.be.called; }); }); @@ -447,7 +402,6 @@ describe('doc/DocAnnotator', () => { beforeEach(() => { stubs.getLocationFromEvent = sandbox.stub(annotator, 'getLocationFromEvent'); stubs.createAnnotationThread = sandbox.stub(annotator, 'createAnnotationThread'); - stubs.bindCustomListenersOnThread = sandbox.stub(annotator, 'bindCustomListenersOnThread'); stubs.renderAnnotationsOnPage = sandbox.stub(annotator, 'renderAnnotationsOnPage'); annotator.highlighter = { @@ -462,6 +416,7 @@ describe('doc/DocAnnotator', () => { thread = { dialog, + type: 'highlight', show: sandbox.stub(), getThreadEventData: sandbox.stub() }; @@ -487,7 +442,7 @@ describe('doc/DocAnnotator', () => { expect(stubs.createAnnotationThread).to.not.be.called; }); - it('should create an annotation thread off of the highlight selection by invoking createAnnotationThread() with correct type', () => { + it('should create an annotation thread off of the highlight selection by invoking createAnnotationThread with correct type', () => { annotator.lastHighlightEvent = {}; const location = { page: 1 }; stubs.getLocationFromEvent.returns(location); @@ -504,7 +459,6 @@ describe('doc/DocAnnotator', () => { stubs.createAnnotationThread.returns(null); annotator.createHighlightThread('some text'); - expect(stubs.bindCustomListenersOnThread).to.not.be.called; }); it('should render the annotation thread dialog if it is a basic annotation type', () => { @@ -548,14 +502,18 @@ describe('doc/DocAnnotator', () => { expect(dialog.postAnnotation).to.be.calledWith(text); }); - it('should bind event listeners by invoking bindCustomListenersOnThread()', () => { + it('should not register the thread if there is no appropriate controller', () => { annotator.lastHighlightEvent = {}; const location = { page: 1 }; stubs.getLocationFromEvent.returns(location); stubs.createAnnotationThread.returns(thread); - annotator.createHighlightThread(); - expect(stubs.bindCustomListenersOnThread).to.be.calledWith(thread); + const controller = { registerThread: sandbox.stub() }; + stubs.registerThread = controller.registerThread; + annotator.modeControllers = { 'random': controller }; + + expect(annotator.createHighlightThread()).to.deep.equal(thread); + expect(stubs.registerThread).to.not.be.called; }); it('should return an annotation thread', () => { @@ -565,13 +523,23 @@ describe('doc/DocAnnotator', () => { stubs.getLocationFromEvent.returns(location); stubs.createAnnotationThread.returns(thread); + const controller = { registerThread: sandbox.stub() }; + stubs.registerThread = controller.registerThread; + annotator.modeControllers = { 'highlight': controller }; + expect(annotator.createHighlightThread()).to.deep.equal(thread); + expect(stubs.registerThread).to.be.called; }); }); describe('renderAnnotationsOnPage()', () => { beforeEach(() => { sandbox.stub(annotator, 'scaleAnnotationCanvases'); + + annotator.modeControllers = { + 'highlight': HighlightModeController, + 'highlight-comment': HighlightModeController + }; }); it('should destroy any pending highlight annotations on the page', () => { @@ -594,21 +562,17 @@ describe('doc/DocAnnotator', () => { }); it('should call show on ONLY enabled annotation types', () => { - const plain = { state: 'I do not care', type: 'highlight', show: sandbox.stub() }; - const comment = { state: 'I do not care', type: 'highlight-comment', show: sandbox.stub() }; - const point = { state: 'I do not care', type: 'point', show: sandbox.stub() }; + const plain = { threadID: 1, location: { page: 1 }, type: 'highlight', show: sandbox.stub() }; + const comment = { threadID: 2, location: { page: 1 }, type: 'highlight-comment', show: sandbox.stub() }; + const point = { threadID: 3, location: { page: 1 }, type: 'point', show: sandbox.stub() }; const threads = [plain, comment, point]; - annotator.threads = { 1: threads }; - sandbox.stub(annotator, 'getHighlightThreadsOnPage').returns(threads); - annotator.options.annotator = { - TYPE: ['highlight', 'point'] - }; + annotator.threads = { 1: { 1: plain, 2: comment, 3: point } }; + sandbox.stub(annotator, 'getHighlightThreadsOnPage').returns([]); annotator.renderAnnotationsOnPage(1); - expect(plain.show).to.be.called; - expect(point.show).to.be.called; - expect(comment.show).to.not.be.called; + expect(point.show).to.not.be.called; + expect(comment.show).to.be.called; annotator.threads = {}; }); @@ -639,64 +603,17 @@ describe('doc/DocAnnotator', () => { }); }); - describe('toggleAnnotationHandler()', () => { - beforeEach(() => { - stubs.destroyStub = sandbox.stub(annotator, 'destroyPendingThreads'); - stubs.annotationMode = sandbox.stub(annotator, 'isInAnnotationMode'); - stubs.exitModes = sandbox.stub(annotator, 'exitAnnotationModesExcept'); - stubs.disable = sandbox.stub(annotator, 'disableAnnotationMode'); - stubs.enable = sandbox.stub(annotator, 'enableAnnotationMode'); - sandbox.stub(annotator, 'getAnnotateButton'); - stubs.isAnnotatable = sandbox.stub(annotator, 'isModeAnnotatable').returns(true); - - annotator.modeButtons = { - point: { selector: 'point_btn' }, - draw: { selector: 'draw_btn' } - }; - - annotator.createHighlightDialog = { - isVisible: false, - hide: sandbox.stub(), - destroy: sandbox.stub() - } - }); - - afterEach(() => { - annotator.modeButtons = {}; - }); - - it('should do nothing if specified annotation type is not annotatable', () => { - stubs.isAnnotatable.returns(false); - annotator.toggleAnnotationHandler('bleh'); - expect(stubs.destroyStub).to.not.be.called; - }); - - it('should hide the highlight dialog and remove selection if it is visible', () => { - const getSelectionStub = sandbox.stub(document, 'getSelection').returns({ - removeAllRanges: sandbox.stub() - }); - - annotator.toggleAnnotationHandler(TYPES.highlight); - expect(annotator.createHighlightDialog.hide).to.not.be.called; - expect(getSelectionStub).to.not.be.called; - - annotator.createHighlightDialog.isVisible = true; - - annotator.toggleAnnotationHandler(TYPES.highlight); - expect(annotator.createHighlightDialog.hide).to.be.called; - expect(getSelectionStub).to.be.called; - }); - }); - describe('setupAnnotations()', () => { const setupFunc = Annotator.prototype.setupAnnotations; beforeEach(() => { Object.defineProperty(Annotator.prototype, 'setupAnnotations', { value: sandbox.stub() }); - annotator.plainHighlightEnabled = true; - annotator.commentHighlightEnabled = true; - annotator.highlighter = { - addClassApplier: sandbox.stub() + stubs.highlighter = { addClassApplier: sandbox.stub() }; + sandbox.stub(rangy, 'createHighlighter').returns(stubs.highlighter); + + annotator.modeControllers = { + 'highlight': {}, + 'highlight-comment': {} }; }); @@ -704,22 +621,19 @@ describe('doc/DocAnnotator', () => { Object.defineProperty(Annotator.prototype, 'setupAnnotations', { value: setupFunc }); }); - it('should call parent to setup annotations and initialize highlighter', () => { - sandbox.stub(rangy, 'createHighlighter').returns(annotator.highlighter); - - annotator.setupAnnotations(); - expect(rangy.createHighlighter).to.be.called; - expect(annotator.highlighter.addClassApplier).to.be.called; + it('should not bind any plain highlight functions if they are disabled', () => { + stubs.createDialogMock.expects('addListener').withArgs(CreateEvents.plain, sinon.match.func).never(); }); - it('should not create a highlighter if all forms of highlight are disabled', () => { - sandbox.stub(rangy, 'createHighlighter'); - annotator.plainHighlightEnabled = false; - annotator.commentHighlightEnabled = false; + it('should not bind any comment highlight functions if they are disabled', () => { + stubs.createDialogMock.expects('addListener').withArgs(CreateEvents.comment, sinon.match.func).never(); + stubs.createDialogMock.expects('addListener').withArgs(CreateEvents.commentPost, sinon.match.func).never(); + }); + it('should call parent to setup annotations and initialize highlighter', () => { annotator.setupAnnotations(); - expect(rangy.createHighlighter).to.not.be.called; - expect(annotator.highlighter.addClassApplier).to.not.be.called; + expect(rangy.createHighlighter).to.be.called; + expect(stubs.highlighter.addClassApplier).to.be.called; }); }); @@ -882,24 +796,16 @@ describe('doc/DocAnnotator', () => { }); describe('highlightMousedownHandler()', () => { - const bindFunc = Annotator.prototype.bindCustomListenersOnThread; - - afterEach(() => { - Object.defineProperty(Annotator.prototype, 'bindCustomListenersOnThread', { value: bindFunc }); - }); - it('should get highlights on page and call their onMouse down method', () => { const thread = { location: { page: 1 }, onMousedown: () => {}, - unbindCustomListenersOnThread: () => {}, removeAllListeners: () => {} }; stubs.threadMock = sandbox.mock(thread); stubs.threadMock.expects('onMousedown'); stubs.highlights = sandbox.stub(annotator, 'getHighlightThreadsOnPage').returns([thread]); - - annotator.addThreadToMap(thread); + annotator.threads = { 1: { '123abc': thread } }; annotator.highlightMousedownHandler({ clientX: 1, clientY: 1 }); expect(stubs.highlights).to.be.called; @@ -1034,8 +940,12 @@ describe('doc/DocAnnotator', () => { }); it('should add delayThreads and hide innactive threads if the page is found', () => { - annotator.addThreadToMap(stubs.thread); - annotator.addThreadToMap(stubs.delayThread); + annotator.threads = { + 1: { + '123abc': stubs.thread, + '456def': stubs.delayThread + } + }; stubs.threadMock.expects('onMousemove').returns(false); stubs.delayMock.expects('onMousemove').returns(true); stubs.threadMock.expects('show').never(); @@ -1056,7 +966,7 @@ describe('doc/DocAnnotator', () => { it('should switch to the text cursor if mouse is no longer hovering over a highlight', () => { stubs.delayMock.expects('onMousemove').returns(false); - annotator.addThreadToMap(stubs.delayThread); + annotator.threads = { 1: { '456def': stubs.delayThread } }; sandbox.stub(annotator, 'removeDefaultCursor'); annotator.mouseMoveEvent = { clientX: 3, clientY: 3 }; @@ -1071,7 +981,7 @@ describe('doc/DocAnnotator', () => { it('should switch to the hand cursor if mouse is hovering over a highlight', () => { stubs.delayMock.expects('onMousemove').returns(true); sandbox.stub(annotator, 'useDefaultCursor'); - annotator.addThreadToMap(stubs.delayThread); + annotator.threads = { 1: { '456def': stubs.delayThread } }; annotator.mouseMoveEvent = { clientX: 3, clientY: 3 }; annotator.onHighlightCheck(); @@ -1080,8 +990,12 @@ describe('doc/DocAnnotator', () => { }); it('should show the top-most delayed thread, and hide all others', () => { - annotator.addThreadToMap(stubs.delayThread); - annotator.addThreadToMap(stubs.delayThread2); + annotator.threads = { + 1: { + '456def': stubs.delayThread, + '789ghi': stubs.delayThread2 + } + }; stubs.delayMock.expects('onMousemove').returns(true); stubs.delayMock.expects('show'); @@ -1095,7 +1009,7 @@ describe('doc/DocAnnotator', () => { it('should do nothing if there are pending, pending-active, active, or active hover highlight threads', () => { stubs.thread.state = STATES.pending; - annotator.addThreadToMap(stubs.thread); + annotator.threads = { 1: { '123abc': stubs.thread } }; stubs.threadMock.expects('onMousemove').returns(false).never(); annotator.mouseMoveEvent = { clientX: 3, clientY: 3 }; @@ -1247,7 +1161,7 @@ describe('doc/DocAnnotator', () => { const threadMock = sandbox.mock(thread); threadMock.expects('reset'); annotator.lastHighlightEvent = {}; - annotator.addThreadToMap(thread); + annotator.threads = { 1: { '123abc': stubs.thread } }; sandbox.stub(window, 'getSelection').returns(selection); sandbox.stub(annotator.createHighlightDialog, 'show'); @@ -1273,7 +1187,6 @@ describe('doc/DocAnnotator', () => { stubs.getThreads = sandbox.stub(annotator, 'getHighlightThreadsOnPage').returns([]); stubs.getLocation = sandbox.stub(annotator, 'getLocationFromEvent').returns(undefined); stubs.createThread = sandbox.stub(annotator, 'createAnnotationThread'); - stubs.bindListeners = sandbox.stub(annotator, 'bindCustomListenersOnThread'); stubs.getSel = sandbox.stub(window, 'getSelection'); stubs.event = new Event({ x: 1, y: 1 }); @@ -1348,7 +1261,7 @@ describe('doc/DocAnnotator', () => { describe('highlightClickHandler()', () => { beforeEach(() => { stubs.event = { x: 1, y: 1 }; - annotator.addThreadToMap(stubs.thread); + annotator.threads = { 1: { '123abc': stubs.thread } }; stubs.getPageInfo = stubs.getPageInfo.returns({ pageEl: {}, page: 1 }); }); @@ -1403,12 +1316,12 @@ describe('doc/DocAnnotator', () => { describe('getHighlightThreadsOnPage()', () => { it('return the highlight threads on that page', () => { const thread = { + threadID: '123abc', location: { page: 1 }, type: TYPES.highlight, - unbindCustomListenersOnThread: sandbox.stub(), removeAllListeners: sandbox.stub() }; - annotator.addThreadToMap(thread); + annotator.threads = { 1: { '123abc': thread } }; stubs.isHighlight = sandbox.stub(annotatorUtil, 'isHighlightAnnotation').returns(thread); const threads = annotator.getHighlightThreadsOnPage(1); @@ -1541,36 +1454,54 @@ describe('doc/DocAnnotator', () => { }); }); - describe('handleAnnotationThreadEvents()', () => { - beforeEach(() => { - stubs.handleFunc = Annotator.prototype.handleAnnotationThreadEvents; - Object.defineProperty(Annotator.prototype, 'handleAnnotationThreadEvents', { value: sandbox.stub() }); + describe('handleControllerEvents()', () => { + const mode = 'something'; - stubs.getThread = sandbox.stub(annotator, 'getThreadByID'); - stubs.show = sandbox.stub(annotator, 'showHighlightsOnPage'); + beforeEach(() => { + const selection = document.getSelection(); + stubs.removeSelection = sandbox.stub(selection, 'removeAllRanges'); + sandbox.stub(annotator, 'showHighlightsOnPage'); + sandbox.stub(annotator, 'toggleAnnotationMode'); + annotator.createHighlightDialog = { + isVisible: true, + hide: sandbox.stub(), + }; }); afterEach(() => { - Object.defineProperty(Annotator.prototype, 'handleAnnotationThreadEvents', { value: stubs.handleFunc }); + annotator.createHighlightDialog = null; + }) + + it('should clear selections and hide the createHighlightDialog on togglemode', () => { + annotator.handleControllerEvents({ event: CONTROLLER_EVENT.toggleMode, mode }); + expect(stubs.removeSelection).to.be.called; + expect(annotator.createHighlightDialog.hide).to.be.called; }); - it('should do nothing if invalid params are specified', () => { - annotator.handleAnnotationThreadEvents('no data'); - annotator.handleAnnotationThreadEvents({ data: 'no threadID'}); - expect(stubs.getThread).to.not.be.called; + it('should do nothing if createHighlightDialog is hidden or does not exist on togglemode', () => { + annotator.createHighlightDialog = undefined; + annotator.handleControllerEvents({ event: CONTROLLER_EVENT.toggleMode, mode }); + expect(stubs.removeSelection).to.not.be.called; - annotator.handleAnnotationThreadEvents({ data: { threadID: 1 }}); - expect(Annotator.prototype.handleAnnotationThreadEvents).to.not.be.called; + annotator.createHighlightDialog = { isVisible: false }; + annotator.handleControllerEvents({ event: CONTROLLER_EVENT.toggleMode, mode }); + expect(stubs.removeSelection).to.not.be.called; }); - it('should re-render page highlights on threadDelete', () => { - stubs.getThread.returns(stubs.thread); - const data = { - event: THREAD_EVENT.threadDelete, - data: { threadID: 1 } - }; - annotator.handleAnnotationThreadEvents(data); - expect(stubs.show).to.be.calledWith(stubs.thread.location.page); + it('should show highlights on page on showhighlights', () => { + annotator.handleControllerEvents({ event: CONTROLLER_EVENT.showHighlights, data: 1 }); + expect(annotator.showHighlightsOnPage).to.be.calledWith(1); + }); + + it('should hide the createHighlightDialog on binddomlisteners', () => { + annotator.handleControllerEvents({ event: CONTROLLER_EVENT.bindDOMListeners }); + expect(annotator.createHighlightDialog.hide).to.be.called; + }); + + it('should do nothing if createHighlightDialog is hidden or does not exist on binddomlisteners', () => { + annotator.createHighlightDialog.isVisible = false + annotator.handleControllerEvents({ event: CONTROLLER_EVENT.bindDOMListeners }); + expect(annotator.createHighlightDialog.hide).to.not.be.called; }); }); }); diff --git a/src/doc/__tests__/DocHighlightThread-test.js b/src/doc/__tests__/DocHighlightThread-test.js index 6290ba8db..1f37312f6 100644 --- a/src/doc/__tests__/DocHighlightThread-test.js +++ b/src/doc/__tests__/DocHighlightThread-test.js @@ -96,6 +96,7 @@ describe('doc/DocHighlightThread', () => { describe('destroy()', () => { it('should destroy the thread', () => { + sandbox.stub(thread, 'emit'); thread.state = STATES.pending; // This stubs out a parent method by forcing the method we care about @@ -108,6 +109,7 @@ describe('doc/DocHighlightThread', () => { thread.destroy(); assert.equal(thread.element, null); + expect(thread.emit).to.be.calledWith('annotationthreadcleanup'); }); }); diff --git a/src/drawing/DrawingThread.js b/src/drawing/DrawingThread.js index 9045b1fa8..7c7b1840e 100644 --- a/src/drawing/DrawingThread.js +++ b/src/drawing/DrawingThread.js @@ -148,17 +148,20 @@ class DrawingThread extends AnnotationThread { * @return {void} */ deleteThread() { - this.annotations.forEach(this.deleteAnnotationWithID); + Object.keys(this.annotations).forEach((annotationID) => this.deleteAnnotationWithID({ annotationID })); // Calculate the bounding rectangle const [x, y, width, height] = this.getBrowserRectangularBoundary(); + // Clear the drawn thread and its boundary - this.concreteContext.clearRect( - x - DRAW_BORDER_OFFSET, - y + DRAW_BORDER_OFFSET, - width + DRAW_BORDER_OFFSET * 2, - height - DRAW_BORDER_OFFSET * 2 - ); + if (this.concreteContext) { + this.concreteContext.clearRect( + x - DRAW_BORDER_OFFSET, + y + DRAW_BORDER_OFFSET, + width + DRAW_BORDER_OFFSET * 2, + height - DRAW_BORDER_OFFSET * 2 + ); + } this.clearBoundary(); diff --git a/src/drawing/__tests__/DrawingThread-test.js b/src/drawing/__tests__/DrawingThread-test.js index 223b46dd9..705ef1751 100644 --- a/src/drawing/__tests__/DrawingThread-test.js +++ b/src/drawing/__tests__/DrawingThread-test.js @@ -85,14 +85,14 @@ describe('drawing/DrawingThread', () => { destroy: sandbox.stub() }; - thread.annotations = ['annotation']; + thread.annotations = { '123abc': {} }; thread.deleteThread(); expect(thread.getBrowserRectangularBoundary).to.be.called; expect(thread.concreteContext.clearRect).to.be.called; expect(thread.clearBoundary).to.be.called; - expect(thread.deleteAnnotationWithID).to.be.calledWith('annotation'); + expect(thread.deleteAnnotationWithID).to.be.calledWith({ annotationID: '123abc' }); expect(thread.pathContainer).to.equal(null); }); }); diff --git a/src/image/ImageAnnotator.js b/src/image/ImageAnnotator.js index c1c91c03a..f0de44ff6 100644 --- a/src/image/ImageAnnotator.js +++ b/src/image/ImageAnnotator.js @@ -119,8 +119,6 @@ class ImageAnnotator extends Annotator { if (!thread) { this.emit(ANNOTATOR_EVENT.error, this.localized.loadError); - } else { - this.addThreadToMap(thread); } return thread; diff --git a/src/image/__tests__/ImageAnnotator-test.js b/src/image/__tests__/ImageAnnotator-test.js index 91dfd284e..3ef17bdca 100644 --- a/src/image/__tests__/ImageAnnotator-test.js +++ b/src/image/__tests__/ImageAnnotator-test.js @@ -45,6 +45,8 @@ describe('image/ImageAnnotator', () => { annotator.threads = {}; annotator.modeControllers = {}; annotator.permissions = annotator.getAnnotationPermissions(annotator.options.file); + + sandbox.stub(annotator, 'emit'); }); afterEach(() => { @@ -144,7 +146,6 @@ describe('image/ImageAnnotator', () => { describe('createAnnotationThread()', () => { it('should emit error and return undefined if thread fails to create', () => { - sandbox.stub(annotator, 'emit'); sandbox.stub(annotatorUtil, 'areThreadParamsValid').returns(true); const thread = annotator.createAnnotationThread([], {}, 'random'); expect(thread).to.be.undefined; @@ -153,14 +154,13 @@ describe('image/ImageAnnotator', () => { it('should create, add point thread to internal map, and return it', () => { sandbox.stub(annotatorUtil, 'areThreadParamsValid').returns(true); - sandbox.stub(annotator, 'addThreadToMap'); sandbox.stub(annotator, 'handleValidationError'); const thread = annotator.createAnnotationThread([], { page: 2 }, TYPES.point); - expect(annotator.addThreadToMap).to.be.called; expect(thread instanceof ImagePointThread).to.be.true; expect(annotator.handleValidationError).to.not.be.called; expect(thread.location.page).equals(2); + expect(annotator.emit).to.not.be.calledWith(ANNOTATOR_EVENT.error, annotator.localized.loadError); }); it('should emit error and return undefined if thread params are invalid', () => { @@ -173,26 +173,24 @@ describe('image/ImageAnnotator', () => { it('should force page number 1 if the annotation was created without one', () => { sandbox.stub(annotatorUtil, 'areThreadParamsValid').returns(true); - sandbox.stub(annotator, 'addThreadToMap'); sandbox.stub(annotator, 'handleValidationError'); const thread = annotator.createAnnotationThread([], {}, TYPES.point); - expect(annotator.addThreadToMap).to.be.called; expect(thread instanceof ImagePointThread).to.be.true; expect(annotator.handleValidationError).to.not.be.called; expect(thread.location.page).equals(1); + expect(annotator.emit).to.not.be.calledWith(ANNOTATOR_EVENT.error, annotator.localized.loadError); }); it('should force page number 1 if the annotation was created wit page number -1', () => { sandbox.stub(annotatorUtil, 'areThreadParamsValid').returns(true); - sandbox.stub(annotator, 'addThreadToMap'); sandbox.stub(annotator, 'handleValidationError'); const thread = annotator.createAnnotationThread([], { page: -1 }, TYPES.point); - expect(annotator.addThreadToMap).to.be.called; expect(thread instanceof ImagePointThread).to.be.true; expect(annotator.handleValidationError).to.not.be.called; expect(thread.location.page).equals(1); + expect(annotator.emit).to.not.be.calledWith(ANNOTATOR_EVENT.error, annotator.localized.loadError); }); }); });