diff --git a/src/lib/_boxui.scss b/src/lib/_boxui.scss index 3b11a81eb..d20fc4790 100644 --- a/src/lib/_boxui.scss +++ b/src/lib/_boxui.scss @@ -83,6 +83,19 @@ } } +.bp-btn-plain { + background: transparent; + box-shadow: none; + outline: none; + + &.is-disabled { + pointer-events: none; + color: #666; + opacity: .4; + background-color: $haze; + } +} + .bp-btn { -webkit-appearance: none; background-color: $white; @@ -150,14 +163,6 @@ } } -.bp-btn-plain, -.bp-btn-plain:hover, -.bp-btn-plain:active, -.bp-btn-plain:focus { - background: transparent; - box-shadow: none; - outline: none; -} //------------------------------------------------------------------------------ // Forms diff --git a/src/lib/annotations/Annotator.js b/src/lib/annotations/Annotator.js index de570d4a8..582bfe187 100644 --- a/src/lib/annotations/Annotator.js +++ b/src/lib/annotations/Annotator.js @@ -17,6 +17,8 @@ import { SELECTOR_ANNOTATION_BUTTON_DRAW_CANCEL, SELECTOR_ANNOTATION_BUTTON_DRAW_ENTER, SELECTOR_ANNOTATION_BUTTON_DRAW_POST, + SELECTOR_ANNOTATION_BUTTON_DRAW_UNDO, + SELECTOR_ANNOTATION_BUTTON_DRAW_REDO, TYPES } from './annotationConstants'; @@ -385,13 +387,18 @@ class Annotator extends EventEmitter { if (mode === TYPES.draw) { const drawEnterEl = buttonEl.querySelector(SELECTOR_ANNOTATION_BUTTON_DRAW_ENTER); - annotatorUtil.showElement(drawEnterEl); - const drawCancelEl = buttonEl.querySelector(SELECTOR_ANNOTATION_BUTTON_DRAW_CANCEL); - annotatorUtil.hideElement(drawCancelEl); - 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); + + annotatorUtil.showElement(drawEnterEl); + annotatorUtil.hideElement(drawCancelEl); annotatorUtil.hideElement(postButtonEl); + annotatorUtil.hideElement(undoButtonEl); + annotatorUtil.hideElement(redoButtonEl); + annotatorUtil.disableElement(undoButtonEl); + annotatorUtil.disableElement(redoButtonEl); } } @@ -414,13 +421,16 @@ class Annotator extends EventEmitter { if (mode === TYPES.draw) { const drawEnterEl = buttonEl.querySelector(SELECTOR_ANNOTATION_BUTTON_DRAW_ENTER); - annotatorUtil.hideElement(drawEnterEl); - const drawCancelEl = buttonEl.querySelector(SELECTOR_ANNOTATION_BUTTON_DRAW_CANCEL); - annotatorUtil.showElement(drawCancelEl); - 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); + + annotatorUtil.hideElement(drawEnterEl); + annotatorUtil.showElement(drawCancelEl); annotatorUtil.showElement(postButtonEl); + annotatorUtil.showElement(undoButtonEl); + annotatorUtil.showElement(redoButtonEl); } } @@ -657,6 +667,7 @@ class Annotator extends EventEmitter { unbindCustomListenersOnThread(thread) { thread.removeAllListeners('threaddeleted'); thread.removeAllListeners('threadcleanup'); + thread.removeAllListeners('annotationevent'); } /** @@ -670,16 +681,18 @@ class Annotator extends EventEmitter { const handlers = []; if (mode === TYPES.point) { - handlers.push({ - type: 'mousedown', - func: this.pointClickHandler, - eventObj: this.annotatedElement - }); - handlers.push({ - type: 'touchstart', - func: this.pointClickHandler, - eventObj: this.annotatedElement - }); + handlers.push( + { + type: 'mousedown', + func: this.pointClickHandler, + eventObj: this.annotatedElement + }, + { + type: 'touchstart', + func: this.pointClickHandler, + eventObj: this.annotatedElement + } + ); } else if (mode === TYPES.draw) { const drawingThread = this.createAnnotationThread([], {}, TYPES.draw); this.bindCustomListenersOnThread(drawingThread); @@ -689,23 +702,56 @@ class Annotator extends EventEmitter { /* eslint-enable require-jsdoc */ const postButtonEl = this.getAnnotateButton(SELECTOR_ANNOTATION_BUTTON_DRAW_POST); - - handlers.push({ - type: 'mousemove', - func: annotatorUtil.eventToLocationHandler(locationFunction, drawingThread.handleMove), - eventObj: this.annotatedElement - }); - handlers.push({ - type: 'mousedown', - func: annotatorUtil.eventToLocationHandler(locationFunction, drawingThread.handleStart), - eventObj: this.annotatedElement - }); - handlers.push({ - type: 'mouseup', - func: annotatorUtil.eventToLocationHandler(locationFunction, drawingThread.handleStop), - eventObj: this.annotatedElement + 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', @@ -716,6 +762,26 @@ class Annotator extends EventEmitter { eventObj: postButtonEl }); } + + if (undoButtonEl) { + handlers.push({ + type: 'click', + func: () => { + drawingThread.undo(); + }, + eventObj: undoButtonEl + }); + } + + if (redoButtonEl) { + handlers.push({ + type: 'click', + func: () => { + drawingThread.redo(); + }, + eventObj: redoButtonEl + }); + } } handlers.forEach((handler) => { diff --git a/src/lib/annotations/__tests__/Annotator-test.js b/src/lib/annotations/__tests__/Annotator-test.js index d9208fd83..68f3b9348 100644 --- a/src/lib/annotations/__tests__/Annotator-test.js +++ b/src/lib/annotations/__tests__/Annotator-test.js @@ -10,7 +10,9 @@ import { CLASS_ANNOTATION_MODE, CLASS_ACTIVE, CLASS_HIDDEN, - SELECTOR_BOX_PREVIEW_BTN_ANNOTATE_POINT + SELECTOR_ANNOTATION_BUTTON_DRAW_POST, + SELECTOR_ANNOTATION_BUTTON_DRAW_UNDO, + SELECTOR_ANNOTATION_BUTTON_DRAW_REDO } from '../annotationConstants'; let annotator; @@ -369,7 +371,7 @@ describe('lib/annotations/Annotator', () => { annotator.disableAnnotationMode(TYPES.draw, btn); expect(btn).to.not.have.class(CLASS_ACTIVE); expect(stubs.show).to.be.called; - expect(stubs.hide).to.be.calledTwice; + expect(stubs.hide).to.have.callCount(4); }); }); @@ -401,7 +403,7 @@ describe('lib/annotations/Annotator', () => { annotator.enableAnnotationMode(TYPES.draw, btn); expect(btn).to.have.class(CLASS_ACTIVE); expect(stubs.hide).to.be.called; - expect(stubs.show).to.be.calledTwice; + expect(stubs.show).to.have.callCount(4); }); }); @@ -545,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('annotationevent'); annotator.unbindCustomListenersOnThread(stubs.thread); }); }); @@ -579,14 +582,14 @@ describe('lib/annotations/Annotator', () => { expect(annotator.annotationModeHandlers.length).equals(2); }); - it('should bind draw mode handlers', () => { + 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(null); + sandbox.stub(annotator, 'getAnnotateButton').returns(postButtonEl); const locationHandler = (() => {}); sandbox.stub(annotatorUtil, 'eventToLocationHandler').returns(locationHandler); @@ -597,22 +600,23 @@ describe('lib/annotations/Annotator', () => { sinon.match.string, locationHandler ).thrice; - expect(postButtonEl.addEventListener).to.not.be.calledWith( + expect(postButtonEl.addEventListener).to.be.calledWith( 'click', sinon.match.func ); - expect(annotator.annotationModeHandlers.length).equals(3); + expect(annotator.annotationModeHandlers.length).equals(6); }); - it('should bind draw mode click handlers if post button exists', () => { + 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() }; - sandbox.stub(annotator, 'getAnnotateButton').returns(postButtonEl); const locationHandler = (() => {}); + const getAnnotateButton = sandbox.stub(annotator, 'getAnnotateButton'); + getAnnotateButton.withArgs(SELECTOR_ANNOTATION_BUTTON_DRAW_POST).returns(postButtonEl); sandbox.stub(annotatorUtil, 'eventToLocationHandler').returns(locationHandler); @@ -628,6 +632,33 @@ describe('lib/annotations/Annotator', () => { ); 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); + }); }); describe('unbindModeListeners()', () => { diff --git a/src/lib/annotations/__tests__/annotatorUtil-test.js b/src/lib/annotations/__tests__/annotatorUtil-test.js index 81f54ec27..d47b2ccc5 100644 --- a/src/lib/annotations/__tests__/annotatorUtil-test.js +++ b/src/lib/annotations/__tests__/annotatorUtil-test.js @@ -5,6 +5,8 @@ import { getPageInfo, showElement, hideElement, + enableElement, + disableElement, showInvisibleElement, hideElementVisibility, resetTextarea, @@ -122,6 +124,34 @@ describe('lib/annotations/annotatorUtil', () => { }); }); + describe('enableElement()', () => { + it('should remove disabled class from element with matching selector', () => { + // Hide element before testing show function + childEl.classList.add('is-disabled'); + enableElement('.child'); + assert.ok(!childEl.classList.contains('is-disabled')); + }); + + it('should remove hidden class from provided element', () => { + // Hide element before testing show function + childEl.classList.add('is-disabled'); + enableElement(childEl); + assert.ok(!childEl.classList.contains('is-disabled')); + }); + }); + + describe('disableElement()', () => { + it('should add hidden class to matching element', () => { + disableElement('.child'); + assert.ok(childEl.classList.contains('is-disabled')); + }); + + it('should add hidden class to provided element', () => { + disableElement(childEl); + assert.ok(childEl.classList.contains('is-disabled')); + }); + }); + describe('showInvisibleElement()', () => { it('should remove invisible class from element with matching selector', () => { // Hide element before testing show function diff --git a/src/lib/annotations/annotationConstants.js b/src/lib/annotations/annotationConstants.js index d74ff2f8c..483869548 100644 --- a/src/lib/annotations/annotationConstants.js +++ b/src/lib/annotations/annotationConstants.js @@ -1,6 +1,8 @@ export const CLASS_ACTIVE = 'bp-is-active'; export const CLASS_HIDDEN = 'bp-is-hidden'; export const CLASS_INVISIBLE = 'bp-is-invisible'; +export const CLASS_DISABLED = 'is-disabled'; + export const CLASS_ANNOTATION_BUTTON_CANCEL = 'cancel-annotation-btn'; export const CLASS_ANNOTATION_BUTTON_POST = 'post-annotation-btn'; export const CLASS_ANNOTATION_DIALOG = 'bp-annotation-dialog'; @@ -25,6 +27,8 @@ export const CLASS_ANNOTATION_LAYER_HIGHLIGHT = 'bp-annotation-layer-highlight'; export const CLASS_ANNOTATION_LAYER_DRAW = 'bp-annotation-layer-draw'; export const CLASS_ANNOTATION_LAYER_DRAW_IN_PROGRESS = 'bp-annotation-layer-draw-in-progress'; export const CLASS_ANNOTATION_BUTTON_POINT = 'bp-btn-annotate-point'; +export const CLASS_ANNOTATION_BUTTON_DRAW_UNDO = 'bp-btn-annotate-draw-undo'; +export const CLASS_ANNOTATION_BUTTON_DRAW_REDO = 'bp-btn-annotate-draw-redo'; export const CLASS_ANNOTATION_BUTTON_DRAW_POST = 'bp-btn-annotate-draw-post'; export const CLASS_ANNOTATION_BUTTON_DRAW_CANCEL = 'bp-btn-annotate-draw-cancel'; export const CLASS_ANNOTATION_BUTTON_DRAW_ENTER = 'bp-btn-annotate-draw-enter'; @@ -46,6 +50,8 @@ export const SECTION_CREATE = '[data-section="create"]'; export const SECTION_SHOW = '[data-section="show"]'; export const SELECTOR_ANNOTATION_BUTTON_POINT = `.${CLASS_ANNOTATION_BUTTON_POINT}`; +export const SELECTOR_ANNOTATION_BUTTON_DRAW_UNDO = `.${CLASS_ANNOTATION_BUTTON_DRAW_UNDO}`; +export const SELECTOR_ANNOTATION_BUTTON_DRAW_REDO = `.${CLASS_ANNOTATION_BUTTON_DRAW_REDO}`; export const SELECTOR_ANNOTATION_BUTTON_DRAW_POST = `.${CLASS_ANNOTATION_BUTTON_DRAW_POST}`; export const SELECTOR_ANNOTATION_BUTTON_DRAW_CANCEL = `.${CLASS_ANNOTATION_BUTTON_DRAW_CANCEL}`; export const SELECTOR_ANNOTATION_BUTTON_DRAW_ENTER = `.${CLASS_ANNOTATION_BUTTON_DRAW_ENTER}`; diff --git a/src/lib/annotations/annotatorUtil.js b/src/lib/annotations/annotatorUtil.js index 2ad1d0ef3..04480c131 100644 --- a/src/lib/annotations/annotatorUtil.js +++ b/src/lib/annotations/annotatorUtil.js @@ -5,7 +5,8 @@ import { PENDING_STATES, CLASS_ACTIVE, CLASS_HIDDEN, - CLASS_INVISIBLE + CLASS_INVISIBLE, + CLASS_DISABLED } from './annotationConstants'; const HEADER_CLIENT_NAME = 'X-Box-Client-Name'; @@ -120,6 +121,40 @@ export function hideElement(elementOrSelector) { } } +/** + * Disables the specified element or element with specified selector. + * + * @param {HTMLElement|string} elementOrSelector - Element or CSS selector + * @return {void} + */ +export function disableElement(elementOrSelector) { + let element = elementOrSelector; + if (typeof elementOrSelector === 'string' || elementOrSelector instanceof String) { + element = document.querySelector(elementOrSelector); + } + + if (element) { + element.classList.add(CLASS_DISABLED); + } +} + +/** + * Enables the specified element or element with specified selector. + * + * @param {HTMLElement|string} elementOrSelector - Element or CSS selector + * @return {void} + */ +export function enableElement(elementOrSelector) { + let element = elementOrSelector; + if (typeof elementOrSelector === 'string' || elementOrSelector instanceof String) { + element = document.querySelector(elementOrSelector); + } + + if (element) { + element.classList.remove(CLASS_DISABLED); + } +} + /** * Shows the specified element or element with specified selector. * diff --git a/src/lib/annotations/doc/DocAnnotator.js b/src/lib/annotations/doc/DocAnnotator.js index 5ac9bc12c..6168f80a1 100644 --- a/src/lib/annotations/doc/DocAnnotator.js +++ b/src/lib/annotations/doc/DocAnnotator.js @@ -490,6 +490,10 @@ class DocAnnotator extends Annotator { // We need to redraw highlights on the page if a thread was deleted // since deleting 'cuts' out the highlight, which may have been // overlapping with another + if (!annotatorUtil.isHighlightAnnotation(thread.type)) { + return; + } + thread.addListener('threaddeleted', () => { this.showHighlightsOnPage(thread.location.page); }); diff --git a/src/lib/annotations/doc/DocDrawingThread.js b/src/lib/annotations/doc/DocDrawingThread.js index c58cd86f1..94b83c583 100644 --- a/src/lib/annotations/doc/DocDrawingThread.js +++ b/src/lib/annotations/doc/DocDrawingThread.js @@ -28,10 +28,7 @@ class DocDrawingThread extends DrawingThread { constructor(data) { super(data); - this.handleStart = this.handleStart.bind(this); - this.handleMove = this.handleMove.bind(this); - this.handleStop = this.handleStop.bind(this); - this.checkAndHandleScaleUpdate = this.checkAndHandleScaleUpdate.bind(this); + this.onPageChange = this.onPageChange.bind(this); this.reconstructBrowserCoordFromLocation = this.reconstructBrowserCoordFromLocation.bind(this); } /** @@ -41,8 +38,10 @@ class DocDrawingThread extends DrawingThread { * @return {void} */ handleMove(location) { - const pageChanged = this.hasPageChanged(location); - if (this.drawingFlag !== DRAW_STATES.drawing || pageChanged) { + if (this.drawingFlag !== DRAW_STATES.drawing) { + return; + } else if (this.hasPageChanged(location)) { + this.onPageChange(); return; } @@ -60,9 +59,7 @@ class DocDrawingThread extends DrawingThread { handleStart(location) { const pageChanged = this.hasPageChanged(location); if (pageChanged) { - this.handleStop(); - this.saveAnnotation(); - this.emit('drawthreadcommited'); + this.onPageChange(); return; } @@ -94,6 +91,7 @@ class DocDrawingThread extends DrawingThread { if (this.pendingPath && !this.pendingPath.isEmpty()) { this.pathContainer.insert(this.pendingPath); + this.emitAvailableActions(); this.pendingPath = null; } } @@ -105,7 +103,7 @@ class DocDrawingThread extends DrawingThread { * @return {boolean} Whether or not the thread page has changed */ hasPageChanged(location) { - return this.location && this.location.page && this.location.page !== location.page; + return !!this.location && !!this.location.page && this.location.page !== location.page; } /** @@ -116,16 +114,25 @@ class DocDrawingThread extends DrawingThread { * @return {void} */ saveAnnotation(type, text) { - super.saveAnnotation(type, text); + this.emit('annotationevent', { + type: 'drawingcommit' + }); this.reset(); + // Only make save request to server if there exist paths to save + const { undoCount } = this.pathContainer.getNumberOfItems(); + if (undoCount === 0) { + return; + } + + super.saveAnnotation(type, text); + const drawingAnnotationLayerContext = docAnnotatorUtil.getContext( this.pageEl, CLASS_ANNOTATION_LAYER_DRAW, PAGE_PADDING_TOP, PAGE_PADDING_BOTTOM ); - if (drawingAnnotationLayerContext) { const inProgressCanvas = this.drawingContext.canvas; const width = parseInt(inProgressCanvas.style.width, 10); @@ -145,12 +152,6 @@ class DocDrawingThread extends DrawingThread { return; } - // Get DrawingPath objects to be shown - const drawings = this.getDrawings(); - if (this.pendingPath && !this.pendingPath.isEmpty()) { - drawings.push(this.pendingPath); - } - this.checkAndHandleScaleUpdate(); // Get the annotation layer context to draw with @@ -158,8 +159,8 @@ class DocDrawingThread extends DrawingThread { if (this.state === STATES.pending) { context = this.drawingContext; } else { - this.pageEl = docAnnotatorUtil.getPageEl(this.annotatedElement, this.location.page); const config = { scale: this.lastScaleFactor }; + this.pageEl = docAnnotatorUtil.getPageEl(this.annotatedElement, this.location.page); context = docAnnotatorUtil.getContext( this.pageEl, CLASS_ANNOTATION_LAYER_DRAW, @@ -169,15 +170,15 @@ class DocDrawingThread extends DrawingThread { this.setContextStyles(config, context); } - // Draw the paths to the annotation layer canvas - if (context) { - context.beginPath(); - drawings.forEach((drawing) => { - drawing.generateBrowserPath(this.reconstructBrowserCoordFromLocation); - drawing.drawPath(context); - }); - context.stroke(); + // 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); } + + this.draw(context, false); } /** @@ -206,6 +207,18 @@ class DocDrawingThread extends DrawingThread { this.setContextStyles(config); } + /** + * End the current drawing and emit a page changed event + * + * @return {void} + */ + onPageChange() { + this.handleStop(); + this.emit('annotationevent', { + type: 'pagechanged' + }); + } + /** * Requires a DocDrawingThread to have been started with DocDrawingThread.start(). Reconstructs a browserCoordinate * relative to the dimensions of the DocDrawingThread page element. diff --git a/src/lib/annotations/doc/__tests__/DocAnnotator-test.js b/src/lib/annotations/doc/__tests__/DocAnnotator-test.js index a1a833e12..8f3a655f2 100644 --- a/src/lib/annotations/doc/__tests__/DocAnnotator-test.js +++ b/src/lib/annotations/doc/__tests__/DocAnnotator-test.js @@ -598,8 +598,9 @@ describe('lib/annotations/doc/DocAnnotator', () => { Object.defineProperty(Annotator.prototype, 'bindCustomListenersOnThread', { value: bindFunc }); }); - it('should call parent to bind custom listeners and also bind on threaddeleted', () => { + it('should call parent to bind custom listeners and also bind highlights on threaddeleted', () => { const thread = { addListener: () => {} }; + sandbox.stub(annotatorUtil, 'isHighlightAnnotation').returns(true); stubs.threadMock = sandbox.mock(thread); stubs.threadMock.expects('addListener').withArgs('threaddeleted', sinon.match.func); diff --git a/src/lib/annotations/doc/__tests__/DocDrawingThread-test.js b/src/lib/annotations/doc/__tests__/DocDrawingThread-test.js index b9ac5e9e0..57a9f1230 100644 --- a/src/lib/annotations/doc/__tests__/DocDrawingThread-test.js +++ b/src/lib/annotations/doc/__tests__/DocDrawingThread-test.js @@ -1,6 +1,7 @@ import * as docAnnotatorUtil from '../docAnnotatorUtil'; import * as annotatorUtil from '../../annotatorUtil'; import DocDrawingThread from '../DocDrawingThread'; +import AnnotationThread from '../../AnnotationThread'; import DrawingPath from '../../drawing/DrawingPath'; import { DRAW_STATES @@ -84,24 +85,22 @@ describe('lib/annotations/doc/DocDrawingThread', () => { it('should commit the thread when the page changes', () => { sandbox.stub(docDrawingThread, 'hasPageChanged').returns(true); sandbox.stub(docDrawingThread, 'checkAndHandleScaleUpdate'); - sandbox.stub(docDrawingThread, 'handleStop'); + sandbox.stub(docDrawingThread, 'onPageChange'); sandbox.stub(docDrawingThread, 'saveAnnotation'); - docDrawingThread.drawingFlag = DRAW_STATES.idle; docDrawingThread.pendingPath = undefined; docDrawingThread.handleStart(docDrawingThread.location); docDrawingThread.location = {}; expect(docDrawingThread.hasPageChanged).to.be.called; - expect(docDrawingThread.handleStop).to.be.called; - expect(docDrawingThread.saveAnnotation).to.be.called; + expect(docDrawingThread.onPageChange).to.be.called; expect(docDrawingThread.checkAndHandleScaleUpdate).to.not.be.called; - expect(docDrawingThread.drawingFlag).to.equal(DRAW_STATES.idle); }); }); describe('handleStop()', () => { it("should set the state to 'idle' and clear the pendingPath", () => { + sandbox.stub(docDrawingThread, 'emitAvailableActions'); docDrawingThread.drawingFlag = DRAW_STATES.drawing; docDrawingThread.pendingPath = { isEmpty: () => false @@ -112,13 +111,27 @@ describe('lib/annotations/doc/DocDrawingThread', () => { docDrawingThread.handleStop(); + expect(docDrawingThread.emitAvailableActions).to.be.called; expect(docDrawingThread.drawingFlag).to.equal(DRAW_STATES.idle); expect(docDrawingThread.pendingPath).to.be.null; }); }); + describe('onPageChange()', () => { + it('should emit an annotationevent of type pagechanged and stop a pending drawing', (done) =>{ + sandbox.stub(docDrawingThread, 'handleStop'); + docDrawingThread.addListener('annotationevent', (data) => { + expect(docDrawingThread.handleStop).to.be.called; + expect(data.type).to.equal('pagechanged'); + done(); + }); + + docDrawingThread.onPageChange(); + }); + }); + describe('checkAndHandleScaleUpdate()', () => { - it('should update the scale factor when the scale has changed', () => { + it('should update the drawing information when the scale has changed', () => { sandbox.stub(docDrawingThread, 'setContextStyles'); sandbox.stub(annotatorUtil, 'getScale').returns(1.4); sandbox.stub(docAnnotatorUtil, 'getPageEl'); @@ -165,4 +178,134 @@ describe('lib/annotations/doc/DocDrawingThread', () => { }); }) }); + + describe('saveAnnotation()', () => { + const resetValue = AnnotationThread.prototype.saveAnnotation; + + beforeEach(() => { + Object.defineProperty(AnnotationThread.prototype, 'saveAnnotation', { value: sandbox.stub() }); + }); + + afterEach(() => { + Object.defineProperty(AnnotationThread.prototype, 'saveAnnotation', { value: resetValue }); + }); + + it('should clean up without committing when there are no paths to be saved', () => { + sandbox.stub(docDrawingThread.pathContainer, 'getNumberOfItems').returns({ + undoCount: 0, + redoCount: 1 + }); + + docDrawingThread.saveAnnotation('draw'); + expect(docDrawingThread.pathContainer.getNumberOfItems).to.be.called; + expect(AnnotationThread.prototype.saveAnnotation).to.not.be.called; + }); + + it('should clean up and commit in-progress drawings when there are paths to be saved', () => { + docDrawingThread.drawingContext = { + canvas: { + style: { + width: 10, + height: 15 + } + }, + width: 20, + height: 30, + clearRect: sandbox.stub() + }; + const context = { + drawImage: sandbox.stub() + }; + + sandbox.stub(docAnnotatorUtil, 'getContext').returns(context); + sandbox.stub(docDrawingThread.pathContainer, 'getNumberOfItems').returns({ + undoCount: 1, + redoCount: 0 + }); + + docDrawingThread.saveAnnotation('draw'); + expect(docAnnotatorUtil.getContext).to.be.called; + expect(docDrawingThread.pathContainer.getNumberOfItems).to.be.called; + expect(docDrawingThread.drawingContext.clearRect).to.be.called; + expect(context.drawImage).to.be.called; + expect(AnnotationThread.prototype.saveAnnotation).to.be.called; + }); + }); + + describe('hasPageChanged()', () => { + it('should return false when there is no location', () => { + const value = docDrawingThread.hasPageChanged(undefined); + expect(value).to.be.falsy; + }); + + it('should return false when there is a location but no page', () => { + const location = { + page: undefined + }; + const value = docDrawingThread.hasPageChanged(location); + expect(value).to.be.falsy; + }); + + it('should return false when the given location page is the same as the thread location', () => { + docDrawingThread.location = { + page: 2 + }; + const location = { + page: docDrawingThread.location.page + }; + const value = docDrawingThread.hasPageChanged(location); + expect(value).to.be.falsy; + }); + + it('should return true when the given location page is different from the thread location', () => { + docDrawingThread.location = { + page: 2 + }; + const location = { + page: (docDrawingThread.location.page + 1) + }; + const value = docDrawingThread.hasPageChanged(location); + expect(value).to.be.true; + }); + }); + + describe('show()', () => { + beforeEach(() => { + sandbox.stub(docAnnotatorUtil, 'getPageEl'); + sandbox.stub(docAnnotatorUtil, 'getContext'); + sandbox.stub(docDrawingThread, 'checkAndHandleScaleUpdate'); + sandbox.stub(docDrawingThread, 'setContextStyles'); + sandbox.stub(docDrawingThread, 'draw'); + docDrawingThread.pathContainer = { + applyToItems: sandbox.stub() + }; + }); + + it('should do nothing when no element is assigned to the DocDrawingThread', () => { + docDrawingThread.annotatedElement = undefined; + docDrawingThread.location = 'loc'; + docDrawingThread.show(); + expect(docDrawingThread.checkAndHandleScaleUpdate).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; + }); + + it('should draw the paths in the thread', () => { + docDrawingThread.annotatedElement = 'annotatedEl'; + docDrawingThread.location = 'loc'; + docDrawingThread.state = 'not pending'; + + docDrawingThread.show() + expect(docAnnotatorUtil.getPageEl).to.be.called; + expect(docAnnotatorUtil.getContext).to.be.called; + expect(docDrawingThread.checkAndHandleScaleUpdate).to.be.called; + expect(docDrawingThread.setContextStyles).to.be.called; + expect(docDrawingThread.draw).to.be.called; + }); + }); }); diff --git a/src/lib/annotations/drawing/DrawingContainer.js b/src/lib/annotations/drawing/DrawingContainer.js new file mode 100644 index 000000000..9c7dc0d4f --- /dev/null +++ b/src/lib/annotations/drawing/DrawingContainer.js @@ -0,0 +1,100 @@ +class DrawingContainer { + //-------------------------------------------------------------------------- + // Typedef + //-------------------------------------------------------------------------- + + /** + * @typedef {Object} AvailableItemData + * @property {number} undo Number of undoable items + * @property {number} redo Number of redoable items + */ + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** @property {Array} - The item history stack for undo operations */ + undoStack = []; + + /** @property {Array} - The item history stack for redo operations */ + redoStack = []; + + /** + * Insert an item into the drawing container. Clears any redoable items. + * + * @param {Object} item - An object to be contained in the data structure. + * @return {void} + */ + insert(item) { + this.undoStack.push(item); + this.redoStack = []; + } + + /** + * Move an item from the undo stack into the redo stack if an item exists. + * + * @return {boolean} Whether or not an undo was done. + */ + undo() { + if (this.undoStack.length === 0) { + return false; + } + + const latestUndone = this.undoStack.pop(); + this.redoStack.push(latestUndone); + return true; + } + + /** + * Move an item from the redo stack into the undo stack if an item exists. + * + * @return {boolean} Whether or not a redo was done. + */ + redo() { + if (this.redoStack.length === 0) { + return false; + } + + const latestRedone = this.redoStack.pop(); + this.undoStack.push(latestRedone); + return true; + } + + /** + * Retrieve a JSON blob containing the number of undo and redo in each stack. + * + * @return {AvailableItemData} The number of undo and redo items available. + */ + getNumberOfItems() { + return { + undoCount: this.undoStack.length, + redoCount: this.redoStack.length + }; + } + + /** + * Retrieve the visible items on the undo stack. + * + * @return {Array} A copy of the undoStack as an array. + */ + getItems() { + return this.undoStack.slice(); + } + + /** + * Apply a function to the items in the container. + * + * @param {Function} fn - The function to apply to the items. + * @param {boolean} [includeHiddenItems] - Whether or not to apply the function to items hidden on the redo stack. + * @return {void} + */ + applyToItems(fn, includeHiddenItems = false) { + this.undoStack.forEach(fn); + + if (includeHiddenItems) { + this.redoStack.forEach(fn); + } + } +} + +export default DrawingContainer; diff --git a/src/lib/annotations/drawing/DrawingThread.js b/src/lib/annotations/drawing/DrawingThread.js index c4d27196f..01b0939b9 100644 --- a/src/lib/annotations/drawing/DrawingThread.js +++ b/src/lib/annotations/drawing/DrawingThread.js @@ -1,19 +1,16 @@ -import rbush from 'rbush'; import AnnotationThread from '../AnnotationThread'; import DrawingPath from './DrawingPath'; +import DrawingContainer from './DrawingContainer'; import { DRAW_STATES, DRAW_RENDER_THRESHOLD } from '../annotationConstants'; -const RTREE_WIDTH = 5; // Lower number - faster search, higher - faster insert const BASE_LINE_WIDTH = 3; class DrawingThread extends AnnotationThread { /** @property {number} - Drawing state */ drawingFlag = DRAW_STATES.idle; - /** @property {rbush} - Rtree path container */ - /* eslint-disable new-cap */ - pathContainer = new rbush(RTREE_WIDTH); - /* eslint-enable new-cap */ + /** @property {DrawingContainer} - The path container supporting undo and redo */ + pathContainer = new DrawingContainer(); /** @property {DrawingPath} - The path being drawn but not yet finalized */ pendingPath; @@ -45,6 +42,7 @@ class DrawingThread extends AnnotationThread { this.handleMove = this.handleMove.bind(this); this.handleStop = this.handleStop.bind(this); + // Recreate stored paths if (data && data.location && data.location.drawingPaths instanceof Array) { data.location.drawingPaths.forEach((drawingPathData) => { const pathInstance = new DrawingPath(drawingPathData); @@ -70,20 +68,11 @@ class DrawingThread extends AnnotationThread { this.drawingContext.clearRect(0, 0, canvas.width, canvas.height); } - this.reset(); super.destroy(); + this.reset(); this.emit('threadcleanup'); } - /** - * Get all of the DrawingPaths in the current thread. - * - * @return {void} - */ - getDrawings() { - return this.pathContainer.all(); - } - /* eslint-disable no-unused-vars */ /** * Handle a pointer movement @@ -153,27 +142,38 @@ class DrawingThread extends AnnotationThread { } const elapsed = timestamp - (this.lastRenderTimestamp || 0); - if (elapsed < DRAW_RENDER_THRESHOLD || !this.drawingContext) { - return; + if (elapsed >= DRAW_RENDER_THRESHOLD && this.draw(this.drawingContext, true)) { + this.lastRenderTimestamp = timestamp; } + } - this.lastRenderTimestamp = timestamp; - - const canvas = this.drawingContext.canvas; - const drawings = this.getDrawings(); - if (this.pendingPath && !this.pendingPath.isEmpty()) { - drawings.push(this.pendingPath); + /** + * Overturns the last drawing stroke if it exists. Emits the number of undo and redo + * actions available if an undo was executed. + * + * @return {void} + */ + undo() { + const executedUndo = this.pathContainer.undo(); + if (executedUndo) { + this.draw(this.drawingContext, true); + this.emitAvailableActions(); } + } - /* OPTIMIZE (@minhnguyen): Render only what has been obstructed by the new drawing - * rather than every single line in the thread. If we do end - * up splitting saves into multiple requests, we can buffer - * the amount of re-renders onto a temporary in-progress canvas. - */ - this.drawingContext.clearRect(0, 0, canvas.width, canvas.height); - this.drawingContext.beginPath(); - drawings.forEach((drawing) => drawing.drawPath(this.drawingContext)); - this.drawingContext.stroke(); + /** + * Replays the last undone drawing stroke if it exists. Emits the number of undo and redo + * actions available if a redraw was executed. + * + * @return {void} + * + */ + redo() { + const executedRedo = this.pathContainer.redo(); + if (executedRedo) { + this.draw(this.drawingContext, true); + this.emitAvailableActions(); + } } //-------------------------------------------------------------------------- @@ -191,11 +191,58 @@ class DrawingThread extends AnnotationThread { */ createAnnotationData(type, text) { const annotation = super.createAnnotationData(type, text); - const drawings = this.getDrawings(); + const paths = this.pathContainer.getItems(); - annotation.location.drawingPaths = drawings.map(DrawingPath.extractDrawingInfo); + annotation.location.drawingPaths = paths.map(DrawingPath.extractDrawingInfo); return annotation; } + + /** + * Draws the paths in the thread onto the given context. + * + * @protected + * @param {CanvasContext} context - The context to draw on + * @param {boolean} [clearCanvas] - A flag to clear the canvas before drawing. + * @return {void} + */ + draw(context, clearCanvas = false) { + if (!context) { + return; + } + + /* OPTIMIZE (@minhnguyen): Render only what has been obstructed by the new drawing + * rather than every single line in the thread. If we do end + * up splitting saves into multiple requests, we can buffer + * the amount of re-renders onto a temporary memory canvas. + */ + if (clearCanvas) { + const canvas = context.canvas; + context.clearRect(0, 0, canvas.width, canvas.height); + } + + context.beginPath(); + this.pathContainer.applyToItems((drawing) => drawing.drawPath(context)); + if (this.pendingPath && !this.pendingPath.isEmpty()) { + this.pendingPath.drawPath(context); + } + + context.stroke(); + } + + /** + * Emit an event containing the number of undo and redo actions that can be done. + * + * @protected + * @return {void} + */ + emitAvailableActions() { + const availableActions = this.pathContainer.getNumberOfItems(); + this.emit('annotationevent', { + type: 'availableactions', + undo: availableActions.undoCount, + redo: availableActions.redoCount + }); + } } export default DrawingThread; diff --git a/src/lib/annotations/drawing/__tests__/DrawingContainer-test.js b/src/lib/annotations/drawing/__tests__/DrawingContainer-test.js new file mode 100644 index 000000000..e8484bd30 --- /dev/null +++ b/src/lib/annotations/drawing/__tests__/DrawingContainer-test.js @@ -0,0 +1,118 @@ +import DrawingContainer from '../DrawingContainer'; + +let drawingContainer; +const sandbox = sinon.sandbox.create(); + +describe('lib/annotations/drawing/DrawingContainer', () => { + before(() => { + fixture.setBase('src/lib'); + }); + + beforeEach(() => { + drawingContainer = new DrawingContainer(); + }); + + afterEach(() => { + sandbox.verifyAndRestore(); + drawingContainer = null; + }); + + describe('insert()', () => { + it('should insert an item into the undoStack and clear the redo stack', () => { + drawingContainer.redoStack = [1,2,3]; + expect(drawingContainer.undoStack.length).to.equal(0); + drawingContainer.insert(4); + expect(drawingContainer.undoStack.length).to.equal(1); + expect(drawingContainer.redoStack.length).to.equal(0); + }); + }); + + describe('undo()', () => { + it('should not undo when the undo stack is empty', () => { + expect(drawingContainer.undoStack.length).to.equal(0); + const val = drawingContainer.undo(); + expect(val).to.be.falsy; + }); + + it('should move an item from the top of the undo stack to the top of the redo stack', () => { + drawingContainer.undoStack = [1,2,3]; + drawingContainer.redoStack = [4,5,6]; + const lengthBefore = drawingContainer.undoStack.length; + const topUndo = drawingContainer.undoStack[lengthBefore - 1]; + const val = drawingContainer.undo(); + + expect(val).to.be.truthy; + expect(drawingContainer.undoStack.length).to.equal(lengthBefore - 1); + expect(drawingContainer.redoStack.length).to.equal(lengthBefore + 1); + expect(drawingContainer.redoStack[lengthBefore]).to.equal(topUndo); + }); + }); + + describe('redo()', () => { + it('should not redo when the redo stack is empty', () => { + expect(drawingContainer.redoStack.length).to.equal(0); + const val = drawingContainer.redo(); + expect(val).to.be.falsy; + }); + + it('should move an item from the top of the redo stack to the top of the undo stack', () => { + drawingContainer.undoStack = [1,2,3]; + drawingContainer.redoStack = [4,5,6]; + const lengthBefore = drawingContainer.redoStack.length; + const topRedo = drawingContainer.redoStack[lengthBefore - 1]; + const val = drawingContainer.redo(); + + expect(val).to.be.truthy; + expect(drawingContainer.redoStack.length).to.equal(lengthBefore - 1); + expect(drawingContainer.undoStack.length).to.equal(lengthBefore + 1); + expect(drawingContainer.undoStack[lengthBefore]).to.equal(topRedo); + }); + }); + + describe('getNumberOfItems()', () => { + it('should return the number of items on the undo stack and redo stack', () => { + drawingContainer.undoStack = [1,2,3,4]; + drawingContainer.redoStack = [1,2]; + const val = drawingContainer.getNumberOfItems(); + + expect(val.undoCount).to.equal(drawingContainer.undoStack.length); + expect(val.redoCount).to.equal(drawingContainer.redoStack.length); + }); + }); + + describe('getItems()', () => { + it('should get the items on the undoStack', () => { + drawingContainer.undoStack = [1,2,3,4]; + drawingContainer.redoStack = [1,2]; + const val = drawingContainer.getItems(); + + expect(val).to.not.deep.equal(drawingContainer.redoStack); + expect(val).to.not.equal(drawingContainer.undoStack); + expect(val).to.deep.equal(drawingContainer.undoStack); + }); + }); + + describe('applyToItems()', () => { + it('should apply the function only to items on the undo stack', () => { + const counter = { + count: 0 + }; + drawingContainer.undoStack = [counter, counter, counter, counter]; + drawingContainer.redoStack = [counter]; + drawingContainer.applyToItems((item) => item.count = item.count + 1); + + expect(counter.count).to.equal(drawingContainer.undoStack.length); + }); + + it('should apply the function to items on the undo and redo stack', () => { + const counter = { + count: 0 + }; + drawingContainer.undoStack = [counter, counter, counter, counter]; + drawingContainer.redoStack = [counter, counter]; + drawingContainer.applyToItems((item) => item.count = item.count + 1, true); + + expect(counter.count).to.equal(drawingContainer.undoStack.length + drawingContainer.redoStack.length); + }); + }); +}); diff --git a/src/lib/annotations/drawing/__tests__/DrawingThread-test.js b/src/lib/annotations/drawing/__tests__/DrawingThread-test.js index 779a136bc..7d8fd80aa 100644 --- a/src/lib/annotations/drawing/__tests__/DrawingThread-test.js +++ b/src/lib/annotations/drawing/__tests__/DrawingThread-test.js @@ -5,6 +5,7 @@ import { } from '../../annotationConstants' let drawingThread; +let stubs; const sandbox = sinon.sandbox.create(); describe('lib/annotations/drawing/DrawingThread', () => { @@ -13,6 +14,7 @@ describe('lib/annotations/drawing/DrawingThread', () => { }); beforeEach(() => { + stubs = {}; drawingThread = new DrawingThread({ annotatedElement: document.querySelector('.annotated-element'), annotations: [], @@ -60,25 +62,6 @@ describe('lib/annotations/drawing/DrawingThread', () => { }) }); - describe('getDrawings()', () => { - it('should return all items inserted into the container', () => { - drawingThread.pathContainer.insert('not a test'); - drawingThread.pathContainer.insert('not a secondary test'); - - const allDrawings = drawingThread.getDrawings(); - - assert.ok(allDrawings instanceof Array); - assert.equal(allDrawings.length, 2); - }); - - it('should return an empty array when no items are inserted into the container', () => { - const allDrawings = drawingThread.getDrawings(); - - assert.ok(allDrawings instanceof Array); - assert.equal(allDrawings.length, 0); - }) - }); - describe('setContextStyles()', () => { it('should set configurable context properties', () => { drawingThread.drawingContext = { @@ -107,59 +90,165 @@ describe('lib/annotations/drawing/DrawingThread', () => { }); describe('render()', () => { + beforeEach(() => { + sandbox.stub(drawingThread, 'draw'); + }); + it('should draw the pending path when the context is not empty', () => { - const timeElapsed = 20000; - const drawingArray = []; + const timeStamp = 20000; + drawingThread.render(timeStamp); + expect(drawingThread.draw).to.be.called; + }); + + it('should do nothing when the timeElapsed is less than the refresh rate', () => { + const timeStamp = 100; + drawingThread.lastRenderTimestamp = 100; + drawingThread.render(timeStamp); + expect(drawingThread.draw).to.not.be.called; + }); + }); + + 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); + drawingThread.annotationService = { + user: { id: '1' } + }; + + 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(annotationData.fileVersionId).to.equal(drawingThread.fileVersionId); + expect(annotationData.threadID).to.equal(drawingThread.threadID); + expect(annotationData.user.id).to.equal('1'); + expect(annotationData.location.drawingPaths).to.equal(pathStr); + }); + }); + + describe('undo()', () => { + beforeEach(() => { + stubs.draw = sandbox.stub(drawingThread, 'draw'); + stubs.emitAvailableActions = sandbox.stub(drawingThread, 'emitAvailableActions'); + stubs.containerUndo = sandbox.stub(drawingThread.pathContainer, 'undo'); + }); - sandbox.stub(drawingThread, 'getDrawings') - .returns(drawingArray); + it('should do nothing when the path container fails to undo', () => { + stubs.containerUndo.returns(false); + drawingThread.undo(); + expect(stubs.containerUndo).to.be.called; + expect(stubs.draw).to.not.be.called; + expect(stubs.emitAvailableActions).to.not.be.called; + }); + + it('should draw when the path container indicates a successful undo', () => { + stubs.containerUndo.returns(true); + drawingThread.undo(); + expect(stubs.containerUndo).to.be.called; + expect(stubs.draw).to.be.called; + expect(stubs.emitAvailableActions).to.be.called; + }); + }); + + describe('redo()', () => { + beforeEach(() => { + stubs.draw = sandbox.stub(drawingThread, 'draw'); + stubs.emitAvailableActions = sandbox.stub(drawingThread, 'emitAvailableActions'); + stubs.containerRedo = sandbox.stub(drawingThread.pathContainer, 'redo'); + }); + + it('should do nothing when the path container fails to redo', () => { + stubs.containerRedo.returns(false); + drawingThread.redo(); + expect(stubs.containerRedo).to.be.called; + expect(stubs.draw).to.not.be.called; + expect(stubs.emitAvailableActions).to.not.be.called; + }); + it('should draw when the path container indicates a successful redo', () => { + stubs.containerRedo.returns(true); + drawingThread.redo(); + expect(stubs.containerRedo).to.be.called; + expect(stubs.draw).to.be.called; + expect(stubs.emitAvailableActions).to.be.called; + }); + }); + + describe('draw()', () => { + let context; + + beforeEach(() => { drawingThread.pendingPath = { - drawPath: sandbox.stub(), - isEmpty: sandbox.stub().returns(false) + isEmpty: sandbox.stub(), + drawPath: sandbox.stub() }; - drawingThread.drawingContext = { + stubs.applyToItems = sandbox.stub(drawingThread.pathContainer, 'applyToItems'); + stubs.pendingEmpty = drawingThread.pendingPath.isEmpty; + stubs.pendingDraw = drawingThread.pendingPath.drawPath; + context = { + clearRect: sandbox.stub(), beginPath: sandbox.stub(), stroke: sandbox.stub(), - clearRect: sandbox.stub(), canvas: { - width: 2, + width: 1, height: 2 } }; - drawingThread.render(timeElapsed); + }); - expect(drawingThread.getDrawings).to.be.called; - expect(drawingThread.drawingContext.clearRect).to.be.called; - expect(drawingThread.drawingContext.beginPath).to.be.called; - expect(drawingThread.drawingContext.stroke).to.be.called; - expect(drawingThread.pendingPath.drawPath).to.be.called; - expect(drawingThread.pendingPath.isEmpty).to.be.called; + it('should do nothing when context is null or undefined', () => { + context = undefined; + drawingThread.draw(context); + context = null; + drawingThread.draw(context); + expect(stubs.applyToItems).to.not.be.called; }); - it('should do nothing when the context is empty', () => { - const timeElapsed = 20000; + it('should draw the items in the path container when given a valid context', () => { + stubs.pendingEmpty.returns(false); + drawingThread.draw(context); + expect(context.beginPath).to.be.called; + expect(stubs.applyToItems).to.be.called; + expect(stubs.pendingEmpty).to.be.called; + expect(stubs.pendingDraw).to.be.called; + expect(context.stroke).to.be.called; + }); - sandbox.stub(drawingThread, 'getDrawings'); - drawingThread.context = null; - drawingThread.render(timeElapsed); + it('should clear the canvas when the flag is true', () => { + drawingThread.draw(context, true); + expect(context.clearRect).to.be.called; + }); - expect(drawingThread.getDrawings).to.not.be.called; + it('should not clear the canvas when the flag is true', () => { + drawingThread.draw(context, false); + expect(context.clearRect).to.not.be.called; }); }); - describe('createAnnotationData()', () => { - it('should create a valid annotation data object', () => { - drawingThread.annotationService = { - user: { id: '1' } - }; + describe('emitAvailableActions()', () => { + afterEach(() => { + drawingThread.removeAllListeners('annotationevent'); + }); - const placeholder = "String here so string doesn't get fined"; - const annotationData = drawingThread.createAnnotationData('draw', placeholder); + it('should trigger an annotationevent with the number of available undo and redo actions', (done) => { + const numItems = { + undoCount: 3, + redoCount: 2 + }; + sandbox.stub(drawingThread.pathContainer, 'getNumberOfItems').returns(numItems); + drawingThread.addListener('annotationevent', (data) => { + expect(data.type).to.equal('availableactions'); + expect(data.undo).to.equal(numItems.undoCount); + expect(data.redo).to.equal(numItems.redoCount); + done(); + }); - expect(annotationData.fileVersionId).to.equal(drawingThread.fileVersionId); - expect(annotationData.threadID).to.equal(drawingThread.threadID); - expect(annotationData.user.id).to.equal('1'); + drawingThread.emitAvailableActions(); }); }); }); diff --git a/src/lib/shell.html b/src/lib/shell.html index f4f92d267..ede8f51c5 100644 --- a/src/lib/shell.html +++ b/src/lib/shell.html @@ -7,6 +7,16 @@
+ +