diff --git a/src/lib/annotations/AnnotationModeController.js b/src/lib/annotations/AnnotationModeController.js new file mode 100644 index 000000000..f644550c6 --- /dev/null +++ b/src/lib/annotations/AnnotationModeController.js @@ -0,0 +1,172 @@ +import EventEmitter from 'events'; + +class AnnotationModeController extends EventEmitter { + /** @property {Array} - The array of annotation threads */ + threads = []; + + /** @property {Array} - The array of annotation handlers */ + handlers = []; + + /** + * [constructor] + * + * @return {AnnotationModeController} Annotation controller instance + */ + constructor() { + super(); + + this.handleAnnotationEvent = this.handleAnnotationEvent.bind(this); + } + + /** + * Register the annotator and any information associated with the annotator + * + * @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; + } + + /** + * Bind the mode listeners and store each handler for future unbinding + * + * @public + * @return {void} + */ + bindModeListeners() { + const currentHandlerIndex = this.handlers.length; + this.setupHandlers(); + + for (let index = currentHandlerIndex; index < this.handlers.length; index++) { + const handler = this.handlers[index]; + const types = handler.type instanceof Array ? handler.type : [handler.type]; + + types.forEach((eventName) => handler.eventObj.addEventListener(eventName, handler.func)); + } + } + + /** + * Unbind the previously bound mode listeners + * + * @public + * @return {void} + */ + unbindModeListeners() { + while (this.handlers.length > 0) { + const handler = this.handlers.pop(); + const types = handler.type instanceof Array ? handler.type : [handler.type]; + + types.forEach((eventName) => { + handler.eventObj.removeEventListener(eventName, handler.func); + }); + } + } + + /** + * Register a thread with the controller so that the controller can keep track of relevant threads + * + * @public + * @param {AnnotationThread} thread - The thread to register with the controller + * @return {void} + */ + registerThread(thread) { + this.threads.push(thread); + } + + /** + * Unregister a previously registered thread + * + * @public + * @param {AnnotationThread} thread - The thread to unregister with the controller + * @return {void} + */ + unregisterThread(thread) { + this.threads = this.threads.filter((item) => item !== thread); + } + + /** + * 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('annotationevent', (data) => { + this.handleAnnotationEvent(thread, data); + }); + } + + /** + * Unbinds custom event listeners for the thread. + * + * @protected + * @param {AnnotationThread} thread - Thread to unbind events from + * @return {void} + */ + unbindCustomListenersOnThread(thread) { + if (!thread) { + return; + } + + thread.removeAllListeners('threaddeleted'); + thread.removeAllListeners('threadcleanup'); + thread.removeAllListeners('annotationsaved'); + thread.removeAllListeners('annotationevent'); + } + + /** + * Set up and return the necessary handlers for the annotation mode + * + * @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() {} + + /** + * Handle an annotation event. + * + * @protected + * @param {AnnotationThread} thread - The thread that emitted the event + * @param {Object} data - Extra data related to the annotation event + * @return {void} + */ + /* eslint-disable no-unused-vars */ + handleAnnotationEvent(thread, data = {}) {} + /* eslint-enable no-unused-vars */ + + /** + * Creates a handler description object and adds its to the internal handler container. + * Useful for setupAndGetHandlers. + * + * @protected + * @param {HTMLElement} element - The element to bind the listener to + * @param {Array|string} type - An array of event types to listen for or the event name to listen for + * @param {Function} handlerFn - The callback to be invoked when the element emits a specified eventname + * @return {void} + */ + pushElementHandler(element, type, handlerFn) { + if (!element) { + return; + } + + this.handlers.push({ + eventObj: element, + func: handlerFn, + type + }); + } +} + +export default AnnotationModeController; diff --git a/src/lib/annotations/AnnotationThread.js b/src/lib/annotations/AnnotationThread.js index aaff8bbd8..be6488811 100644 --- a/src/lib/annotations/AnnotationThread.js +++ b/src/lib/annotations/AnnotationThread.js @@ -187,7 +187,7 @@ class AnnotationThread extends EventEmitter { // If this annotation was the last one in the thread, destroy the thread } else if (this.annotations.length === 0 || annotatorUtil.isPlainHighlight(this.annotations)) { - if (this.isMobile) { + if (this.isMobile && this.dialog) { this.dialog.removeAnnotation(annotationID); this.dialog.hideMobileDialog(); } @@ -411,6 +411,8 @@ class AnnotationThread extends EventEmitter { this.dialog.addAnnotation(savedAnnotation); this.dialog.removeAnnotation(tempAnnotation.annotationID); } + + this.emit('annotationsaved'); } /** diff --git a/src/lib/annotations/Annotator.js b/src/lib/annotations/Annotator.js index 582bfe187..505515407 100644 --- a/src/lib/annotations/Annotator.js +++ b/src/lib/annotations/Annotator.js @@ -62,6 +62,9 @@ class Annotator extends EventEmitter { this.hasTouch = data.hasTouch; this.modeButtons = data.modeButtons; this.annotationModeHandlers = []; + + const { CONTROLLERS } = this.options.annotator || {}; + this.modeControllers = CONTROLLERS || {}; } /** @@ -84,7 +87,10 @@ class Annotator extends EventEmitter { Object.keys(this.modeButtons).forEach((type) => { const handler = this.getAnnotationModeClickHandler(type); const buttonEl = this.container.querySelector(this.modeButtons[type].selector); - buttonEl.removeEventListener('click', handler); + + if (buttonEl) { + buttonEl.removeEventListener('click', handler); + } }); this.unbindDOMListeners(); @@ -167,6 +173,10 @@ class Annotator extends EventEmitter { const handler = this.getAnnotationModeClickHandler(currentMode); annotateButtonEl.addEventListener('click', handler); + + if (this.modeControllers[currentMode]) { + this.modeControllers[currentMode].registerAnnotator(this); + } } } @@ -536,6 +546,17 @@ class Annotator extends EventEmitter { // Bind events on valid annotation thread const thread = this.createAnnotationThread(annotations, firstAnnotation.location, firstAnnotation.type); this.bindCustomListenersOnThread(thread); + + const { annotator } = this.options; + if (!annotator) { + return; + } + + if (this.modeControllers[firstAnnotation.type]) { + const controller = this.modeControllers[firstAnnotation.type]; + controller.bindCustomListenersOnThread(thread); + controller.registerThread(thread); + } }); this.emit('annotationsfetched'); @@ -667,6 +688,7 @@ class Annotator extends EventEmitter { unbindCustomListenersOnThread(thread) { thread.removeAllListeners('threaddeleted'); thread.removeAllListeners('threadcleanup'); + thread.removeAllListeners('annotationsaved'); thread.removeAllListeners('annotationevent'); } @@ -693,99 +715,12 @@ class Annotator extends EventEmitter { eventObj: this.annotatedElement } ); - } else if (mode === TYPES.draw) { - const drawingThread = this.createAnnotationThread([], {}, TYPES.draw); - this.bindCustomListenersOnThread(drawingThread); - - /* eslint-disable require-jsdoc */ - const locationFunction = (event) => this.getLocationFromEvent(event, TYPES.point); - /* eslint-enable require-jsdoc */ - - const postButtonEl = this.getAnnotateButton(SELECTOR_ANNOTATION_BUTTON_DRAW_POST); - const undoButtonEl = this.getAnnotateButton(SELECTOR_ANNOTATION_BUTTON_DRAW_UNDO); - const redoButtonEl = this.getAnnotateButton(SELECTOR_ANNOTATION_BUTTON_DRAW_REDO); - - // NOTE (@minhnguyen): Move this logic to a new controller class - const that = this; - drawingThread.addListener('annotationevent', (data = {}) => { - switch (data.type) { - case 'drawcommit': - drawingThread.removeAllListeners('annotationevent'); - break; - case 'pagechanged': - drawingThread.saveAnnotation(TYPES.draw); - that.unbindModeListeners(); - that.bindModeListeners(TYPES.draw); - break; - case 'availableactions': - if (data.undo === 1) { - annotatorUtil.enableElement(undoButtonEl); - } else if (data.undo === 0) { - annotatorUtil.disableElement(undoButtonEl); - } - - if (data.redo === 1) { - annotatorUtil.enableElement(redoButtonEl); - } else if (data.redo === 0) { - annotatorUtil.disableElement(redoButtonEl); - } - break; - default: - } - }); - - handlers.push( - { - type: 'mousemove', - func: annotatorUtil.eventToLocationHandler(locationFunction, drawingThread.handleMove), - eventObj: this.annotatedElement - }, - { - type: 'mousedown', - func: annotatorUtil.eventToLocationHandler(locationFunction, drawingThread.handleStart), - eventObj: this.annotatedElement - }, - { - type: 'mouseup', - func: annotatorUtil.eventToLocationHandler(locationFunction, drawingThread.handleStop), - eventObj: this.annotatedElement - } - ); - - if (postButtonEl) { - handlers.push({ - type: 'click', - func: () => { - drawingThread.saveAnnotation(mode); - this.toggleAnnotationHandler(mode); - }, - eventObj: postButtonEl - }); - } - - if (undoButtonEl) { - handlers.push({ - type: 'click', - func: () => { - drawingThread.undo(); - }, - eventObj: undoButtonEl - }); - } - - if (redoButtonEl) { - handlers.push({ - type: 'click', - func: () => { - drawingThread.redo(); - }, - eventObj: redoButtonEl - }); - } + } else if (mode === TYPES.draw && this.modeControllers[mode]) { + this.modeControllers[mode].bindModeListeners(); } handlers.forEach((handler) => { - handler.eventObj.addEventListener(handler.type, handler.func); + handler.eventObj.addEventListener(handler.type, handler.func, false); this.annotationModeHandlers.push(handler); }); } @@ -836,13 +771,18 @@ class Annotator extends EventEmitter { * Unbinds event listeners for annotation modes. * * @protected + * @param {string} mode - Annotation mode to be unbound * @return {void} */ - unbindModeListeners() { + 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(); + } } /** diff --git a/src/lib/annotations/Annotator.scss b/src/lib/annotations/Annotator.scss index c555c019f..faa0aea23 100644 --- a/src/lib/annotations/Annotator.scss +++ b/src/lib/annotations/Annotator.scss @@ -423,6 +423,27 @@ $avatar-color-9: #f22c44; width: 100%; } +//------------------------------------------------------------------------------ +// Draw annotation mode +//------------------------------------------------------------------------------ +.bp-annotation-draw-boundary { + animation: dash 1s linear infinite; + fill: none; + stroke: rgb(0, 0, 0); + stroke-dasharray: 5; + stroke-width: 3px; +} + +@keyframes dash { + from { + stroke-dashoffset: 10; + } + + to { + stroke-dashoffset: 0; + } +} + //------------------------------------------------------------------------------ // Annotation mode //------------------------------------------------------------------------------ diff --git a/src/lib/annotations/BoxAnnotations.js b/src/lib/annotations/BoxAnnotations.js index dc3b547b3..7f14f504d 100644 --- a/src/lib/annotations/BoxAnnotations.js +++ b/src/lib/annotations/BoxAnnotations.js @@ -1,5 +1,6 @@ import DocAnnotator from './doc/DocAnnotator'; import ImageAnnotator from './image/ImageAnnotator'; +import DrawingModeController from './drawing/DrawingModeController'; import { TYPES } from './annotationConstants'; const ANNOTATORS = [ @@ -17,6 +18,12 @@ const ANNOTATORS = [ } ]; +const ANNOTATOR_TYPE_CONTROLLERS = { + [TYPES.draw]: { + CONSTRUCTOR: DrawingModeController + } +}; + class BoxAnnotations { /** * [constructor] @@ -45,10 +52,35 @@ class BoxAnnotations { */ getAnnotatorsForViewer(viewerName, disabledAnnotators = []) { const annotators = this.getAnnotators(); - - return annotators.find( + const annotatorConfig = annotators.find( (annotator) => !disabledAnnotators.includes(annotator.NAME) && annotator.VIEWER.includes(viewerName) ); + this.instantiateControllers(annotatorConfig); + + return annotatorConfig; + } + + /** + * Instantiates and attaches controller instances to an annotator configuration. Does nothing if controller + * has already been instantiated or the config is invalid. + * + * @private + * @param {Object} annotatorConfig - The config where annotation type controller instances should be attached + * @return {void} + */ + instantiateControllers(annotatorConfig) { + if (!annotatorConfig || !annotatorConfig.TYPE || annotatorConfig.CONTROLLERS) { + return; + } + + /* eslint-disable no-param-reassign */ + annotatorConfig.CONTROLLERS = {}; + annotatorConfig.TYPE.forEach((type) => { + if (type in ANNOTATOR_TYPE_CONTROLLERS) { + annotatorConfig.CONTROLLERS[type] = new ANNOTATOR_TYPE_CONTROLLERS[type].CONSTRUCTOR(); + } + }); + /* eslint-enable no-param-reassign */ } /** diff --git a/src/lib/annotations/__tests__/AnnotationModeController-test.js b/src/lib/annotations/__tests__/AnnotationModeController-test.js new file mode 100644 index 000000000..36cc466d7 --- /dev/null +++ b/src/lib/annotations/__tests__/AnnotationModeController-test.js @@ -0,0 +1,159 @@ +import AnnotationModeController from '../AnnotationModeController'; + +let annotationModeController; +let stubs; +const sandbox = sinon.sandbox.create(); + +describe('lib/annotations/AnnotationModeController', () => { + beforeEach(() => { + annotationModeController = new AnnotationModeController(); + stubs = {}; + }); + + afterEach(() => { + sandbox.verifyAndRestore(); + stubs = null; + annotationModeController = null; + }); + + describe('registerAnnotator()', () => { + it('should internally keep track of the registered annotator', () => { + const annotator = 'I am an annotator'; + expect(annotationModeController.annotator).to.be.undefined; + + annotationModeController.registerAnnotator(annotator); + expect(annotationModeController.annotator).to.equal(annotator); + }); + }); + + describe('bindModeListeners()', () => { + it('should bind mode listeners', () => { + const handlerObj = { + type: 'event', + func: () => {}, + eventObj: { + addEventListener: sandbox.stub() + } + }; + sandbox.stub(annotationModeController, 'setupHandlers', () => { + annotationModeController.handlers = [handlerObj]; + }); + expect(annotationModeController.handlers.length).to.equal(0); + + annotationModeController.bindModeListeners(); + expect(handlerObj.eventObj.addEventListener).to.be.calledWith(handlerObj.type, handlerObj.func); + expect(annotationModeController.handlers.length).to.equal(1); + }); + }); + + describe('unbindModeListeners()', () => { + it('should unbind mode listeners', () => { + const handlerObj = { + type: 'event', + func: () => {}, + eventObj: { + removeEventListener: sandbox.stub() + } + }; + + annotationModeController.handlers = [handlerObj]; + expect(annotationModeController.handlers.length).to.equal(1); + + annotationModeController.unbindModeListeners(); + expect(handlerObj.eventObj.removeEventListener).to.be.calledWith(handlerObj.type, handlerObj.func); + expect(annotationModeController.handlers.length).to.equal(0); + }); + }); + + describe('registerThread()', () => { + it('should internally keep track of the registered thread', () => { + const thread = 'I am a thread'; + expect(annotationModeController.threads.includes(thread)).to.be.falsy; + + annotationModeController.registerThread(thread); + expect(annotationModeController.threads.includes(thread)).to.be.truthy; + }); + }); + + describe('unregisterThread()', () => { + it('should internally keep track of the registered thread', () => { + const thread = 'I am a thread'; + annotationModeController.threads = [thread, 'other']; + expect(annotationModeController.threads.includes(thread)).to.be.truthy; + + annotationModeController.unregisterThread(thread); + expect(annotationModeController.threads.includes(thread)).to.be.falsy; + }); + }); + + describe('bindCustomListenersOnThread()', () => { + it('should do nothing when the input is empty', () => { + annotationModeController.annotator = { + bindCustomListenersOnThread: sandbox.stub() + }; + + annotationModeController.bindCustomListenersOnThread(undefined); + expect(annotationModeController.annotator.bindCustomListenersOnThread).to.not.be.called; + }); + + it('should bind custom listeners on thread', () => { + const thread = { + addListener: sandbox.stub() + }; + annotationModeController.annotator = { + bindCustomListenersOnThread: sandbox.stub() + }; + + annotationModeController.bindCustomListenersOnThread(thread); + expect(annotationModeController.annotator.bindCustomListenersOnThread).to.be.called; + expect(thread.addListener).to.be.called; + }); + }); + + describe('unbindCustomListenersOnThread()', () => { + it('should do nothing when the input is empty', () => { + const thread = { + removeAllListeners: sandbox.stub() + }; + + annotationModeController.unbindCustomListenersOnThread(undefined); + expect(thread.removeAllListeners).to.not.be.called; + }); + + it('should bind custom listeners on thread', () => { + const thread = { + removeAllListeners: sandbox.stub() + }; + + annotationModeController.unbindCustomListenersOnThread(thread); + expect(thread.removeAllListeners).to.have.callCount(4); + }); + }); + + describe('pushElementHandler()', () => { + it('should do nothing when the element is invalid', () => { + const lengthBefore = annotationModeController.handlers.length; + + annotationModeController.pushElementHandler(undefined, 'type', () => {}); + const lengthAfter = annotationModeController.handlers.length; + expect(lengthAfter).to.equal(lengthBefore); + }); + + it('should add a handler descriptor to the handlers array', () => { + const lengthBefore = annotationModeController.handlers.length; + const element = 'element'; + const type = ['type1', 'type2']; + const fn = 'fn'; + + annotationModeController.pushElementHandler(element, type, fn); + const handlers = annotationModeController.handlers; + const lengthAfter = handlers.length; + expect(lengthAfter).to.equal(lengthBefore+1); + expect(handlers[handlers.length - 1]).to.deep.equal({ + eventObj: element, + func: fn, + type + }); + }); + }); +}); diff --git a/src/lib/annotations/__tests__/AnnotationThread-test.js b/src/lib/annotations/__tests__/AnnotationThread-test.js index b7e25ad91..79ccd276c 100644 --- a/src/lib/annotations/__tests__/AnnotationThread-test.js +++ b/src/lib/annotations/__tests__/AnnotationThread-test.js @@ -218,6 +218,16 @@ describe('lib/annotations/AnnotationThread', () => { expect(thread.annotations.find(isServerAnnotation)).to.not.be.undefined; }); + it('should emit an annotationsaved event on success', (done) => { + const serverAnnotation = 'real annotation'; + const tempAnnotation = serverAnnotation; + thread.addListener('annotationsaved', () => { + expect(stubs.saveAnnotationToThread).to.be.called; + done(); + }); + + thread.updateTemporaryAnnotation(tempAnnotation, serverAnnotation); + }); }) describe('deleteAnnotation()', () => { diff --git a/src/lib/annotations/__tests__/Annotator-test.js b/src/lib/annotations/__tests__/Annotator-test.js index 68f3b9348..a6e74f947 100644 --- a/src/lib/annotations/__tests__/Annotator-test.js +++ b/src/lib/annotations/__tests__/Annotator-test.js @@ -547,6 +547,7 @@ describe('lib/annotations/Annotator', () => { it('should unbind custom listeners from the thread', () => { stubs.threadMock.expects('removeAllListeners').withArgs('threaddeleted'); stubs.threadMock.expects('removeAllListeners').withArgs('threadcleanup'); + stubs.threadMock.expects('removeAllListeners').withArgs('annotationsaved'); stubs.threadMock.expects('removeAllListeners').withArgs('annotationevent'); annotator.unbindCustomListenersOnThread(stubs.thread); }); @@ -561,6 +562,13 @@ describe('lib/annotations/Annotator', () => { removeEventListener: sandbox.stub() }; + stubs.controllers = { + [TYPES.draw]: { + bindModeListeners: sandbox.stub() + } + }; + + annotator.modeControllers = stubs.controllers; drawingThread = { handleStart: () => {}, handleStop: () => {}, @@ -583,81 +591,10 @@ describe('lib/annotations/Annotator', () => { }); it('should bind draw mode click handlers if post button exists', () => { - sandbox.stub(annotator, 'createAnnotationThread').returns(drawingThread); - - const postButtonEl = { - addEventListener: sandbox.stub(), - removeEventListener: sandbox.stub() - }; - sandbox.stub(annotator, 'getAnnotateButton').returns(postButtonEl); - const locationHandler = (() => {}); - - sandbox.stub(annotatorUtil, 'eventToLocationHandler').returns(locationHandler); - annotator.bindModeListeners(TYPES.draw); - expect(annotator.annotatedElement.addEventListener).to.be.calledWith( - sinon.match.string, - locationHandler - ).thrice; - expect(postButtonEl.addEventListener).to.be.calledWith( - 'click', - sinon.match.func - ); - expect(annotator.annotationModeHandlers.length).equals(6); - }); - - it('should successfully bind draw mode handlers if undo and redo buttons do not exist', () => { - sandbox.stub(annotator, 'createAnnotationThread').returns(drawingThread); - - const postButtonEl = { - addEventListener: sandbox.stub(), - removeEventListener: sandbox.stub() - }; - const locationHandler = (() => {}); - const getAnnotateButton = sandbox.stub(annotator, 'getAnnotateButton'); - getAnnotateButton.withArgs(SELECTOR_ANNOTATION_BUTTON_DRAW_POST).returns(postButtonEl); - - sandbox.stub(annotatorUtil, 'eventToLocationHandler').returns(locationHandler); - - annotator.bindModeListeners(TYPES.draw); - - expect(annotator.annotatedElement.addEventListener).to.be.calledWith( - sinon.match.string, - locationHandler - ).thrice; - expect(postButtonEl.addEventListener).to.be.calledWith( - 'click', - sinon.match.func - ); - expect(annotator.annotationModeHandlers.length).equals(4); - }); - - it('should successfully bind draw mode handlers if post button does not exist', () => { - sandbox.stub(annotator, 'createAnnotationThread').returns(drawingThread); - - const doButtonEl = { - addEventListener: sandbox.stub(), - removeEventListener: sandbox.stub() - }; - const locationHandler = (() => {}); - const getAnnotateButton = sandbox.stub(annotator, 'getAnnotateButton'); - getAnnotateButton.withArgs(SELECTOR_ANNOTATION_BUTTON_DRAW_UNDO).returns(doButtonEl); - getAnnotateButton.withArgs(SELECTOR_ANNOTATION_BUTTON_DRAW_REDO).returns(doButtonEl); - - sandbox.stub(annotatorUtil, 'eventToLocationHandler').returns(locationHandler); - - annotator.bindModeListeners(TYPES.draw); - - expect(annotator.annotatedElement.addEventListener).to.be.calledWith( - sinon.match.string, - locationHandler - ).thrice; - expect(doButtonEl.addEventListener).to.be.calledWith( - 'click', - sinon.match.func - ); - expect(annotator.annotationModeHandlers.length).equals(5); + expect(annotator.annotatedElement.addEventListener).to.not.be.called; + expect(stubs.controllers[TYPES.draw].bindModeListeners).to.be.called; }); }); @@ -687,6 +624,18 @@ describe('lib/annotations/Annotator', () => { 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()', () => { diff --git a/src/lib/annotations/__tests__/BoxAnnotations-test.js b/src/lib/annotations/__tests__/BoxAnnotations-test.js index e3609b43e..98186e090 100644 --- a/src/lib/annotations/__tests__/BoxAnnotations-test.js +++ b/src/lib/annotations/__tests__/BoxAnnotations-test.js @@ -1,11 +1,15 @@ /* eslint-disable no-unused-expressions */ import BoxAnnotations from '../BoxAnnotations'; +import { TYPES } from '../annotationConstants'; +import DrawingModeController from '../drawing/DrawingModeController'; let loader; +let stubs; const sandbox = sinon.sandbox.create(); describe('lib/annotators/BoxAnnotations', () => { beforeEach(() => { + stubs = {}; loader = new BoxAnnotations(); }); @@ -17,6 +21,7 @@ describe('lib/annotators/BoxAnnotations', () => { } loader = null; + stubs = null; }); describe('getAnnotators()', () => { @@ -31,28 +36,39 @@ describe('lib/annotators/BoxAnnotations', () => { }); describe('getAnnotatorsForViewer()', () => { + beforeEach(() => { + stubs.instantiateControllers = sandbox.stub(loader, 'instantiateControllers'); + }); it('should return undefined if the annotator does not exist', () => { const annotator = loader.getAnnotatorsForViewer('not_supported_type'); expect(annotator).to.be.undefined; + expect(stubs.instantiateControllers).to.be.called; }); it('should return the correct annotator for the viewer name', () => { const name = 'Document'; const annotator = loader.getAnnotatorsForViewer(name); expect(annotator.NAME).to.equal(name); // First entry is Document annotator + expect(stubs.instantiateControllers).to.be.called; }); it('should return nothing if the viewer requested is disabled', () => { const annotator = loader.getAnnotatorsForViewer('Document', ['Document']); expect(annotator).to.be.undefined; + expect(stubs.instantiateControllers).to.be.called; }); }); describe('determineAnnotator()', () => { + beforeEach(() => { + stubs.instantiateControllers = sandbox.stub(loader, 'instantiateControllers'); + }); + it('should choose the first annotator that matches the viewer', () => { const viewer = 'Document'; const annotator = loader.determineAnnotator(viewer); expect(annotator.NAME).to.equal(viewer); + expect(stubs.instantiateControllers).to.be.called; }); it('should not choose a disabled annotator', () => { @@ -100,6 +116,50 @@ describe('lib/annotators/BoxAnnotations', () => { const annotator = loader.determineAnnotator('Document', 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'] + }); + expect(stubs.instantiateControllers).to.be.called; + }); + }); + + 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/lib/annotations/annotationConstants.js b/src/lib/annotations/annotationConstants.js index 483869548..3e5c19425 100644 --- a/src/lib/annotations/annotationConstants.js +++ b/src/lib/annotations/annotationConstants.js @@ -102,4 +102,8 @@ export const PAGE_PADDING_TOP = 15; export const PAGE_PADDING_BOTTOM = 15; export const ID_MOBILE_ANNOTATION_DIALOG = 'mobile-annotation-dialog'; + export const DRAW_RENDER_THRESHOLD = 16.67; // 60 FPS target using 16.667ms/frame +export const DRAW_BASE_LINE_WIDTH = 3; +export const DRAW_BORDER_OFFSET = 5; +export const DRAW_DASHED_SPACING = 5; diff --git a/src/lib/annotations/annotatorUtil.js b/src/lib/annotations/annotatorUtil.js index 04480c131..c5a8ee7ff 100644 --- a/src/lib/annotations/annotatorUtil.js +++ b/src/lib/annotations/annotatorUtil.js @@ -420,12 +420,10 @@ export function eventToLocationHandler(locationFunction, callback) { return; } - evt.stopPropagation(); evt.preventDefault(); + evt.stopPropagation(); const location = locationFunction(evt); - if (location) { - callback(location); - } + callback(location); }; } diff --git a/src/lib/annotations/doc/DocAnnotator.js b/src/lib/annotations/doc/DocAnnotator.js index 6cd3702f3..4ea91b1ea 100644 --- a/src/lib/annotations/doc/DocAnnotator.js +++ b/src/lib/annotations/doc/DocAnnotator.js @@ -104,6 +104,7 @@ class DocAnnotator extends Annotator { this.createHighlightThread = this.createHighlightThread.bind(this); this.createPlainHighlight = this.createPlainHighlight.bind(this); this.highlightCreateHandler = this.highlightCreateHandler.bind(this); + this.drawingSelectionHandler = this.drawingSelectionHandler.bind(this); this.createHighlightDialog = new CreateHighlightDialog(this.container, { isMobile: this.isMobile, @@ -315,7 +316,7 @@ class DocAnnotator extends Annotator { if (!thread && this.notification) { this.emit('annotationerror', __('annotations_create_error')); - } else if (thread) { + } else if (thread && (type !== TYPES.draw || location.page)) { this.addThreadToMap(thread); } @@ -460,7 +461,9 @@ class DocAnnotator extends Annotator { if (this.hasTouch && this.isMobile) { document.addEventListener('selectionchange', this.onSelectionChange); + this.annotatedElement.addEventListener('touchstart', this.drawingSelectionHandler); } else { + this.annotatedElement.addEventListener('click', this.drawingSelectionHandler); this.annotatedElement.addEventListener('dblclick', this.highlightMouseupHandler); this.annotatedElement.addEventListener('mousedown', this.highlightMousedownHandler); this.annotatedElement.addEventListener('contextmenu', this.highlightMousedownHandler); @@ -491,7 +494,9 @@ class DocAnnotator extends Annotator { if (this.hasTouch && this.isMobile) { document.removeEventListener('selectionchange', this.onSelectionChange); + this.annotatedElement.removeEventListener('touchstart', this.drawingSelectionHandler); } else { + this.annotatedElement.removeEventListener('click', this.drawingSelectionHandler); this.annotatedElement.removeEventListener('dblclick', this.highlightMouseupHandler); this.annotatedElement.removeEventListener('mousedown', this.highlightMousedownHandler); this.annotatedElement.removeEventListener('contextmenu', this.highlightMousedownHandler); @@ -773,6 +778,19 @@ class DocAnnotator extends Annotator { this.mouseMoveEvent = event; } + /** + * Drawing selection handler. Delegates to the drawing controller + * + * @private + * @param {Event} event - DOM event + * @return {void} + */ + drawingSelectionHandler(event) { + if (this.modeControllers[TYPES.draw]) { + this.modeControllers[TYPES.draw].handleSelection(event); + } + } + /** * Mouseup handler. Switches between creating a highlight and delegating * to highlight click handlers depending on whether mouse moved since diff --git a/src/lib/annotations/doc/DocDrawingThread.js b/src/lib/annotations/doc/DocDrawingThread.js index e30c1d207..bc8f57449 100644 --- a/src/lib/annotations/doc/DocDrawingThread.js +++ b/src/lib/annotations/doc/DocDrawingThread.js @@ -1,13 +1,13 @@ +import DrawingPath from '../drawing/DrawingPath'; +import DrawingThread from '../drawing/DrawingThread'; import { STATES, DRAW_STATES, CLASS_ANNOTATION_LAYER_DRAW, CLASS_ANNOTATION_LAYER_DRAW_IN_PROGRESS } from '../annotationConstants'; -import DrawingPath from '../drawing/DrawingPath'; -import DrawingThread from '../drawing/DrawingThread'; -import * as docAnnotatorUtil from './docAnnotatorUtil'; -import * as annotatorUtil from '../annotatorUtil'; +import { getBrowserCoordinatesFromLocation, getContext, getPageEl } from './docAnnotatorUtil'; +import { createLocation, getScale } from '../annotatorUtil'; class DocDrawingThread extends DrawingThread { /** @property {HTMLElement} - Page element being observed */ @@ -32,42 +32,50 @@ class DocDrawingThread extends DrawingThread { /** * Handle a pointer movement * + * @public * @param {Object} location - The location information of the pointer * @return {void} */ handleMove(location) { - if (this.drawingFlag !== DRAW_STATES.drawing) { + if (this.drawingFlag !== DRAW_STATES.drawing || !location) { return; } else if (this.hasPageChanged(location)) { - this.onPageChange(); + this.onPageChange(location); return; } - const [x, y] = docAnnotatorUtil.getBrowserCoordinatesFromLocation(location, this.pageEl); - const browserLocation = annotatorUtil.createLocation(x, y); - this.pendingPath.addCoordinate(location, browserLocation); + const [x, y] = getBrowserCoordinatesFromLocation(location, this.pageEl); + const browserLocation = createLocation(x, y); + + if (this.pendingPath) { + this.pendingPath.addCoordinate(location, browserLocation); + } } /** * Start a drawing stroke * + * @public * @param {Object} location - The location information of the pointer * @return {void} */ handleStart(location) { const pageChanged = this.hasPageChanged(location); if (pageChanged) { - this.onPageChange(); + this.onPageChange(location); return; } // Assign a location and dimension to the annotation thread - if (!this.location || (this.location && !this.location.page)) { + if ((!this.location || !this.location.page) && location.page) { this.location = { page: location.page, dimensions: location.dimensions }; this.checkAndHandleScaleUpdate(); + this.emit('annotationevent', { + type: 'locationassigned' + }); } this.drawingFlag = DRAW_STATES.drawing; @@ -82,6 +90,7 @@ class DocDrawingThread extends DrawingThread { /** * End a drawing stroke * + * @public * @return {void} */ handleStop() { @@ -97,23 +106,25 @@ class DocDrawingThread extends DrawingThread { /** * Determine if the drawing in progress if a drawing goes to a different page * + * @public * @param {Object} location - The current event location information * @return {boolean} Whether or not the thread page has changed */ hasPageChanged(location) { - return !!this.location && !!this.location.page && this.location.page !== location.page; + return location && !!this.location && !!this.location.page && this.location.page !== location.page; } /** * Saves a drawing annotation to the drawing annotation layer canvas. * + * @public * @param {string} type - Type of annotation * @param {string} text - Text of annotation to save * @return {void} */ saveAnnotation(type, text) { this.emit('annotationevent', { - type: 'drawingcommit' + type: 'drawcommit' }); this.reset(); @@ -124,13 +135,15 @@ class DocDrawingThread extends DrawingThread { } super.saveAnnotation(type, text); + this.setBoundary(); - const drawingAnnotationLayerContext = docAnnotatorUtil.getContext(this.pageEl, CLASS_ANNOTATION_LAYER_DRAW); - if (drawingAnnotationLayerContext) { + this.concreteContext = getContext(this.pageEl, CLASS_ANNOTATION_LAYER_DRAW); + if (this.concreteContext) { + // Move the in-progress drawing to the concrete context const inProgressCanvas = this.drawingContext.canvas; const width = parseInt(inProgressCanvas.style.width, 10); const height = parseInt(inProgressCanvas.style.height, 10); - drawingAnnotationLayerContext.drawImage(inProgressCanvas, 0, 0, width, height); + this.concreteContext.drawImage(inProgressCanvas, 0, 0, width, height); this.drawingContext.clearRect(0, 0, inProgressCanvas.width, inProgressCanvas.height); } } @@ -138,6 +151,7 @@ class DocDrawingThread extends DrawingThread { /** * Display the document drawing thread. Will set the drawing context if the scale has changed since the last show. * + * @public * @return {void} */ show() { @@ -145,23 +159,14 @@ class DocDrawingThread extends DrawingThread { return; } - this.checkAndHandleScaleUpdate(); - // Get the annotation layer context to draw with - let context; - if (this.state === STATES.pending) { - context = this.drawingContext; - } else { - const config = { scale: this.lastScaleFactor }; - this.pageEl = docAnnotatorUtil.getPageEl(this.annotatedElement, this.location.page); - context = docAnnotatorUtil.getContext(this.pageEl, CLASS_ANNOTATION_LAYER_DRAW); - this.setContextStyles(config, context); - } + const context = this.selectContext(); // Generate the paths and draw to the annotation layer canvas this.pathContainer.applyToItems((drawing) => drawing.generateBrowserPath(this.reconstructBrowserCoordFromLocation) ); + if (this.pendingPath && !this.pendingPath.isEmpty()) { this.pendingPath.generateBrowserPath(this.reconstructBrowserCoordFromLocation); } @@ -173,18 +178,19 @@ class DocDrawingThread extends DrawingThread { * Prepare the pending drawing canvas if the scale factor has changed since the last render. Will do nothing if * the thread has not been assigned a page. * + * @private * @return {void} */ checkAndHandleScaleUpdate() { - const scale = annotatorUtil.getScale(this.annotatedElement); + const scale = getScale(this.annotatedElement); if (this.lastScaleFactor === scale || (!this.location || !this.location.page)) { return; } // Set the scale and in-memory context for the pending thread this.lastScaleFactor = scale; - this.pageEl = docAnnotatorUtil.getPageEl(this.annotatedElement, this.location.page); - this.drawingContext = docAnnotatorUtil.getContext(this.pageEl, CLASS_ANNOTATION_LAYER_DRAW_IN_PROGRESS); + this.pageEl = getPageEl(this.annotatedElement, this.location.page); + this.drawingContext = getContext(this.pageEl, CLASS_ANNOTATION_LAYER_DRAW_IN_PROGRESS); const config = { scale }; this.setContextStyles(config); @@ -193,12 +199,15 @@ class DocDrawingThread extends DrawingThread { /** * End the current drawing and emit a page changed event * + * @private + * @param {Object} location - The location information indicating the page has changed. * @return {void} */ - onPageChange() { + onPageChange(location) { this.handleStop(); this.emit('annotationevent', { - type: 'pagechanged' + type: 'pagechanged', + location }); } @@ -211,13 +220,53 @@ class DocDrawingThread extends DrawingThread { * @return {Location} The location coordinate relative to the browser */ reconstructBrowserCoordFromLocation(documentLocation) { - const reconstructedLocation = annotatorUtil.createLocation( - documentLocation.x, - documentLocation.y, - this.location.dimensions - ); - const [xNew, yNew] = docAnnotatorUtil.getBrowserCoordinatesFromLocation(reconstructedLocation, this.pageEl); - return annotatorUtil.createLocation(xNew, yNew); + const reconstructedLocation = createLocation(documentLocation.x, documentLocation.y, this.location.dimensions); + const [xNew, yNew] = getBrowserCoordinatesFromLocation(reconstructedLocation, this.pageEl); + return createLocation(xNew, yNew); + } + + /** + * Choose the context to draw on. If the state of the thread is pending, select the in-progress context, + * otherwise select the concrete context. + * + * @private + * @return {void} + */ + selectContext() { + this.checkAndHandleScaleUpdate(); + + if (this.state === STATES.pending) { + return this.drawingContext; + } + + const config = { scale: this.lastScaleFactor }; + this.concreteContext = getContext(this.pageEl, CLASS_ANNOTATION_LAYER_DRAW); + + this.setContextStyles(config, this.concreteContext); + + return this.concreteContext; + } + + /** + * Retrieve the rectangle upper left coordinate along with its width and height + * + * @private + * @return {Array|null} The an array of length 4 with the first item being the x coordinate, the second item + * being the y coordinate, and the 3rd/4th items respectively being the width and height + */ + getRectangularBoundary() { + if (!this.location || !this.location.dimensions || !this.pageEl) { + return null; + } + + const l1 = createLocation(this.minX, this.minY, this.location.dimensions); + const l2 = createLocation(this.maxX, this.maxY, this.location.dimensions); + const [x1, y1] = getBrowserCoordinatesFromLocation(l1, this.pageEl); + const [x2, y2] = getBrowserCoordinatesFromLocation(l2, this.pageEl); + const width = x2 - x1; + const height = y2 - y1; + + return [x1, y1, width, height]; } } diff --git a/src/lib/annotations/doc/__tests__/DocAnnotator-test.js b/src/lib/annotations/doc/__tests__/DocAnnotator-test.js index 69e85fcdf..9fa8abebb 100644 --- a/src/lib/annotations/doc/__tests__/DocAnnotator-test.js +++ b/src/lib/annotations/doc/__tests__/DocAnnotator-test.js @@ -295,9 +295,9 @@ describe('lib/annotations/doc/DocAnnotator', () => { expect(annotator.handleValidationError).to.not.be.called; }); - it('should create, add drawing thread to internal map, and return it', () => { + it('should create drawing thread and return it without adding it to the internal thread map', () => { const thread = annotator.createAnnotationThread([], {}, TYPES.draw); - expect(stubs.addThread).to.have.been.called; + expect(stubs.addThread).to.not.have.been.called; expect(thread instanceof DocDrawingThread).to.be.true; expect(annotator.handleValidationError).to.not.be.called; }); @@ -538,6 +538,8 @@ describe('lib/annotations/doc/DocAnnotator', () => { stubs.elMock.expects('addEventListener').withArgs('mousedown', sinon.match.func).never(); stubs.elMock.expects('addEventListener').withArgs('contextmenu', sinon.match.func).never(); stubs.elMock.expects('addEventListener').withArgs('mousemove', sinon.match.func).never(); + stubs.elMock.expects('addEventListener').withArgs('touchstart', sinon.match.func).never(); + stubs.elMock.expects('addEventListener').withArgs('click', sinon.match.func).never(); annotator.bindDOMListeners(); }); @@ -549,6 +551,7 @@ describe('lib/annotations/doc/DocAnnotator', () => { stubs.elMock.expects('addEventListener').withArgs('mousedown', sinon.match.func); stubs.elMock.expects('addEventListener').withArgs('contextmenu', sinon.match.func); stubs.elMock.expects('addEventListener').withArgs('mousemove', sinon.match.func); + stubs.elMock.expects('addEventListener').withArgs('click', sinon.match.func) annotator.bindDOMListeners(); }); @@ -557,10 +560,12 @@ describe('lib/annotations/doc/DocAnnotator', () => { annotator.isMobile = true; annotator.hasTouch = true; const docListen = sandbox.spy(document, 'addEventListener'); + const annotatedElementListen = sandbox.spy(annotator.annotatedElement, 'addEventListener'); annotator.bindDOMListeners(); expect(docListen).to.be.calledWith('selectionchange', sinon.match.func); + expect(annotatedElementListen).to.be.calledWith('touchstart', sinon.match.func); }); }); @@ -581,6 +586,7 @@ describe('lib/annotations/doc/DocAnnotator', () => { stubs.elMock.expects('removeEventListener').withArgs('contextmenu', sinon.match.func).never(); stubs.elMock.expects('removeEventListener').withArgs('mousemove', sinon.match.func).never(); stubs.elMock.expects('removeEventListener').withArgs('dblclick', sinon.match.func).never(); + stubs.elMock.expects('removeEventListener').withArgs('click', sinon.match.func).never(); annotator.unbindDOMListeners(); }); @@ -592,6 +598,7 @@ describe('lib/annotations/doc/DocAnnotator', () => { stubs.elMock.expects('removeEventListener').withArgs('contextmenu', sinon.match.func); stubs.elMock.expects('removeEventListener').withArgs('mousemove', sinon.match.func); stubs.elMock.expects('removeEventListener').withArgs('dblclick', sinon.match.func); + stubs.elMock.expects('removeEventListener').withArgs('click', sinon.match.func); annotator.unbindDOMListeners(); }); @@ -613,10 +620,12 @@ describe('lib/annotations/doc/DocAnnotator', () => { annotator.isMobile = true; annotator.hasTouch = true; const docStopListen = sandbox.spy(document, 'removeEventListener'); + const annotatedElementStopListen = sandbox.spy(annotator.annotatedElement, 'removeEventListener'); annotator.unbindDOMListeners(); expect(docStopListen).to.be.calledWith('selectionchange', sinon.match.func); + expect(annotatedElementStopListen).to.be.calledWith('touchstart', sinon.match.func); }); }); @@ -1348,4 +1357,24 @@ describe('lib/annotations/doc/DocAnnotator', () => { annotator.removeRangyHighlight({ id: 1 }); }); }); + + describe('drawingSelectionHandler()', () => { + it('should use the controller to select with the event', () => { + const drawController = { + handleSelection: sandbox.stub() + }; + annotator.modeControllers = { + [TYPES.draw]: drawController + }; + + const evt = 'event'; + annotator.drawingSelectionHandler(evt); + expect(drawController.handleSelection).to.be.calledWith(evt); + }); + + it('should not error when no modeButtons exist for draw', () => { + annotator.modeButtons = {}; + expect(() => annotator.drawingSelectionHandler('irrelevant')).to.not.throw(); + }); + }); }); diff --git a/src/lib/annotations/doc/__tests__/DocDrawingThread-test.js b/src/lib/annotations/doc/__tests__/DocDrawingThread-test.js index 57a9f1230..0cc5fd2e6 100644 --- a/src/lib/annotations/doc/__tests__/DocDrawingThread-test.js +++ b/src/lib/annotations/doc/__tests__/DocDrawingThread-test.js @@ -4,10 +4,12 @@ import DocDrawingThread from '../DocDrawingThread'; import AnnotationThread from '../../AnnotationThread'; import DrawingPath from '../../drawing/DrawingPath'; import { - DRAW_STATES + DRAW_STATES, + STATES } from '../../annotationConstants'; let docDrawingThread; +let stubs; const sandbox = sinon.sandbox.create(); describe('lib/annotations/doc/DocDrawingThread', () => { @@ -23,6 +25,7 @@ describe('lib/annotations/doc/DocDrawingThread', () => { y: 0, page: docDrawingThread.page }; + stubs = {}; }); afterEach(() => { @@ -33,6 +36,7 @@ describe('lib/annotations/doc/DocDrawingThread', () => { describe('handleMove()', () => { beforeEach(() => { + docDrawingThread.drawingFlag = DRAW_STATES.drawing; docDrawingThread.pageEl = document.querySelector('.page-element'); docDrawingThread.page = docDrawingThread.pageEl.getAttribute('page'); docDrawingThread.pendingPath = { @@ -40,8 +44,6 @@ describe('lib/annotations/doc/DocDrawingThread', () => { isEmpty: sandbox.stub() }; - sandbox.stub(docAnnotatorUtil, 'getPageEl') - .returns(docDrawingThread.pageEl); sandbox.stub(docAnnotatorUtil, 'getBrowserCoordinatesFromLocation') .returns([location.x, location.y]); }); @@ -54,11 +56,27 @@ describe('lib/annotations/doc/DocDrawingThread', () => { }); it("should add a coordinate frame when the state is 'draw'", () => { - docDrawingThread.drawingFlag = DRAW_STATES.drawing; + sandbox.stub(docDrawingThread, 'hasPageChanged').returns(false); docDrawingThread.handleMove(docDrawingThread.location); + expect(docDrawingThread.hasPageChanged).to.be.called; expect(docDrawingThread.pendingPath.addCoordinate).to.be.called; }); + + it('should do nothing when location is empty', () => { + sandbox.stub(docDrawingThread, 'hasPageChanged').returns(false); + + docDrawingThread.handleMove(undefined); + expect(docDrawingThread.hasPageChanged).to.not.be.called; + }); + + it('should only handle page change when the page changes', () => { + sandbox.stub(docDrawingThread, 'hasPageChanged').returns(true); + sandbox.stub(docDrawingThread, 'onPageChange'); + + docDrawingThread.handleMove({page: 1}); + expect(docDrawingThread.onPageChange).to.be.called; + }) }); @@ -86,11 +104,10 @@ describe('lib/annotations/doc/DocDrawingThread', () => { sandbox.stub(docDrawingThread, 'hasPageChanged').returns(true); sandbox.stub(docDrawingThread, 'checkAndHandleScaleUpdate'); sandbox.stub(docDrawingThread, 'onPageChange'); - sandbox.stub(docDrawingThread, 'saveAnnotation'); docDrawingThread.pendingPath = undefined; - docDrawingThread.handleStart(docDrawingThread.location); docDrawingThread.location = {}; + docDrawingThread.handleStart(docDrawingThread.location); expect(docDrawingThread.hasPageChanged).to.be.called; expect(docDrawingThread.onPageChange).to.be.called; @@ -191,6 +208,8 @@ describe('lib/annotations/doc/DocDrawingThread', () => { }); it('should clean up without committing when there are no paths to be saved', () => { + sandbox.stub(docDrawingThread, 'reset'); + sandbox.stub(docDrawingThread, 'emit'); sandbox.stub(docDrawingThread.pathContainer, 'getNumberOfItems').returns({ undoCount: 0, redoCount: 1 @@ -199,6 +218,10 @@ describe('lib/annotations/doc/DocDrawingThread', () => { docDrawingThread.saveAnnotation('draw'); expect(docDrawingThread.pathContainer.getNumberOfItems).to.be.called; expect(AnnotationThread.prototype.saveAnnotation).to.not.be.called; + expect(docDrawingThread.reset).to.be.called; + expect(docDrawingThread.emit).to.be.calledWith('annotationevent', { + type: 'drawcommit' + }); }); it('should clean up and commit in-progress drawings when there are paths to be saved', () => { @@ -271,10 +294,7 @@ describe('lib/annotations/doc/DocDrawingThread', () => { describe('show()', () => { beforeEach(() => { - sandbox.stub(docAnnotatorUtil, 'getPageEl'); - sandbox.stub(docAnnotatorUtil, 'getContext'); - sandbox.stub(docDrawingThread, 'checkAndHandleScaleUpdate'); - sandbox.stub(docDrawingThread, 'setContextStyles'); + sandbox.stub(docDrawingThread, 'selectContext'); sandbox.stub(docDrawingThread, 'draw'); docDrawingThread.pathContainer = { applyToItems: sandbox.stub() @@ -285,14 +305,14 @@ describe('lib/annotations/doc/DocDrawingThread', () => { docDrawingThread.annotatedElement = undefined; docDrawingThread.location = 'loc'; docDrawingThread.show(); - expect(docDrawingThread.checkAndHandleScaleUpdate).to.not.be.called; + expect(docDrawingThread.selectContext).to.not.be.called; }); it('should do nothing when no location is assigned to the DocDrawingThread', () => { docDrawingThread.annotatedElement = 'annotatedEl'; docDrawingThread.location = undefined; docDrawingThread.show(); - expect(docDrawingThread.checkAndHandleScaleUpdate).to.not.be.called; + expect(docDrawingThread.selectContext).to.not.be.called; }); it('should draw the paths in the thread', () => { @@ -301,11 +321,77 @@ describe('lib/annotations/doc/DocDrawingThread', () => { docDrawingThread.state = 'not pending'; docDrawingThread.show() - expect(docAnnotatorUtil.getPageEl).to.be.called; - expect(docAnnotatorUtil.getContext).to.be.called; + expect(docDrawingThread.selectContext).to.be.called; + expect(docDrawingThread.draw).to.be.called; + }); + }); + + describe('selectContext()', () => { + beforeEach(() => { + sandbox.stub(docDrawingThread, 'checkAndHandleScaleUpdate'); + sandbox.stub(docDrawingThread, 'setContextStyles'); + stubs.context = sandbox.stub(docAnnotatorUtil, 'getContext'); + }); + + it('should return the pending drawing context when the state is pending', () => { + docDrawingThread.state = STATES.pending; + docDrawingThread.drawingContext = { + clearRect: sandbox.stub(), + canvas: { + height: 100, + width: 100 + } + }; + + const retValue = docDrawingThread.selectContext(); + expect(docDrawingThread.checkAndHandleScaleUpdate).to.be.called; + expect(docAnnotatorUtil.getContext).to.not.be.called; + expect(retValue).to.deep.equal(docDrawingThread.drawingContext); + }); + + it('should set and return the concrete context when the state is not pending', () => { + const concreteContext = { + clearRect: sandbox.stub(), + canvas: { + height: 100, + width: 100 + } + }; + + stubs.context.returns(concreteContext); + docDrawingThread.state = STATES.idle; + + const retValue = docDrawingThread.selectContext(); expect(docDrawingThread.checkAndHandleScaleUpdate).to.be.called; expect(docDrawingThread.setContextStyles).to.be.called; - expect(docDrawingThread.draw).to.be.called; + expect(docAnnotatorUtil.getContext).to.be.called; + expect(retValue).to.deep.equal(docDrawingThread.concreteContext); + }); + }); + + describe('getRectangularBoundary()', () => { + it('should return null when no thread has not been assigned a location', () => { + docDrawingThread.location = undefined; + + const value = docDrawingThread.getRectangularBoundary(); + expect(value).to.be.null; + }); + + it('should return a starting coordinate along with a height and width', () => { + docDrawingThread.pageEl = 'page'; + docDrawingThread.location = { + dimensions: 'not empty' + }; + + stubs.createLocation = sandbox.stub(annotatorUtil, 'createLocation'); + stubs.getBrowserCoordinates = sandbox.stub(docAnnotatorUtil, 'getBrowserCoordinatesFromLocation'); + stubs.getBrowserCoordinates.onCall(0).returns([5, 5]); + stubs.getBrowserCoordinates.onCall(1).returns([50, 45]); + + const value = docDrawingThread.getRectangularBoundary(); + expect(stubs.createLocation).to.be.called.twice; + expect(stubs.getBrowserCoordinates).to.be.called.twice; + expect(value).to.deep.equal([5, 5, 45, 40]); }); }); }); diff --git a/src/lib/annotations/drawing/DrawingContainer.js b/src/lib/annotations/drawing/DrawingContainer.js index 9c7dc0d4f..965a90398 100644 --- a/src/lib/annotations/drawing/DrawingContainer.js +++ b/src/lib/annotations/drawing/DrawingContainer.js @@ -81,6 +81,33 @@ class DrawingContainer { return this.undoStack.slice(); } + /** + * Get the axis-aligned bounding box for all of the drawing items in the container + * + * @return {Object} The object with the boundaries and the path + */ + getAxisAlignedBoundingBox() { + const items = this.getItems(); + const boundary = { + minX: Infinity, + maxX: -Infinity, + minY: Infinity, + maxY: -Infinity, + paths: [] + }; + + items.forEach((drawingPath) => { + boundary.minX = Math.min(boundary.minX, drawingPath.minX); + boundary.maxX = Math.max(boundary.maxX, drawingPath.maxX); + boundary.minY = Math.min(boundary.minY, drawingPath.minY); + boundary.maxY = Math.max(boundary.maxY, drawingPath.maxY); + boundary.paths.push({ + path: drawingPath.path + }); + }); + return boundary; + } + /** * Apply a function to the items in the container. * diff --git a/src/lib/annotations/drawing/DrawingModeController.js b/src/lib/annotations/drawing/DrawingModeController.js new file mode 100644 index 000000000..1f2a57623 --- /dev/null +++ b/src/lib/annotations/drawing/DrawingModeController.js @@ -0,0 +1,273 @@ +import rbush from 'rbush'; +import AnnotationModeController from '../AnnotationModeController'; +import * as annotatorUtil from '../annotatorUtil'; +import { + TYPES, + SELECTOR_ANNOTATION_BUTTON_DRAW_POST, + SELECTOR_ANNOTATION_BUTTON_DRAW_UNDO, + SELECTOR_ANNOTATION_BUTTON_DRAW_REDO, + DRAW_BORDER_OFFSET +} from '../annotationConstants'; + +class DrawingModeController extends AnnotationModeController { + /* eslint-disable new-cap */ + /** @property {Array} - The array of annotation threads */ + threads = new rbush(); + /* eslint-enable new-cap */ + + /** @property {DrawingThread} - The currently selected DrawingThread */ + selectedThread; + + /** @property {HTMLElement} - The button to commit the pending drawing thread */ + postButtonEl; + + /** @property {HTMLElement} - The button to undo a stroke on the pending drawing thread */ + undoButtonEl; + + /** @property {HTMLElement} - The button to redo a stroke on the pending drawing thread */ + redoButtonEl; + + /** + * Register the annotator and any information associated with the annotator + * + * @inheritdoc + * @public + * @param {Annotator} annotator - The annotator to be associated with the controller + * @return {void} + */ + registerAnnotator(annotator) { + super.registerAnnotator(annotator); + + 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); + } + + /** + * Register a thread that has been assigned a location with the controller + * + * @inheritdoc + * @public + * @param {AnnotationThread} thread - The thread to register with the controller + * @return {void} + */ + registerThread(thread) { + if (!thread || !thread.location) { + return; + } + + this.threads.insert(thread); + } + + /** + * Unregister a previously registered thread that has been assigned a location + * + * @inheritdoc + * @public + * @param {AnnotationThread} thread - The thread to unregister with the controller + * @return {void} + */ + unregisterThread(thread) { + if (!thread || !thread.location) { + return; + } + + this.threads.remove(thread); + } + + /** + * Binds custom event listeners for a thread. + * + * @inheritdoc + * @protected + * @param {AnnotationThread} thread - Thread to bind events to + * @return {void} + */ + bindCustomListenersOnThread(thread) { + if (!thread) { + return; + } + + 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('threaddeleted', () => this.unregisterThread(thread)); + } + + /** + * 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() { + /* eslint-disable require-jsdoc */ + const locationFunction = (event) => this.annotator.getLocationFromEvent(event, TYPES.point); + /* eslint-enable require-jsdoc */ + + // Setup + this.currentThread = this.annotator.createAnnotationThread([], {}, TYPES.draw); + this.bindCustomListenersOnThread(this.currentThread); + + // Get handlers + this.pushElementHandler( + this.annotator.annotatedElement, + ['mousemove', 'touchmove'], + annotatorUtil.eventToLocationHandler(locationFunction, this.currentThread.handleMove) + ); + this.pushElementHandler( + this.annotator.annotatedElement, + ['mousedown', 'touchstart'], + annotatorUtil.eventToLocationHandler(locationFunction, this.currentThread.handleStart) + ); + this.pushElementHandler( + this.annotator.annotatedElement, + ['mouseup', 'touchcancel', 'touchend'], + annotatorUtil.eventToLocationHandler(locationFunction, this.currentThread.handleStop) + ); + this.pushElementHandler(this.postButtonEl, 'click', () => { + this.currentThread.saveAnnotation(TYPES.draw); + this.annotator.toggleAnnotationHandler(TYPES.draw); + }); + this.pushElementHandler(this.undoButtonEl, 'click', this.currentThread.undo); + this.pushElementHandler(this.redoButtonEl, 'click', this.currentThread.redo); + } + + /** + * Handle an annotation event. + * + * @inheritdoc + * @protected + * @param {AnnotationThread} thread - The thread that emitted the event + * @param {Object} data - Extra data related to the annotation event + * @return {void} + */ + handleAnnotationEvent(thread, data = {}) { + switch (data.type) { + case 'locationassigned': + // Register the thread to the threadmap when a starting location is assigned. Should only occur once. + this.annotator.addThreadToMap(thread); + break; + case 'drawcommit': + // Upon a commit, remove the listeners on the thread. + // Adding the thread to the Rbush only happens upon a successful save + thread.removeAllListeners('annotationevent'); + break; + case 'pagechanged': + // On page change, 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(TYPES.draw); + this.currentThread.handleStart(data.location); + break; + case 'availableactions': + this.updateUndoRedoButtonEls(data.undo, data.redo); + break; + default: + } + } + + /** + * Find the selected drawing threads given a pointer event. Randomly picks one if multiple drawings overlap + * + * @protected + * @param {Event} event - The event object containing the pointer information + * @return {void} + */ + handleSelection(event) { + if (!event) { + return; + } + + const location = this.annotator.getLocationFromEvent(event, TYPES.point); + if (!location) { + return; + } + + const eventBoundary = { + minX: +location.x - DRAW_BORDER_OFFSET, + minY: +location.y - DRAW_BORDER_OFFSET, + maxX: +location.x + DRAW_BORDER_OFFSET, + maxY: +location.y + DRAW_BORDER_OFFSET + }; + + // Get the threads that correspond to the point that was clicked on + const intersectingThreads = this.threads + .search(eventBoundary) + .filter((drawingThread) => drawingThread.location.page === location.page); + + // Clear boundary on previously selected thread + if (this.selectedThread) { + const canvas = this.selectedThread.drawingContext.canvas; + this.selectedThread.drawingContext.clearRect(0, 0, canvas.width, canvas.height); + } + + // Selected a region with no drawing threads, remove the reference to the previously selected thread + if (intersectingThreads.length === 0) { + this.selectedThread = undefined; + return; + } + + // Randomly select a thread in case there are multiple + const index = Math.floor(Math.random() * intersectingThreads.length); + const selected = intersectingThreads[index]; + this.select(selected); + } + + /** + * Select the indicated drawing thread. Deletes a drawing thread upon the second consecutive selection + * + * @private + * @param {DrawingThread} selectedDrawingThread - The drawing thread to select + * @return {void} + */ + select(selectedDrawingThread) { + if (this.selectedThread && this.selectedThread === selectedDrawingThread) { + // Selected the same thread twice, delete the thread + const toDelete = this.selectedThread; + toDelete.deleteThread(); + + // Redraw any threads that the deleted thread could have been covering + const toRedraw = this.threads.search(toDelete); + toRedraw.forEach((drawingThread) => drawingThread.show()); + this.selectedThread = undefined; + } else { + // Selected the thread for the first time, select the thread (TODO @minhnguyen: show UI on select) + selectedDrawingThread.drawBoundary(); + this.selectedThread = selectedDrawingThread; + } + } + + /** + * Toggle the undo and redo buttons based on the number of actions available + * + * @private + * @param {number} undoCount - The number of objects that can be undone + * @param {number} redoCount - The number of objects that can be redone + * @return {void} + */ + updateUndoRedoButtonEls(undoCount, redoCount) { + if (this.undoButtonEl) { + if (undoCount === 1) { + annotatorUtil.enableElement(this.undoButtonEl); + } else if (undoCount === 0) { + annotatorUtil.disableElement(this.undoButtonEl); + } + } + + if (this.redoButtonEl) { + if (redoCount === 1) { + annotatorUtil.enableElement(this.redoButtonEl); + } else if (redoCount === 0) { + annotatorUtil.disableElement(this.redoButtonEl); + } + } + } +} + +export default DrawingModeController; diff --git a/src/lib/annotations/drawing/DrawingPath.js b/src/lib/annotations/drawing/DrawingPath.js index c83d38d37..6af7eb396 100644 --- a/src/lib/annotations/drawing/DrawingPath.js +++ b/src/lib/annotations/drawing/DrawingPath.js @@ -30,17 +30,24 @@ class DrawingPath { */ constructor(drawingPathData) { if (drawingPathData) { - this.path = drawingPathData.path.map((num) => createLocation(parseFloat(num.x), parseFloat(num.y))); - this.maxX = drawingPathData.maxX; - this.minX = drawingPathData.minX; - this.maxY = drawingPathData.maxY; - this.minY = drawingPathData.minY; + this.path = drawingPathData.path.map((num) => { + const x = +num.x; + const y = +num.y; + + this.minX = Math.min(this.minX, x); + this.maxX = Math.max(this.maxX, x); + this.minY = Math.min(this.minY, y); + this.maxY = Math.max(this.maxY, y); + + return createLocation(x, y); + }); } } /** * Add position to coordinates and update the bounding box * + * @public * @param {Location} documentLocation - Original document location coordinate to be part of the drawing path * @param {Location} [browserLocation] - Optional browser position to be saved to browserPath * @return {void} @@ -80,6 +87,7 @@ class DrawingPath { /** * Determine if any coordinates are contained in the DrawingPath * + * @public * @return {boolean} Whether or not any coordinates have been recorded */ isEmpty() { @@ -89,6 +97,7 @@ class DrawingPath { /** * Draw the recorded browser coordinates onto a CanvasContext. Requires a browser path to have been generated. * + * @public * @param {CanvasContext} drawingContext - Context to draw the recorded path on * @return {void} */ @@ -121,6 +130,7 @@ class DrawingPath { /** * Generate a browser location path that can be drawn on a canvas document from the stored path information * + * @public * @param {Function} coordinateToBrowserCoordinate - A function that takes a document location and returns * the corresponding browser location * @return {void} @@ -135,14 +145,27 @@ class DrawingPath { } /** - * Extract path information from the drawing path + * Extract the path information from two paths by merging their paths and getting the bounding rectangle * - * @param {DrawingPath} drawingPath - The drawingPath to extract information from - * @return {void} + * @public + * @param {Object} accumulator - A drawingPath or accumulator to extract information from + * @param {DrawingPath} pathB - Another drawingPath to extract information from + * @return {Object} A bounding rectangle and the stroke paths it contains */ - static extractDrawingInfo(drawingPath) { + static extractDrawingInfo(accumulator, pathB) { + let paths = accumulator.paths; + if (paths) { + paths.push(pathB.path); + } else { + paths = [accumulator.path, pathB.path]; + } + return { - path: drawingPath.path + minX: Math.min(accumulator.minX, pathB.minX), + maxX: Math.max(accumulator.maxX, pathB.maxX), + minY: Math.min(accumulator.minY, pathB.minY), + maxY: Math.max(accumulator.maxY, pathB.maxY), + paths }; } } diff --git a/src/lib/annotations/drawing/DrawingThread.js b/src/lib/annotations/drawing/DrawingThread.js index 01b0939b9..58b005206 100644 --- a/src/lib/annotations/drawing/DrawingThread.js +++ b/src/lib/annotations/drawing/DrawingThread.js @@ -1,9 +1,13 @@ import AnnotationThread from '../AnnotationThread'; import DrawingPath from './DrawingPath'; import DrawingContainer from './DrawingContainer'; -import { DRAW_STATES, DRAW_RENDER_THRESHOLD } from '../annotationConstants'; - -const BASE_LINE_WIDTH = 3; +import { + DRAW_STATES, + DRAW_RENDER_THRESHOLD, + DRAW_BASE_LINE_WIDTH, + DRAW_BORDER_OFFSET, + DRAW_DASHED_SPACING +} from '../annotationConstants'; class DrawingThread extends AnnotationThread { /** @property {number} - Drawing state */ @@ -15,9 +19,12 @@ class DrawingThread extends AnnotationThread { /** @property {DrawingPath} - The path being drawn but not yet finalized */ pendingPath; - /** @property {CanvasContext} - The context to be drawn on */ + /** @property {CanvasContext} - The context to draw in-progress drawings on */ drawingContext; + /** @property {CanvasContext} - The context to draw saved drawings on on */ + concreteContext; + /** @property {number} - Timestamp of the last render */ lastRenderTimestamp; @@ -27,6 +34,18 @@ class DrawingThread extends AnnotationThread { /** @property {number} - The scale factor that the drawing thread was last rendered at */ lastScaleFactor; + /** @property {number} - The minimum X coordinate occupied by the contained drawing paths */ + minX; + + /** @property {number} - The minimum Y coordinate occupied by the contained drawing paths */ + minY; + + /** @property {number} - The maximum X coordinate occupied by the contained drawing paths */ + maxX; + + /** @property {number} - The maximum Y coordinate occupied by the contained drawing paths */ + maxY; + /** * [constructor] * @@ -41,10 +60,18 @@ class DrawingThread extends AnnotationThread { this.handleStart = this.handleStart.bind(this); this.handleMove = this.handleMove.bind(this); this.handleStop = this.handleStop.bind(this); + this.undo = this.undo.bind(this); + this.redo = this.redo.bind(this); // Recreate stored paths - if (data && data.location && data.location.drawingPaths instanceof Array) { - data.location.drawingPaths.forEach((drawingPathData) => { + if (this.location && this.location.drawingPaths) { + const boundaryData = this.location.drawingPaths; + this.setBoundary(); + this.emit('annotationevent', { + type: 'locationassigned' + }); + + boundaryData.paths.forEach((drawingPathData) => { const pathInstance = new DrawingPath(drawingPathData); this.pathContainer.insert(pathInstance); }); @@ -77,6 +104,7 @@ class DrawingThread extends AnnotationThread { /** * Handle a pointer movement * + * @public * @param {Object} location - The location information of the pointer * @return {void} */ @@ -85,6 +113,7 @@ class DrawingThread extends AnnotationThread { /** * Start a drawing stroke * * + * @public * @param {Object} location - The location information of the pointer * @return {void} */ @@ -93,21 +122,42 @@ class DrawingThread extends AnnotationThread { /** * End a drawing stroke * + * @public * @param {Object} location - The location information of the pointer * @return {void} */ handleStop(location) {} /* eslint-disable no-unused-vars */ - //-------------------------------------------------------------------------- - // Protected - //-------------------------------------------------------------------------- + /** + * Delete a saved drawing thread + * + * @public + * @return {void} + */ + deleteThread() { + this.annotations.forEach(this.deleteAnnotationWithID); + + // Calculate the bounding rectangle + const [x, y, width, height] = this.getRectangularBoundary(); + + // Clear the drawn thread and destroy it + this.concreteContext.clearRect( + x - DRAW_BORDER_OFFSET, + y + DRAW_BORDER_OFFSET, + width + DRAW_BORDER_OFFSET * 2, + height - DRAW_BORDER_OFFSET * 2 + ); + + // Notifies that the thread was destroyed so that observers can react accordingly + this.destroy(); + } /** * Set the drawing styles for a provided context. Sets the context of the in-progress context if * no other context is provided. * - * @protected + * @public * @param {Object} config - The configuration Object * @param {number} config.scale - The document scale * @param {string} config.color - The brush color @@ -125,32 +175,14 @@ class DrawingThread extends AnnotationThread { contextToSet.lineCap = 'round'; contextToSet.lineJoin = 'round'; contextToSet.strokeStyle = color || 'black'; - contextToSet.lineWidth = BASE_LINE_WIDTH * (scale || 1); - } - - /** - * Draw the pending path onto the DrawingThread CanvasContext. Should be used - * in conjunction with requestAnimationFrame. Does nothing when there is drawingContext set. - * - * @protected - * @param {number} timestamp - The time when the function was called; - * @return {void} - */ - render(timestamp) { - if (this.drawingFlag === DRAW_STATES.drawing) { - this.lastAnimationRequestId = window.requestAnimationFrame(this.render); - } - - const elapsed = timestamp - (this.lastRenderTimestamp || 0); - if (elapsed >= DRAW_RENDER_THRESHOLD && this.draw(this.drawingContext, true)) { - this.lastRenderTimestamp = timestamp; - } + contextToSet.lineWidth = DRAW_BASE_LINE_WIDTH * (scale || 1); } /** * Overturns the last drawing stroke if it exists. Emits the number of undo and redo * actions available if an undo was executed. * + * @public * @return {void} */ undo() { @@ -165,6 +197,7 @@ class DrawingThread extends AnnotationThread { * Replays the last undone drawing stroke if it exists. Emits the number of undo and redo * actions available if a redraw was executed. * + * @public * @return {void} * */ @@ -180,23 +213,6 @@ class DrawingThread extends AnnotationThread { // Protected //-------------------------------------------------------------------------- - /** - * Create an annotation data object to pass to annotation service. - * - * @inheritdoc - * @private - * @param {string} type - Type of annotation - * @param {string} text - Annotation text - * @return {Object} Annotation data - */ - createAnnotationData(type, text) { - const annotation = super.createAnnotationData(type, text); - const paths = this.pathContainer.getItems(); - - annotation.location.drawingPaths = paths.map(DrawingPath.extractDrawingInfo); - return annotation; - } - /** * Draws the paths in the thread onto the given context. * @@ -243,6 +259,95 @@ class DrawingThread extends AnnotationThread { redo: availableActions.redoCount }); } + + /** + * Draw the boundary on a drawing thread that has been saved + * + * @protected + * @return {void} + */ + drawBoundary() { + const [x, y, width, height] = this.getRectangularBoundary(); + + // Save context style + this.drawingContext.save(); + + this.drawingContext.beginPath(); + this.drawingContext.lineWidth = this.drawingContext.lineWidth / 2; + this.drawingContext.setLineDash([DRAW_DASHED_SPACING, DRAW_DASHED_SPACING * 2]); + this.drawingContext.rect(x, y, width, height); + this.drawingContext.stroke(); + + // Restore context style + this.drawingContext.restore(); + } + + /** + * Draw the pending path onto the DrawingThread CanvasContext. Should be used + * in conjunction with requestAnimationFrame. Does nothing when there is drawingContext set. + * + * @protected + * @param {number} timestamp - The time when the function was called; + * @return {void} + */ + render(timestamp = window.performance.now()) { + if (this.drawingFlag === DRAW_STATES.drawing) { + this.lastAnimationRequestId = window.requestAnimationFrame(this.render); + } + + const elapsed = timestamp - (this.lastRenderTimestamp || 0); + if (elapsed >= DRAW_RENDER_THRESHOLD) { + this.draw(this.drawingContext, true); + this.lastRenderTimestamp = timestamp; + } + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Create an annotation data object to pass to annotation service. Sets the bounding boundary for the thread. + * + * @inheritdoc + * @private + * @param {string} type - Type of annotation + * @param {string} text - Annotation text + * @return {Object} Annotation data + */ + createAnnotationData(type, text) { + const annotation = super.createAnnotationData(type, text); + const boundaryData = this.pathContainer.getAxisAlignedBoundingBox(); + + annotation.location.drawingPaths = boundaryData; + return annotation; + } + /** + * Set the coordinates of the rectangular boundary on the saved thread + * + * @private + * @return {void} + */ + setBoundary() { + if (!this.location || !this.location.drawingPaths) { + return; + } + + const boundaryData = this.location.drawingPaths; + this.minX = boundaryData.minX; + this.maxX = boundaryData.maxX; + this.minY = boundaryData.minY; + this.maxY = boundaryData.maxY; + } + + /** + * Get the rectangular boundary in the form of [x, y, width, height] where the coordinate indicates the upper left + * point of the rectangular boundary + * + * @private + * @return {void} + */ + getRectangularBoundary() {} } export default DrawingThread; diff --git a/src/lib/annotations/drawing/__tests__/DrawingContainer-test.js b/src/lib/annotations/drawing/__tests__/DrawingContainer-test.js index e8484bd30..dd297ab64 100644 --- a/src/lib/annotations/drawing/__tests__/DrawingContainer-test.js +++ b/src/lib/annotations/drawing/__tests__/DrawingContainer-test.js @@ -115,4 +115,56 @@ describe('lib/annotations/drawing/DrawingContainer', () => { expect(counter.count).to.equal(drawingContainer.undoStack.length + drawingContainer.redoStack.length); }); }); + + describe('getAxisAlignedBoundingBox()', () => { + let getItems; + beforeEach(() => { + getItems = sandbox.stub(drawingContainer, 'getItems'); + }); + + it('should return a boundary of infinity when no items are stored', () => { + getItems.returns([]); + + const returnValue = drawingContainer.getAxisAlignedBoundingBox(); + expect(getItems).to.be.called; + expect(returnValue).to.deep.equal({ + minX: Infinity, + maxX: -Infinity, + minY: Infinity, + maxY: -Infinity, + paths: [] + }); + }); + + it('should get the correct boundary based on the items contained', () => { + const path1 = { + minX: 5, + minY: 6, + maxX: 8, + maxY: 9, + path: [1,2,3,4] + }; + const path2 = { + minX: 3, + minY: 7, + maxX: 14, + maxY: 8, + path: [1,2,3] + }; + getItems.returns([path1, path2]); + + const returnValue = drawingContainer.getAxisAlignedBoundingBox(); + expect(getItems).to.be.called; + expect(returnValue).to.deep.equal({ + minX: path2.minX, + maxX: path2.maxX, + minY: path1.minY, + maxY: path1.maxY, + paths: [ + { path: path1.path }, + { path: path2.path } + ] + }); + }); + }); }); diff --git a/src/lib/annotations/drawing/__tests__/DrawingModeController-test.js b/src/lib/annotations/drawing/__tests__/DrawingModeController-test.js new file mode 100644 index 000000000..886f447f7 --- /dev/null +++ b/src/lib/annotations/drawing/__tests__/DrawingModeController-test.js @@ -0,0 +1,285 @@ +import AnnotationModeController from '../../AnnotationModeController'; +import DrawingModeController from '../DrawingModeController'; +import * as annotatorUtil from '../../annotatorUtil'; + +let drawingModeController; +let stubs; +const sandbox = sinon.sandbox.create(); + +describe('lib/annotations/drawing/DrawingModeController', () => { + beforeEach(() => { + drawingModeController = new DrawingModeController(); + stubs = {}; + }); + + afterEach(() => { + sandbox.verifyAndRestore(); + stubs = null; + drawingModeController = null; + }); + + describe('registerAnnotator()', () => { + it('should use the annotator to get button elements', () => { + const annotator = { + getAnnotateButton: sandbox.stub() + }; + annotator.getAnnotateButton.onCall(0).returns('postButton'); + annotator.getAnnotateButton.onCall(1).returns('undoButton'); + annotator.getAnnotateButton.onCall(2).returns('redoButton'); + + expect(drawingModeController.postButtonEl).to.be.undefined; + expect(drawingModeController.undoButtonEl).to.be.undefined; + expect(drawingModeController.redoButtonEl).to.be.undefined; + + drawingModeController.registerAnnotator(annotator); + expect(drawingModeController.postButtonEl).to.equal('postButton'); + expect(drawingModeController.redoButtonEl).to.equal('redoButton'); + expect(drawingModeController.undoButtonEl).to.equal('undoButton'); + }); + }); + + describe('registerThread()', () => { + it('should internally keep track of the registered thread', () => { + const thread = { + minX: 10, + minY: 10, + maxX: 20, + maxY: 20, + location: { + page: 1 + }, + info: 'I am a thread' + } + + expect(drawingModeController.threads.search(thread)).to.deep.equal([]); + + drawingModeController.registerThread(thread); + expect(drawingModeController.threads.search(thread).includes(thread)).to.be.truthy; + }); + }); + + describe('unregisterThread()', () => { + it('should internally keep track of the registered thread', () => { + const thread = { + minX: 10, + minY: 10, + maxX: 20, + maxY: 20, + location: { + page: 1 + }, + info: 'I am a thread' + } + + drawingModeController.threads.insert(thread); + expect(drawingModeController.threads.search(thread).includes(thread)).to.be.truthy; + + drawingModeController.unregisterThread(thread); + expect(drawingModeController.threads.search(thread)).to.deep.equal([]); + }); + }); + + + describe('bindCustomListenersOnThread()', () => { + beforeEach(() => { + Object.defineProperty(AnnotationModeController.prototype, 'bindCustomListenersOnThread', { value: sandbox.stub() }) + stubs.super = AnnotationModeController.prototype.bindCustomListenersOnThread; + }); + + it('should do nothing when the input is empty', () => { + drawingModeController.bindCustomListenersOnThread(undefined); + expect(stubs.super).to.not.be.called; + }); + + it('should bind custom listeners on thread', () => { + const thread = { + addListener: sandbox.stub() + }; + + drawingModeController.bindCustomListenersOnThread(thread); + expect(stubs.super).to.be.called; + expect(thread.addListener).to.be.called.twice; + }); + }); + + describe('setupHandlers()', () => { + beforeEach(() => { + drawingModeController.annotator = { + createAnnotationThread: sandbox.stub(), + getLocationFromEvent: sandbox.stub(), + annotatedElement: {} + }; + stubs.createThread = drawingModeController.annotator.createAnnotationThread; + stubs.getLocation = drawingModeController.annotator.getLocationFromEvent; + stubs.bindCustomListenersOnThread = sandbox.stub(drawingModeController, 'bindCustomListenersOnThread'); + + stubs.createThread.returns({ + saveAnnotation: () => {}, + undo: () => {}, + redo: () => {}, + handleMove: () => {}, + handleStart: () => {}, + handleStop: () => {} + }); + }); + + it('should successfully contain draw mode handlers if undo and redo buttons do not exist', () => { + drawingModeController.postButtonEl = 'not undefined'; + drawingModeController.undoButtonEl = undefined; + drawingModeController.redoButtonEl = undefined; + + drawingModeController.setupHandlers(); + expect(stubs.createThread).to.be.called; + expect(stubs.bindCustomListenersOnThread).to.be.called; + expect(drawingModeController.handlers.length).to.equal(4); + }); + + it('should successfully contain draw mode handlers if undo and redo buttons exist', () => { + drawingModeController.postButtonEl = 'not undefined'; + drawingModeController.undoButtonEl = 'also not undefined'; + drawingModeController.redoButtonEl = 'additionally not undefined'; + + drawingModeController.setupHandlers(); + expect(stubs.createThread).to.be.called; + expect(stubs.bindCustomListenersOnThread).to.be.called; + expect(drawingModeController.handlers.length).to.equal(6); + }); + }); + + describe('handleAnnotationEvent()', () => { + it('should add thread to map on locationassigned', () => { + const thread = 'obj'; + drawingModeController.annotator = { + addThreadToMap: sandbox.stub() + }; + + drawingModeController.handleAnnotationEvent(thread, { + type: 'locationassigned' + }); + expect(drawingModeController.annotator.addThreadToMap).to.be.called; + }); + + it('should remove annotationevent listeners from the thread on drawcommit', () => { + const thread = { + removeAllListeners: sandbox.stub() + }; + + drawingModeController.handleAnnotationEvent(thread, { + type: 'drawcommit' + }); + expect(thread.removeAllListeners).to.be.calledWith('annotationevent'); + }); + + it('should start a new thread on pagechanged', () => { + const thread1 = { + saveAnnotation: sandbox.stub() + }; + const thread2 = { + handleStart: sandbox.stub() + }; + const data = { + type: 'pagechanged', + location: 'not empty' + }; + sandbox.stub(drawingModeController, 'unbindModeListeners'); + sandbox.stub(drawingModeController, 'bindModeListeners', () => { + drawingModeController.currentThread = thread2; + }); + + drawingModeController.handleAnnotationEvent(thread1, data); + expect(thread1.saveAnnotation).to.be.called; + expect(drawingModeController.unbindModeListeners).to.be.called; + expect(drawingModeController.bindModeListeners).to.be.called; + expect(thread2.handleStart).to.be.calledWith(data.location); + }); + + it('should update undo and redo buttons on availableactions', () => { + const thread = 'thread'; + sandbox.stub(drawingModeController, 'updateUndoRedoButtonEls'); + + drawingModeController.handleAnnotationEvent(thread, { + type: 'availableactions', + undo: 1, + redo: 2 + }); + expect(drawingModeController.updateUndoRedoButtonEls).to.be.calledWith(1, 2); + }); + }); + + describe('handleSelection()', () => { + beforeEach(() => { + drawingModeController.annotator = { + getLocationFromEvent: sandbox.stub() + } + stubs.getLoc = drawingModeController.annotator.getLocationFromEvent; + }); + + it('should do nothing with an empty event', () => { + drawingModeController.handleSelection(); + expect(stubs.getLoc).to.not.be.called; + }) + + it('should call select on an thread found in the data store', () => { + stubs.select = sandbox.stub(drawingModeController, 'select'); + stubs.getLoc.returns({ + x: 5, + y: 5 + }); + + const filteredObject = 'a'; + const filterObjects = { + filter: sandbox.stub().returns([filteredObject]) + }; + drawingModeController.threads = { + search: sandbox.stub().returns(filterObjects) + }; + + drawingModeController.handleSelection('event'); + expect(drawingModeController.threads.search).to.be.called; + expect(filterObjects.filter).to.be.called; + expect(stubs.select).to.be.calledWith(filteredObject); + }); + }); + + describe('select()', () => { + it('should draw the boundary', () => { + const thread = { + drawBoundary: sandbox.stub() + } + + expect(drawingModeController.selectedThread).to.not.deep.equal(thread); + drawingModeController.select(thread); + expect(thread.drawBoundary).to.be.called; + expect(drawingModeController.selectedThread).to.deep.equal(thread); + }); + }); + + describe('updateUndoRedoButtonEls()', () => { + beforeEach(() => { + drawingModeController.undoButtonEl = 'undo'; + drawingModeController.redoButtonEl = 'redo'; + stubs.enable = sandbox.stub(annotatorUtil, 'enableElement'); + stubs.disable = sandbox.stub(annotatorUtil, 'disableElement'); + }); + + it('should disable both when the counts are 0', () => { + drawingModeController.updateUndoRedoButtonEls(0, 0); + expect(stubs.disable).be.calledWith(drawingModeController.undoButtonEl); + expect(stubs.disable).be.calledWith(drawingModeController.redoButtonEl); + expect(stubs.enable).to.not.be.called; + }); + + it('should enable both when the counts are 1', () => { + drawingModeController.updateUndoRedoButtonEls(1, 1); + expect(stubs.enable).be.calledWith(drawingModeController.undoButtonEl); + expect(stubs.enable).be.calledWith(drawingModeController.redoButtonEl); + expect(stubs.disable).to.not.be.called; + }); + + it('should enable undo and do nothing for redo', () => { + drawingModeController.updateUndoRedoButtonEls(1, 2); + expect(stubs.enable).be.calledWith(drawingModeController.undoButtonEl).once; + expect(stubs.disable).to.not.be.called; + }); + }); +}); diff --git a/src/lib/annotations/drawing/__tests__/DrawingPath-test.js b/src/lib/annotations/drawing/__tests__/DrawingPath-test.js index 079bc07b1..10838713d 100644 --- a/src/lib/annotations/drawing/__tests__/DrawingPath-test.js +++ b/src/lib/annotations/drawing/__tests__/DrawingPath-test.js @@ -174,14 +174,52 @@ describe('lib/annotations/drawing/DrawingPath', () => { }); describe('extractDrawingInfo()', () => { - it('should extract the path attribute from an object', () => { - const drawingObj = { - path: 'pathHere', - extra: 'extraAttribute' - } - const result = DrawingPath.extractDrawingInfo(drawingObj); - expect(result).to.not.deep.equal(drawingObj); - expect(result.path).to.equal(drawingObj.path); + it('should start an accumulator if both objects are drawingPaths', () => { + const drawingObjA = { + path: 'pathHereA', + minX: 5, + maxX: 6, + minY: 7, + maxY: 8, + }; + const drawingObjB = { + path: 'pathHereB', + minX: 9, + maxX: 10, + minY: 11, + maxY: 12, + }; + + const result = DrawingPath.extractDrawingInfo(drawingObjA, drawingObjB); + expect(result.minX).to.equal(drawingObjA.minX); + expect(result.minY).to.equal(drawingObjA.minY); + expect(result.maxX).to.equal(drawingObjB.maxX); + expect(result.maxY).to.equal(drawingObjB.maxY); + expect(result.paths).to.deep.equal([drawingObjA.path, drawingObjB.path]); }); }); + + it('should add a path to the accumulator', () => { + const acc = { + paths: ['pathA', 'pathB'], + minX: 5, + maxX: 11, + minY: 6, + maxY: 12 + }; + const drawingObjC = { + path: 'pathC', + minX: 3, + maxX: 10, + minY: 5, + maxY: 11 + }; + + const result = DrawingPath.extractDrawingInfo(acc, drawingObjC); + expect(result.minX).to.equal(drawingObjC.minX); + expect(result.minY).to.equal(drawingObjC.minY); + expect(result.maxX).to.equal(acc.maxX); + expect(result.maxY).to.equal(acc.maxY); + expect(result.paths).to.deep.equal(['pathA', 'pathB', 'pathC']); + }); }); diff --git a/src/lib/annotations/drawing/__tests__/DrawingThread-test.js b/src/lib/annotations/drawing/__tests__/DrawingThread-test.js index 7d8fd80aa..6b5f1f1ff 100644 --- a/src/lib/annotations/drawing/__tests__/DrawingThread-test.js +++ b/src/lib/annotations/drawing/__tests__/DrawingThread-test.js @@ -62,6 +62,25 @@ describe('lib/annotations/drawing/DrawingThread', () => { }) }); + describe('deleteThread()', () => { + it('should delete all attached annotations, clear the drawn rectangle, and call destroy', () => { + sandbox.stub(drawingThread, 'getRectangularBoundary').returns(['a', 'b', 'c', 'd']); + sandbox.stub(drawingThread, 'destroy'); + drawingThread.concreteContext = { + clearRect: sandbox.stub() + }; + drawingThread.annotations = { + forEach: sandbox.stub() + }; + + drawingThread.deleteThread(); + expect(drawingThread.getRectangularBoundary).to.be.called; + expect(drawingThread.concreteContext.clearRect).to.be.called; + expect(drawingThread.annotations.forEach).to.be.called; + expect(drawingThread.destroy).to.be.called; + }); + }); + describe('setContextStyles()', () => { it('should set configurable context properties', () => { drawingThread.drawingContext = { @@ -111,10 +130,7 @@ describe('lib/annotations/drawing/DrawingThread', () => { describe('createAnnotationData()', () => { it('should create a valid annotation data object', () => { const pathStr = 'path'; - const path = { - map: sandbox.stub().returns(pathStr) - }; - sandbox.stub(drawingThread.pathContainer, 'getItems').returns(path); + sandbox.stub(drawingThread.pathContainer, 'getAxisAlignedBoundingBox').returns(pathStr); drawingThread.annotationService = { user: { id: '1' } }; @@ -122,8 +138,7 @@ describe('lib/annotations/drawing/DrawingThread', () => { const placeholder = "String here so string doesn't get fined"; const annotationData = drawingThread.createAnnotationData('draw', placeholder); - expect(drawingThread.pathContainer.getItems).to.be.called; - expect(path.map).to.be.called; + expect(drawingThread.pathContainer.getAxisAlignedBoundingBox).to.be.called; expect(annotationData.fileVersionId).to.equal(drawingThread.fileVersionId); expect(annotationData.threadID).to.equal(drawingThread.threadID); expect(annotationData.user.id).to.equal('1'); @@ -251,4 +266,57 @@ describe('lib/annotations/drawing/DrawingThread', () => { drawingThread.emitAvailableActions(); }); }); + + describe('drawBoundary()', () => { + it('should draw the boundary of the saved path', () => { + sandbox.stub(drawingThread, 'getRectangularBoundary'); + drawingThread.drawingContext = { + save: sandbox.stub(), + beginPath: sandbox.stub(), + setLineDash: sandbox.stub(), + rect: sandbox.stub(), + stroke: sandbox.stub(), + restore: sandbox.stub() + }; + drawingThread.getRectangularBoundary.returns([1,2,5,6]); + + drawingThread.drawBoundary(); + expect(drawingThread.getRectangularBoundary).to.be.called; + expect(drawingThread.drawingContext.save).to.be.called; + expect(drawingThread.drawingContext.beginPath).to.be.called; + expect(drawingThread.drawingContext.setLineDash).to.be.called; + expect(drawingThread.drawingContext.rect).to.be.called; + expect(drawingThread.drawingContext.stroke).to.be.called; + expect(drawingThread.drawingContext.restore).to.be.called; + }) + }); + + describe('setBoundary()', () => { + it('should do nothing when no drawingPaths have been saved', () => { + drawingThread.location = {}; + + drawingThread.setBoundary(); + expect(drawingThread.minX).to.be.undefined; + expect(drawingThread.maxX).to.be.undefined; + expect(drawingThread.minY).to.be.undefined; + expect(drawingThread.maxY).to.be.undefined; + }); + + it('should set the boundary when the location has been assigned', () => { + drawingThread.location = { + drawingPaths: { + minX: 5, + minY: 6, + maxX: 7, + maxY: 8 + } + }; + + drawingThread.setBoundary(); + expect(drawingThread.minX).to.equal(drawingThread.location.drawingPaths.minX); + expect(drawingThread.maxX).to.equal(drawingThread.location.drawingPaths.maxX); + expect(drawingThread.minY).to.equal(drawingThread.location.drawingPaths.minY); + expect(drawingThread.maxY).to.equal(drawingThread.location.drawingPaths.maxY); + }); + }); });