From 318501f6785a84ec63c6904331f69f8e05f00dd8 Mon Sep 17 00:00:00 2001 From: MinhHNguyen Date: Tue, 5 Sep 2017 14:12:52 -0700 Subject: [PATCH] Feature: drawingSelectionUI (#362) * Update: drawing boundary update * Update: tests for drawing boundary on each stroke * Update: drawing dialog * Update: tests * Update: PR feedback --- src/i18n/en-US.properties | 6 ++ .../annotations/AnnotationModeController.js | 7 ++ src/lib/annotations/Annotator.js | 2 - .../__tests__/annotatorUtil-test.js | 67 +++++++++----- src/lib/annotations/annotationConstants.js | 5 + src/lib/annotations/annotatorUtil.js | 20 +++- src/lib/annotations/doc/DocAnnotator.js | 2 + src/lib/annotations/doc/DocDrawingDialog.js | 6 ++ src/lib/annotations/doc/DocDrawingThread.js | 82 +++++++++++------ .../doc/__tests__/DocAnnotator-test.js | 21 ++++- .../doc/__tests__/DocDrawingThread-test.js | 92 ++++++++++++++----- .../annotations/drawing/DrawingContainer.js | 11 ++- .../drawing/DrawingModeController.js | 84 +++++++++++------ src/lib/annotations/drawing/DrawingThread.js | 46 ++++++++-- .../__tests__/DrawingContainer-test.js | 12 +++ .../__tests__/DrawingModeController-test.js | 75 ++++++++++++++- .../drawing/__tests__/DrawingThread-test.js | 45 +++++++-- src/lib/icons/draw_delete.svg | 1 + src/lib/icons/draw_save.svg | 1 + src/lib/icons/icons.js | 4 + 20 files changed, 460 insertions(+), 129 deletions(-) create mode 100644 src/lib/icons/draw_delete.svg create mode 100644 src/lib/icons/draw_save.svg diff --git a/src/i18n/en-US.properties b/src/i18n/en-US.properties index 951cf365a..22f978826 100644 --- a/src/i18n/en-US.properties +++ b/src/i18n/en-US.properties @@ -135,6 +135,12 @@ annotation_highlight_toggle=Highlight text annotation_highlight_comment=Add comment to highlighted text # Text for which user made the highlight annotation annotation_who_highlighted={1} highlighted +# Text for which user made the drawn annotation +annotation_who_drew={1} drew +# Accessibilty text for button that soft commits drawing +annotation_draw_save=Save drawing +# Accessibilty text for button that soft deletes drawing +annotation_draw_delete=Delete drawing # Text for when annotations fail to load annotations_load_error=We're sorry, annotations failed to load for this file. # Text for when the annotation can't be created diff --git a/src/lib/annotations/AnnotationModeController.js b/src/lib/annotations/AnnotationModeController.js index f644550c6..126a8a2ea 100644 --- a/src/lib/annotations/AnnotationModeController.js +++ b/src/lib/annotations/AnnotationModeController.js @@ -88,6 +88,13 @@ class AnnotationModeController extends EventEmitter { this.threads = this.threads.filter((item) => item !== thread); } + /** + * Clean up any selected annotations + * + * @return {void} + */ + removeSelection() {} + /** * Binds custom event listeners for a thread. * diff --git a/src/lib/annotations/Annotator.js b/src/lib/annotations/Annotator.js index 931dc93e6..74b2593d8 100644 --- a/src/lib/annotations/Annotator.js +++ b/src/lib/annotations/Annotator.js @@ -415,8 +415,6 @@ class Annotator extends EventEmitter { annotatorUtil.hideElement(postButtonEl); annotatorUtil.hideElement(undoButtonEl); annotatorUtil.hideElement(redoButtonEl); - annotatorUtil.disableElement(undoButtonEl); - annotatorUtil.disableElement(redoButtonEl); } } diff --git a/src/lib/annotations/__tests__/annotatorUtil-test.js b/src/lib/annotations/__tests__/annotatorUtil-test.js index d47b2ccc5..9bbe3ba7c 100644 --- a/src/lib/annotations/__tests__/annotatorUtil-test.js +++ b/src/lib/annotations/__tests__/annotatorUtil-test.js @@ -25,7 +25,8 @@ import { getHeaders, replacePlaceholders, createLocation, - round + round, + prevDefAndStopProp } from '../annotatorUtil'; import { STATES, @@ -36,6 +37,8 @@ import { const DIALOG_WIDTH = 81; +const sandbox = sinon.sandbox.create(); + describe('lib/annotations/annotatorUtil', () => { let childEl; let parentEl; @@ -52,6 +55,7 @@ describe('lib/annotations/annotatorUtil', () => { }); afterEach(() => { + sandbox.verifyAndRestore(); fixture.cleanup(); }); @@ -403,39 +407,54 @@ describe('lib/annotations/annotatorUtil', () => { }); describe('eventToLocationHandler()', () => { - it('should not call the callback when the location is valid', () => { - const annotator = { - isChanged: false - } - const getLocation = ((event) => 'location'); - const callback = ((location) => { - annotator.isChanged = true - }); - const locationHandler = eventToLocationHandler(getLocation, callback); + let getLocation; + let annotator; + let callback; + let locationHandler; + let event; + + beforeEach(() => { + getLocation = ((event) => 'location'); + callback = sandbox.stub(); + locationHandler = eventToLocationHandler(getLocation, callback); + event = { + preventDefault: () => {}, + stopPropagation: () => {} + }; + }); + it('should not call the callback when the location is valid', () => { locationHandler(undefined); - expect(annotator.isChanged).to.be.false; + expect(callback).to.not.be.called; }); it('should call the callback when the location is valid', () => { - const annotator = { - isChanged: false - } - const getLocation = ((event) => 'location'); - const callback = ((location) => { - annotator.isChanged = true - }); - const locationHandler = eventToLocationHandler(getLocation, callback); - const event = { - preventDefault: () => {}, - stopPropagation: () => {} - }; + locationHandler(event); + expect(callback).to.be.calledWith('location'); + }); + it('should do nothing when the target exists and it is not the textLayer', () => { + event.target = { + className: 'button' + }; locationHandler(event); - expect(annotator.isChanged).to.be.true; + expect(callback).to.not.be.called; }); }); + describe('prevDefAndStopProp()', () => { + it('should prevent default and stop propogation on an event', () => { + const event = { + preventDefault: sandbox.stub(), + stopPropagation: sandbox.stub() + }; + + prevDefAndStopProp(event); + expect(event.preventDefault).to.be.called; + expect(event.stopPropagation).to.be.called; + }) + }); + describe('createLocation()', () => { it('should create a location object without dimensions', () => { const location = createLocation(1,2, undefined); diff --git a/src/lib/annotations/annotationConstants.js b/src/lib/annotations/annotationConstants.js index 3e5c19425..4b140c90b 100644 --- a/src/lib/annotations/annotationConstants.js +++ b/src/lib/annotations/annotationConstants.js @@ -1,3 +1,5 @@ +export const USER_ANONYMOUS = 'Anonymous'; + export const CLASS_ACTIVE = 'bp-is-active'; export const CLASS_HIDDEN = 'bp-is-hidden'; export const CLASS_INVISIBLE = 'bp-is-invisible'; @@ -32,6 +34,9 @@ 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'; +export const CLASS_ANNOTATION_DRAWING_LABEL = 'bp-annotation-drawing-label'; +export const CLASS_ADD_DRAWING_BTN = 'bp-btn-annotate-draw-add'; +export const CLASS_DELETE_DRAWING_BTN = 'bp-btn-annotate-draw-delete'; export const DATA_TYPE_ANNOTATION_DIALOG = 'annotation-dialog'; export const DATA_TYPE_ANNOTATION_INDICATOR = 'annotation-indicator'; diff --git a/src/lib/annotations/annotatorUtil.js b/src/lib/annotations/annotatorUtil.js index d9984da68..b5cf46bb7 100644 --- a/src/lib/annotations/annotatorUtil.js +++ b/src/lib/annotations/annotatorUtil.js @@ -439,7 +439,7 @@ export function validateThreadParams(thread) { } /** - * Returns a function that passes a callback a location when given an event + * Returns a function that passes a callback a location when given an event on the document text layer * * @param {Function} locationFunction - The function to get a location from an event * @param {Function} callback - Callback to be called upon receiving an event @@ -448,7 +448,9 @@ export function validateThreadParams(thread) { export function eventToLocationHandler(locationFunction, callback) { return (event) => { const evt = event || window.event; - if (!evt) { + // Do nothing when the target isn't the text layer in case the text layer receives event precedence over buttons + // NOTE: Currently only applicable to documents. Need to find a better way to ensure button event precedence. + if (!evt || (event.target && event.target.className !== 'textLayer')) { return; } @@ -459,6 +461,20 @@ export function eventToLocationHandler(locationFunction, callback) { }; } +/** + * Call preventDefault and stopPropagation on an event + * + * @param {event} event - Event object to stop event bubbling + * @return {void} + */ +export function prevDefAndStopProp(event) { + if (!event) { + return; + } + + event.preventDefault(); + event.stopPropagation(); +} /** * Create a JSON object containing x/y coordinates and optionally dimensional information * diff --git a/src/lib/annotations/doc/DocAnnotator.js b/src/lib/annotations/doc/DocAnnotator.js index 754c32557..d74e27471 100644 --- a/src/lib/annotations/doc/DocAnnotator.js +++ b/src/lib/annotations/doc/DocAnnotator.js @@ -485,6 +485,8 @@ class DocAnnotator extends Annotator { this.highlightThrottleHandle = null; } + Object.values(this.modeControllers).forEach((controller) => controller.removeSelection()); + if (this.hasTouch && this.isMobile) { document.removeEventListener('selectionchange', this.onSelectionChange); this.annotatedElement.removeEventListener('touchstart', this.drawingSelectionHandler); diff --git a/src/lib/annotations/doc/DocDrawingDialog.js b/src/lib/annotations/doc/DocDrawingDialog.js index f2f6fb37d..940228c94 100644 --- a/src/lib/annotations/doc/DocDrawingDialog.js +++ b/src/lib/annotations/doc/DocDrawingDialog.js @@ -1,6 +1,8 @@ import AnnotationDialog from '../AnnotationDialog'; class DocDrawingDialog extends AnnotationDialog { + visible = false; + /** * Empty stub to avoid unexpected behavior. * @@ -36,6 +38,10 @@ class DocDrawingDialog extends AnnotationDialog { * @return {void} */ show() {} + + isVisible() { + return this.visible; + } } export default DocDrawingDialog; diff --git a/src/lib/annotations/doc/DocDrawingThread.js b/src/lib/annotations/doc/DocDrawingThread.js index 537ae6516..f402475ed 100644 --- a/src/lib/annotations/doc/DocDrawingThread.js +++ b/src/lib/annotations/doc/DocDrawingThread.js @@ -30,6 +30,7 @@ class DocDrawingThread extends DrawingThread { this.onPageChange = this.onPageChange.bind(this); this.reconstructBrowserCoordFromLocation = this.reconstructBrowserCoordFromLocation.bind(this); } + /** * Handle a pointer movement * @@ -84,6 +85,10 @@ class DocDrawingThread extends DrawingThread { this.pendingPath = new DrawingPath(); } + if (this.dialog && this.dialog.isVisible()) { + this.dialog.hide(); + } + // Start drawing rendering this.lastAnimationRequestId = window.requestAnimationFrame(this.render); } @@ -134,14 +139,10 @@ class DocDrawingThread extends DrawingThread { * @return {void} */ saveAnnotation(type, text) { - this.emit('annotationevent', { - type: 'drawcommit' - }); this.reset(); // Only make save request to server if there exist paths to save - const { undoCount } = this.pathContainer.getNumberOfItems(); - if (undoCount === 0) { + if (this.pathContainer.isUndoEmpty()) { return; } @@ -149,8 +150,7 @@ class DocDrawingThread extends DrawingThread { super.saveAnnotation(type, text); // Move the in-progress drawing to the concrete context - const inProgressCanvas = this.drawingContext.canvas; - this.drawingContext.clearRect(0, 0, inProgressCanvas.width, inProgressCanvas.height); + this.createDialog(); this.show(); } @@ -167,7 +167,7 @@ class DocDrawingThread extends DrawingThread { // Get the annotation layer context to draw with const context = this.selectContext(); - if (this.state === STATES.pending) { + if (this.dialog && this.dialog.isVisible()) { this.drawBoundary(); } @@ -183,6 +183,53 @@ class DocDrawingThread extends DrawingThread { this.draw(context, false); } + /** + * Binds custom event listeners for the dialog. + * + * @protected + * @return {void} + */ + bindCustomListenersOnDialog() { + if (!this.dialog) { + return; + } + + this.dialog.addListener('annotationcreate', () => { + this.emit('annotationevent', { + type: 'softcommit' + }); + }); + this.dialog.addListener('annotationdelete', () => { + this.emit('annotationevent', { + type: 'dialogdelete' + }); + }); + } + + /** + * Creates the document drawing annotation dialog for the thread. + * + * @protected + * @override + * @return {void} + */ + createDialog() { + if (this.dialog) { + this.dialog.destroy(); + } + + this.dialog = new DocDrawingDialog({ + annotatedElement: this.annotatedElement, + container: this.container, + annotations: this.annotations, + locale: this.locale, + location: this.location, + canAnnotate: this.annotationService.canAnnotate + }); + + this.bindCustomListenersOnDialog(); + } + /** * 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. @@ -215,7 +262,7 @@ class DocDrawingThread extends DrawingThread { onPageChange(location) { this.handleStop(); this.emit('annotationevent', { - type: 'pagechanged', + type: 'softcommit', location }); } @@ -277,23 +324,6 @@ class DocDrawingThread extends DrawingThread { return [x1, y1, width, height]; } - - /** - * Creates the document drawing annotation dialog for the thread. - * - * @override - * @return {void} - */ - createDialog() { - this.dialog = new DocDrawingDialog({ - annotatedElement: this.annotatedElement, - container: this.container, - annotations: this.annotations, - locale: this.locale, - location: this.location, - canAnnotate: this.annotationService.canAnnotate - }); - } } export default DocDrawingThread; diff --git a/src/lib/annotations/doc/__tests__/DocAnnotator-test.js b/src/lib/annotations/doc/__tests__/DocAnnotator-test.js index 1a57cff05..ef2470d47 100644 --- a/src/lib/annotations/doc/__tests__/DocAnnotator-test.js +++ b/src/lib/annotations/doc/__tests__/DocAnnotator-test.js @@ -538,7 +538,7 @@ describe('lib/annotations/doc/DocAnnotator', () => { }); it('should bind DOM listeners if user can annotate', () => { - annotator.canAnnotate = true; + annotator.permissions.canAnnotate = true; stubs.elMock.expects('addEventListener').withArgs('mouseup', sinon.match.func); stubs.elMock.expects('addEventListener').withArgs('dblclick', sinon.match.func); @@ -573,7 +573,7 @@ describe('lib/annotations/doc/DocAnnotator', () => { }); it('should unbind DOM listeners if user can annotate', () => { - annotator.canAnnotate = true; + annotator.permissions.canAnnotate = true; stubs.elMock.expects('removeEventListener').withArgs('mouseup', sinon.match.func); stubs.elMock.expects('removeEventListener').withArgs('mousedown', sinon.match.func); @@ -586,7 +586,7 @@ describe('lib/annotations/doc/DocAnnotator', () => { it('should stop and destroy the requestAnimationFrame handle created by getHighlightMousemoveHandler()', () => { const rafHandle = 12; // RAF handles are integers - annotator.canAnnotate = true; + annotator.permissions.canAnnotate = true; annotator.highlightThrottleHandle = rafHandle; sandbox.stub(annotator, 'getHighlightMouseMoveHandler').returns(sandbox.stub()); @@ -609,6 +609,18 @@ describe('lib/annotations/doc/DocAnnotator', () => { expect(docStopListen).to.be.calledWith('selectionchange', sinon.match.func); expect(annotatedElementStopListen).to.be.calledWith('touchstart', sinon.match.func); }); + + it('should tell controllers to clean up selections', () => { + annotator.permissions.canAnnotate = true; + annotator.modeControllers = { + 'test': { + removeSelection: sandbox.stub() + } + }; + + annotator.unbindDOMListeners(); + expect(annotator.modeControllers['test'].removeSelection).to.be.called; + }); }); describe('bindCustomListenersOnThread()', () => { @@ -1299,7 +1311,8 @@ describe('lib/annotations/doc/DocAnnotator', () => { describe('drawingSelectionHandler()', () => { it('should use the controller to select with the event', () => { const drawController = { - handleSelection: sandbox.stub() + handleSelection: sandbox.stub(), + removeSelection: sandbox.stub() }; annotator.modeControllers = { [TYPES.draw]: drawController diff --git a/src/lib/annotations/doc/__tests__/DocDrawingThread-test.js b/src/lib/annotations/doc/__tests__/DocDrawingThread-test.js index f465dedfa..04660f2f0 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 DocDrawingDialog from '../DocDrawingDialog'; import AnnotationThread from '../../AnnotationThread'; import DrawingPath from '../../drawing/DrawingPath'; import { @@ -113,6 +114,7 @@ describe('lib/annotations/doc/DocDrawingThread', () => { expect(docDrawingThread.onPageChange).to.be.called; expect(docDrawingThread.checkAndHandleScaleUpdate).to.not.be.called; }); + }); describe('handleStop()', () => { @@ -145,7 +147,8 @@ describe('lib/annotations/doc/DocDrawingThread', () => { docDrawingThread.dialog = { value: 'non-empty', removeAllListeners: () => {}, - destroy: () => {} + destroy: () => {}, + isVisible: () => false } docDrawingThread.handleStop(); @@ -158,7 +161,7 @@ describe('lib/annotations/doc/DocDrawingThread', () => { sandbox.stub(docDrawingThread, 'handleStop'); docDrawingThread.addListener('annotationevent', (data) => { expect(docDrawingThread.handleStop).to.be.called; - expect(data.type).to.equal('pagechanged'); + expect(data.type).to.equal('softcommit'); done(); }); @@ -221,6 +224,7 @@ describe('lib/annotations/doc/DocDrawingThread', () => { beforeEach(() => { stubs.setBoundary = sandbox.stub(docDrawingThread, 'setBoundary'); stubs.show = sandbox.stub(docDrawingThread, 'show'); + stubs.createDialog = sandbox.stub(docDrawingThread, 'createDialog'); Object.defineProperty(AnnotationThread.prototype, 'saveAnnotation', { value: sandbox.stub() }); }); @@ -230,20 +234,14 @@ 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 - }); + sandbox.stub(docDrawingThread.pathContainer, 'isUndoEmpty').returns(true); docDrawingThread.saveAnnotation('draw'); - expect(docDrawingThread.pathContainer.getNumberOfItems).to.be.called; + expect(docDrawingThread.pathContainer.isUndoEmpty).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' - }); expect(stubs.show).to.not.be.called; + expect(stubs.createDialog).to.not.be.called; }); it('should clean up and commit in-progress drawings when there are paths to be saved', () => { @@ -259,17 +257,15 @@ describe('lib/annotations/doc/DocDrawingThread', () => { clearRect: sandbox.stub() }; - sandbox.stub(docDrawingThread.pathContainer, 'getNumberOfItems').returns({ - undoCount: 1, - redoCount: 0 - }); + sandbox.stub(docDrawingThread.pathContainer, 'isUndoEmpty').returns(false); docDrawingThread.saveAnnotation('draw'); - expect(stubs.show).to.be.called; - expect(stubs.setBoundary).to.be.called; - expect(docDrawingThread.pathContainer.getNumberOfItems).to.be.called; + expect(docDrawingThread.pathContainer.isUndoEmpty).to.be.called; expect(docDrawingThread.drawingContext.clearRect).to.be.called; expect(AnnotationThread.prototype.saveAnnotation).to.be.called; + expect(stubs.show).to.be.called; + expect(stubs.setBoundary).to.be.called; + expect(stubs.createDialog).to.be.called; }); }); @@ -317,31 +313,43 @@ describe('lib/annotations/doc/DocDrawingThread', () => { docDrawingThread.pathContainer = { applyToItems: sandbox.stub() }; + + docDrawingThread.annotatedElement = 'annotatedEl'; + docDrawingThread.location = 'loc'; }); it('should do nothing when no element is assigned to the DocDrawingThread', () => { docDrawingThread.annotatedElement = undefined; - docDrawingThread.location = 'loc'; docDrawingThread.show(); 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.selectContext).to.not.be.called; }); it('should draw the paths in the thread', () => { - docDrawingThread.annotatedElement = 'annotatedEl'; - docDrawingThread.location = 'loc'; docDrawingThread.state = 'not pending'; - - docDrawingThread.show() + docDrawingThread.show(); expect(docDrawingThread.selectContext).to.be.called; expect(docDrawingThread.draw).to.be.called; }); + + it('should draw the boundary when a dialog exists and is visible', () => { + sandbox.stub(docDrawingThread, 'drawBoundary'); + docDrawingThread.dialog = { + isVisible: sandbox.stub().returns(true), + destroy: () => {}, + removeAllListeners: () => {}, + hide: () => {} + } + + docDrawingThread.show(); + expect(docDrawingThread.dialog.isVisible).to.be.called; + expect(docDrawingThread.drawBoundary).to.be.called; + }) }); describe('selectContext()', () => { @@ -412,4 +420,40 @@ describe('lib/annotations/doc/DocDrawingThread', () => { expect(value).to.deep.equal([5, 5, 45, 40]); }); }); + + describe('createDialog()', () => { + it('should create a new doc drawing dialog', () => { + const existingDialog = { + destroy: sandbox.stub() + }; + + sandbox.stub(docDrawingThread, 'bindCustomListenersOnDialog'); + docDrawingThread.dialog = existingDialog; + docDrawingThread.annotationService = { + canAnnotate: true + }; + + docDrawingThread.createDialog(); + + expect(existingDialog.destroy).to.be.called; + expect(docDrawingThread.dialog instanceof DocDrawingDialog).to.be.truthy; + expect(docDrawingThread.bindCustomListenersOnDialog).to.be.called; + }); + }); + + describe('bindCustomListenersOnDialog', () => { + it('should bind listeners on the dialog', () => { + docDrawingThread.dialog = { + addListener: sandbox.stub(), + removeAllListeners: sandbox.stub(), + hide: sandbox.stub(), + destroy: sandbox.stub(), + isVisible: sandbox.stub() + }; + + docDrawingThread.bindCustomListenersOnDialog(); + expect(docDrawingThread.dialog.addListener).to.be.calledWith('annotationcreate', sinon.match.func); + expect(docDrawingThread.dialog.addListener).to.be.calledWith('annotationdelete', sinon.match.func); + }); + }); }); diff --git a/src/lib/annotations/drawing/DrawingContainer.js b/src/lib/annotations/drawing/DrawingContainer.js index 965a90398..55400db14 100644 --- a/src/lib/annotations/drawing/DrawingContainer.js +++ b/src/lib/annotations/drawing/DrawingContainer.js @@ -36,7 +36,7 @@ class DrawingContainer { * @return {boolean} Whether or not an undo was done. */ undo() { - if (this.undoStack.length === 0) { + if (this.isUndoEmpty()) { return false; } @@ -60,6 +60,15 @@ class DrawingContainer { return true; } + /** + * Return whether or not there are objects that can be undone + * + * @return {boolean} Whether or not there exists committed items + */ + isUndoEmpty() { + return this.undoStack.length === 0; + } + /** * Retrieve a JSON blob containing the number of undo and redo in each stack. * diff --git a/src/lib/annotations/drawing/DrawingModeController.js b/src/lib/annotations/drawing/DrawingModeController.js index 1f2a57623..b5b69b596 100644 --- a/src/lib/annotations/drawing/DrawingModeController.js +++ b/src/lib/annotations/drawing/DrawingModeController.js @@ -3,6 +3,7 @@ import AnnotationModeController from '../AnnotationModeController'; import * as annotatorUtil from '../annotatorUtil'; import { TYPES, + STATES, SELECTOR_ANNOTATION_BUTTON_DRAW_POST, SELECTOR_ANNOTATION_BUTTON_DRAW_UNDO, SELECTOR_ANNOTATION_BUTTON_DRAW_REDO, @@ -95,6 +96,34 @@ class DrawingModeController extends AnnotationModeController { thread.addListener('threaddeleted', () => this.unregisterThread(thread)); } + /** + * Unbind drawing mode listeners. Resets the undo and redo buttons to be disabled if they exist + * + * @inheritdoc + * @protected + * @return {void} + */ + unbindModeListeners() { + super.unbindModeListeners(); + + annotatorUtil.disableElement(this.undoButtonEl); + annotatorUtil.disableElement(this.redoButtonEl); + } + + /** + * Deselect a saved and selected thread + * + * @return {void} + */ + removeSelection() { + if (!this.selectedThread) { + return; + } + + this.selectedThread.clearBoundary(); + this.selectedThread = undefined; + } + /** * Set up and return the necessary handlers for the annotation mode * @@ -151,19 +180,33 @@ class DrawingModeController extends AnnotationModeController { // 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 + case 'softcommit': + // Save the original thread, create a new thread and // start drawing at the location indicating the page change this.currentThread = undefined; thread.saveAnnotation(TYPES.draw); this.unbindModeListeners(); - this.bindModeListeners(TYPES.draw); - this.currentThread.handleStart(data.location); + this.bindModeListeners(); + + // Given a location (page change) start drawing at the provided location + if (data.location) { + this.currentThread.handleStart(data.location); + } + + break; + case 'dialogdelete': + if (thread.state === STATES.pending) { + // Soft delete, in-progress thread doesn't require a redraw or a delete on the server + // Clear in-progress thread and restart drawing + thread.destroy(); + this.unbindModeListeners(); + this.bindModeListeners(); + } else { + // Redraw any threads that the deleted thread could have been overlapping + thread.deleteThread(); + this.threads.search(thread).forEach((drawingThread) => drawingThread.show()); + } + break; case 'availableactions': this.updateUndoRedoButtonEls(data.undo, data.redo); @@ -202,10 +245,7 @@ class DrawingModeController extends AnnotationModeController { .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); - } + this.removeSelection(); // Selected a region with no drawing threads, remove the reference to the previously selected thread if (intersectingThreads.length === 0) { @@ -213,7 +253,7 @@ class DrawingModeController extends AnnotationModeController { return; } - // Randomly select a thread in case there are multiple + // Randomly select a thread in case there are multiple overlapping threads (use canvas hitmap to avoid this) const index = Math.floor(Math.random() * intersectingThreads.length); const selected = intersectingThreads[index]; this.select(selected); @@ -227,20 +267,8 @@ class DrawingModeController extends AnnotationModeController { * @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; - } + selectedDrawingThread.drawBoundary(); + this.selectedThread = selectedDrawingThread; } /** diff --git a/src/lib/annotations/drawing/DrawingThread.js b/src/lib/annotations/drawing/DrawingThread.js index f7272bfd3..2b220bcda 100644 --- a/src/lib/annotations/drawing/DrawingThread.js +++ b/src/lib/annotations/drawing/DrawingThread.js @@ -90,9 +90,9 @@ class DrawingThread extends AnnotationThread { window.cancelAnimationFrame(this.lastAnimationRequestId); } - if (this.drawingContext) { - const canvas = this.drawingContext.canvas; - this.drawingContext.clearRect(0, 0, canvas.width, canvas.height); + if (this.dialog) { + this.dialog.destroy(); + this.dialog = null; } super.destroy(); @@ -100,6 +100,11 @@ class DrawingThread extends AnnotationThread { this.emit('threadcleanup'); } + reset() { + super.reset(); + + this.clearBoundary(); + } /* eslint-disable no-unused-vars */ /** * Handle a pointer movement @@ -140,7 +145,7 @@ class DrawingThread extends AnnotationThread { // Calculate the bounding rectangle const [x, y, width, height] = this.getBrowserRectangularBoundary(); - // Clear the drawn thread and destroy it + // Clear the drawn thread and its boundary this.concreteContext.clearRect( x - DRAW_BORDER_OFFSET, y + DRAW_BORDER_OFFSET, @@ -148,8 +153,7 @@ class DrawingThread extends AnnotationThread { height - DRAW_BORDER_OFFSET * 2 ); - // Notifies that the thread was destroyed so that observers can react accordingly - this.destroy(); + this.clearBoundary(); } /** @@ -307,6 +311,14 @@ class DrawingThread extends AnnotationThread { // Restore context style this.drawingContext.restore(); + + if (this.dialog) { + if (!this.dialog.isVisible() && !this.pathContainer.isUndoEmpty()) { + this.showDialog(); + } + + this.dialog.position(x + width, y); + } } /** @@ -372,6 +384,10 @@ class DrawingThread extends AnnotationThread { this.maxX = boundaryData.maxX; this.minY = boundaryData.minY; this.maxY = boundaryData.maxY; + + if (this.dialog && this.pathContainer.isUndoEmpty()) { + this.dialog.hide(); + } } /** @@ -382,6 +398,24 @@ class DrawingThread extends AnnotationThread { * @return {void} */ getBrowserRectangularBoundary() {} + + /** + * Clear any drawn boundary and associated dialog + * + * @return {void} + */ + clearBoundary() { + if (this.drawingContext) { + const canvas = this.drawingContext.canvas; + this.drawingContext.clearRect(0, 0, canvas.width, canvas.height); + } + + if (!this.dialog || !this.dialog.isVisible()) { + return; + } + + this.dialog.hide(); + } } export default DrawingThread; diff --git a/src/lib/annotations/drawing/__tests__/DrawingContainer-test.js b/src/lib/annotations/drawing/__tests__/DrawingContainer-test.js index dd297ab64..2dc79040c 100644 --- a/src/lib/annotations/drawing/__tests__/DrawingContainer-test.js +++ b/src/lib/annotations/drawing/__tests__/DrawingContainer-test.js @@ -167,4 +167,16 @@ describe('lib/annotations/drawing/DrawingContainer', () => { }); }); }); + + describe('isUndoEmpty()', () => { + it('should return true when no items are in the undoStack', () => { + drawingContainer.undoStack = []; + expect(drawingContainer.isUndoEmpty()).to.be.truthy; + }); + + it('should return false when there are items are in the undoStack', () => { + drawingContainer.undoStack = ['one']; + expect(drawingContainer.isUndoEmpty()).to.be.falsy; + }); + }); }); diff --git a/src/lib/annotations/drawing/__tests__/DrawingModeController-test.js b/src/lib/annotations/drawing/__tests__/DrawingModeController-test.js index 886f447f7..62e98f64f 100644 --- a/src/lib/annotations/drawing/__tests__/DrawingModeController-test.js +++ b/src/lib/annotations/drawing/__tests__/DrawingModeController-test.js @@ -146,6 +146,18 @@ describe('lib/annotations/drawing/DrawingModeController', () => { }); }); + describe('unbindModeListeners()', () => { + it('should disable undo and redo buttons', () => { + sandbox.stub(annotatorUtil, 'disableElement'); + + drawingModeController.undoButtonEl = 'test1'; + drawingModeController.redoButtonEl = 'test2'; + drawingModeController.unbindModeListeners(); + expect(annotatorUtil.disableElement).to.be.calledWith(drawingModeController.undoButtonEl); + expect(annotatorUtil.disableElement).to.be.calledWith(drawingModeController.redoButtonEl); + }); + }); + describe('handleAnnotationEvent()', () => { it('should add thread to map on locationassigned', () => { const thread = 'obj'; @@ -159,15 +171,21 @@ describe('lib/annotations/drawing/DrawingModeController', () => { expect(drawingModeController.annotator.addThreadToMap).to.be.called; }); - it('should remove annotationevent listeners from the thread on drawcommit', () => { + it('should restart mode listeners from the thread on softcommit', () => { const thread = { - removeAllListeners: sandbox.stub() + saveAnnotation: sandbox.stub(), + handleStart: sandbox.stub() }; + sandbox.stub(drawingModeController, 'unbindModeListeners'); + sandbox.stub(drawingModeController, 'bindModeListeners'); drawingModeController.handleAnnotationEvent(thread, { - type: 'drawcommit' + type: 'softcommit' }); - expect(thread.removeAllListeners).to.be.calledWith('annotationevent'); + expect(drawingModeController.unbindModeListeners).to.be.called; + expect(drawingModeController.bindModeListeners).to.be.called; + expect(thread.saveAnnotation).to.be.called; + expect(thread.handleStart).to.not.be.called; }); it('should start a new thread on pagechanged', () => { @@ -178,7 +196,7 @@ describe('lib/annotations/drawing/DrawingModeController', () => { handleStart: sandbox.stub() }; const data = { - type: 'pagechanged', + type: 'softcommit', location: 'not empty' }; sandbox.stub(drawingModeController, 'unbindModeListeners'); @@ -204,6 +222,38 @@ describe('lib/annotations/drawing/DrawingModeController', () => { }); expect(drawingModeController.updateUndoRedoButtonEls).to.be.calledWith(1, 2); }); + + it('should soft delete a pending thread and restart mode listeners', () => { + const thread = { + state: 'pending', + destroy: sandbox.stub() + }; + + sandbox.stub(drawingModeController, 'unbindModeListeners'); + sandbox.stub(drawingModeController, 'bindModeListeners'); + drawingModeController.handleAnnotationEvent(thread, { + type: 'dialogdelete' + }); + expect(thread.destroy).to.be.called; + expect(drawingModeController.unbindModeListeners).to.be.called; + expect(drawingModeController.bindModeListeners).to.be.called; + }); + + it('should delete a non-pending thread', () => { + const thread = { + state: 'idle', + deleteThread: sandbox.stub() + }; + drawingModeController.threads = { + search: sandbox.stub().returns([]) + }; + + drawingModeController.handleAnnotationEvent(thread, { + type: 'dialogdelete' + }); + expect(thread.deleteThread).to.be.called; + expect(drawingModeController.threads.search).to.be.called; + }); }); describe('handleSelection()', () => { @@ -221,6 +271,7 @@ describe('lib/annotations/drawing/DrawingModeController', () => { it('should call select on an thread found in the data store', () => { stubs.select = sandbox.stub(drawingModeController, 'select'); + stubs.clean = sandbox.stub(drawingModeController, 'removeSelection'); stubs.getLoc.returns({ x: 5, y: 5 @@ -237,10 +288,24 @@ describe('lib/annotations/drawing/DrawingModeController', () => { drawingModeController.handleSelection('event'); expect(drawingModeController.threads.search).to.be.called; expect(filterObjects.filter).to.be.called; + expect(stubs.clean).to.be.called; expect(stubs.select).to.be.calledWith(filteredObject); }); }); + describe('removeSelection()', () => { + it('should clean a selected thread boundary', () => { + const thread = { + clearBoundary: sandbox.stub() + }; + drawingModeController.selectedThread = thread; + + drawingModeController.removeSelection(); + expect(thread.clearBoundary).to.be.called; + expect(drawingModeController.selectedThread).to.be.undefined; + }); + }); + describe('select()', () => { it('should draw the boundary', () => { const thread = { diff --git a/src/lib/annotations/drawing/__tests__/DrawingThread-test.js b/src/lib/annotations/drawing/__tests__/DrawingThread-test.js index c98ab687b..cd5df6156 100644 --- a/src/lib/annotations/drawing/__tests__/DrawingThread-test.js +++ b/src/lib/annotations/drawing/__tests__/DrawingThread-test.js @@ -58,26 +58,33 @@ describe('lib/annotations/drawing/DrawingThread', () => { expect(window.cancelAnimationFrame).to.be.calledWith(1); expect(drawingThread.reset).to.be.called; - expect(drawingThread.drawingContext.clearRect).to.be.called; }) }); + describe('reset()', () => { + it('should clear the boundary', () => { + sandbox.stub(drawingThread, 'clearBoundary'); + drawingThread.reset(); + expect(drawingThread.clearBoundary).to.be.called; + }); + }) + describe('deleteThread()', () => { it('should delete all attached annotations, clear the drawn rectangle, and call destroy', () => { sandbox.stub(drawingThread, 'getBrowserRectangularBoundary').returns(['a', 'b', 'c', 'd']); - sandbox.stub(drawingThread, 'destroy'); + sandbox.stub(drawingThread, 'deleteAnnotationWithID'); + sandbox.stub(drawingThread, 'clearBoundary'); drawingThread.concreteContext = { clearRect: sandbox.stub() }; - drawingThread.annotations = { - forEach: sandbox.stub() - }; + drawingThread.annotations = ['annotation']; + drawingThread.deleteThread(); expect(drawingThread.getBrowserRectangularBoundary).to.be.called; expect(drawingThread.concreteContext.clearRect).to.be.called; - expect(drawingThread.annotations.forEach).to.be.called; - expect(drawingThread.destroy).to.be.called; + expect(drawingThread.clearBoundary).to.be.called; + expect(drawingThread.deleteAnnotationWithID).to.be.calledWith('annotation'); }); }); @@ -340,4 +347,28 @@ describe('lib/annotations/drawing/DrawingThread', () => { expect(drawingThread.maxY).to.equal(drawingThread.location.maxY); }); }); + + describe('clearBoundary()', () => { + it('should clear the drawing context and hide any dialog', () => { + drawingThread.drawingContext = { + canvas: { + width: 100, + height: 100 + }, + clearRect: sandbox.stub() + }; + + drawingThread.dialog = { + isVisible: sandbox.stub().returns(true), + hide: sandbox.stub(), + destroy: () => {}, + removeAllListeners: () => {} + }; + + drawingThread.clearBoundary(); + expect(drawingThread.drawingContext.clearRect).to.be.called; + expect(drawingThread.dialog.isVisible).to.be.called; + expect(drawingThread.dialog.hide).to.be.called; + }); + }); }); diff --git a/src/lib/icons/draw_delete.svg b/src/lib/icons/draw_delete.svg new file mode 100644 index 000000000..bb4bfdd3a --- /dev/null +++ b/src/lib/icons/draw_delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/icons/draw_save.svg b/src/lib/icons/draw_save.svg new file mode 100644 index 000000000..3fd90af6c --- /dev/null +++ b/src/lib/icons/draw_save.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/icons/icons.js b/src/lib/icons/icons.js index cc88816de..c969c9bc6 100644 --- a/src/lib/icons/icons.js +++ b/src/lib/icons/icons.js @@ -40,6 +40,8 @@ import FIND_DROP_UP from './arrow_drop_up.svg'; import CLOSE from './close.svg'; import SEARCH from './search.svg'; import PRINT_CHECKMARK from './print_checkmark.svg'; +import DRAW_SAVE from './draw_save.svg'; +import DRAW_DELETE from './draw_delete.svg'; export const ICON_ANNOTATION = ANNOTATION; export const ICON_HIGHLIGHT_COMMENT = ANNOTATION_HIGHLIGHT_COMMENT; @@ -83,3 +85,5 @@ export const ICON_FIND_DROP_UP = FIND_DROP_UP; export const ICON_CLOSE = CLOSE; export const ICON_SEARCH = SEARCH; export const ICON_PRINT_CHECKMARK = PRINT_CHECKMARK; +export const ICON_DRAW_SAVE = DRAW_SAVE; +export const ICON_DRAW_DELETE = DRAW_DELETE;